/*

HOME ASSISTANT MODULE

Original module
Copyright (C) 2017-2019 by Xose Pérez <xose dot perez at gmail dot com>

Reworked queueing and RAM usage reduction
Copyright (C) 2019-2021 by Maxim Prokhorov <prokhorov dot max at outlook dot com>

*/

#include "espurna.h"

#if HOMEASSISTANT_SUPPORT

#include "light.h"
#include "mqtt.h"
#include "relay.h"
#include "sensor.h"
#include "ws.h"

#include <ArduinoJson.h>

#include <forward_list>
#include <memory>

namespace homeassistant {

// Output is supposed to be used as both part of the MQTT config topic and the `uniq_id` field
// TODO: manage UTF8 strings? in case we somehow receive `desc`, like it was done originally

String normalize_ascii(String&& value, bool lower = false) {
    auto* ptr = const_cast<char*>(value.c_str());
    for (;;) {
        switch (*ptr) {
        case '\0':
            goto return_value;
        case '0' ... '9':
        case 'a' ... 'z':
            break;
        case 'A' ... 'Z':
            if (lower) {
                *ptr += 32;
            }
            break;
        default:
            *ptr = '_';
            break;
        }
        ++ptr;
    }

return_value:
    return std::move(value);
}

// Common data used across the discovery payloads.
// ref. https://developers.home-assistant.io/docs/entity_registry_index/

class Device {
public:
    struct Strings {
        Strings() = delete;
        Strings(const Strings&) = delete;

        Strings(Strings&&) = default;
        Strings(String&& prefix_, String&& name_, const String& identifier_, const String& version_, const String& manufacturer_, const String& device_) :
            prefix(std::move(prefix_)),
            name(std::move(name_)),
            identifier(identifier_),
            version(version_),
            manufacturer(manufacturer_),
            device(device_)
        {
            name = normalize_ascii(std::move(name));
            identifier = normalize_ascii(std::move(identifier), true);
        }

        String prefix;
        String name;
        String identifier;
        String version;
        String manufacturer;
        String device;
    };

    using StringsPtr = std::unique_ptr<Strings>;

    static constexpr size_t BufferSize { JSON_ARRAY_SIZE(1) + JSON_OBJECT_SIZE(1) + JSON_OBJECT_SIZE(5) };

    using Buffer = StaticJsonBuffer<BufferSize>;
    using BufferPtr = std::unique_ptr<Buffer>;

    Device() = delete;
    Device(const Device&) = delete;

    Device(Device&&) = default;
    Device(String&& prefix, String&& name, const String& identifier, const String& version, const String& manufacturer, const String& device) :
        _strings(std::make_unique<Strings>(std::move(prefix), std::move(name), identifier, version, manufacturer, device)),
        _buffer(std::make_unique<Buffer>()),
        _root(_buffer->createObject())
    {
        JsonArray& ids = _root.createNestedArray("ids");
        ids.add(_strings->identifier.c_str());

        _root["name"] = _strings->name.c_str();
        _root["sw"] = _strings->version.c_str();
        _root["mf"] = _strings->manufacturer.c_str();
        _root["mdl"] = _strings->device.c_str();
    }

    const String& name() const {
        return _strings->name;
    }

    const String& prefix() const {
        return _strings->prefix;
    }

    const String& identifier() const {
        return _strings->identifier;
    }

    JsonObject& root() {
        return _root;
    }

private:
    StringsPtr _strings;
    BufferPtr _buffer;
    JsonObject& _root;
};

using DevicePtr = std::unique_ptr<Device>;
using JsonBufferPtr = std::unique_ptr<DynamicJsonBuffer>;

class Context {
public:
    Context() = delete;
    Context(DevicePtr&& device, size_t capacity) :
        _device(std::move(device)),
        _capacity(capacity)
    {}

    const String& name() const {
        return _device->name();
    }

    const String& prefix() const {
        return _device->prefix();
    }

    const String& identifier() const {
        return _device->identifier();
    }

    JsonObject& device() {
        return _device->root();
    }

    void reset() {
        _json = std::make_unique<DynamicJsonBuffer>(_capacity);
    }

    size_t capacity() const {
        return _capacity;
    }

    size_t size() {
        if (_json) {
            return _json->size();
        }

        return 0;
    }

    JsonObject& makeObject() {
        if (!_json) {
            reset();
        }

        return _json->createObject();
    }

private:
    String _prefix;
    DevicePtr _device;

    JsonBufferPtr _json;
    size_t _capacity { 0ul };
};

Context makeContext() {
    auto device = std::make_unique<Device>(
        getSetting("haPrefix", HOMEASSISTANT_PREFIX),
        getSetting("hostname", getIdentifier()),
        getIdentifier(),
        getVersion(),
        getManufacturer(),
        getDevice()
    );

    return Context(std::move(device), 2048ul);
}

String quote(String&& value) {
    if (value.equalsIgnoreCase("y")
        || value.equalsIgnoreCase("n")
        || value.equalsIgnoreCase("yes")
        || value.equalsIgnoreCase("no")
        || value.equalsIgnoreCase("true")
        || value.equalsIgnoreCase("false")
        || value.equalsIgnoreCase("on")
        || value.equalsIgnoreCase("off")
    ) {
        String result;
        result.reserve(value.length() + 2);
        result += '"';
        result += value;
        result += '"';
        return result;
    }

    return std::move(value);
}

// - Discovery object is expected to accept Context reference as input
//   (and all implementations do just that)
// - topic() & message() return refs, since those *may* be called multiple times before advancing to the next 'entity'
// - We use short-hand names right away, since we don't expect this to be used to generate yaml
// - In case the object uses the JSON makeObject() as state, make sure we don't use it (state)
//   and the object itself after next() or ok() return false
// - Make sure JSON state is not created on construction, but lazy-loaded as soon as it is needed.
//   Meaning, we don't cause invalid refs immediatly when there are more than 1 discovery object present and we reset the storage.

class Discovery {
public:
    virtual ~Discovery() {
    }

    virtual bool ok() const = 0;
    virtual const String& topic() = 0;
    virtual const String& message() = 0;
    virtual bool next() = 0;
};

#if RELAY_SUPPORT

struct RelayContext {
    String availability;
    String payload_available;
    String payload_not_available;
    String payload_on;
    String payload_off;
};

RelayContext makeRelayContext() {
    return {
        mqttTopic(MQTT_TOPIC_STATUS, false),
        quote(mqttPayloadStatus(true)),
        quote(mqttPayloadStatus(false)),
        quote(relayPayload(PayloadStatus::On)),
        quote(relayPayload(PayloadStatus::Off))
    };
}

class RelayDiscovery : public Discovery {
public:
    RelayDiscovery() = delete;
    explicit RelayDiscovery(Context& ctx) :
        _ctx(ctx),
        _relay(makeRelayContext()),
        _relays(relayCount())
    {
        if (!_relays) {
            return;
        }

        auto& json = root();
        json["dev"] = _ctx.device();
        json["avty_t"] = _relay.availability.c_str();
        json["pl_avail"] = _relay.payload_available.c_str();
        json["pl_not_avail"] = _relay.payload_not_available.c_str();
        json["pl_on"] = _relay.payload_on.c_str();
        json["pl_off"] = _relay.payload_off.c_str();
    }

    JsonObject& root() {
        if (!_root) {
            _root = &_ctx.makeObject();
        }

        return *_root;
    }

    bool ok() const override {
        return (_relays) && (_index < _relays);
    }

    const String& uniqueId() {
        if (!_unique_id.length()) {
            _unique_id = _ctx.identifier() + '_' + F("relay") + '_' + _index;
        }
        return _unique_id;
    }

    const String& topic() override {
        if (!_topic.length()) {
            _topic = _ctx.prefix();
            _topic += F("/switch/");
            _topic += uniqueId();
            _topic += F("/config");
        }
        return _topic;
    }

    const String& message() override {
        if (!_message.length()) {
            auto& json = root();
            json["uniq_id"] = uniqueId();
            json["name"] = _ctx.name() + ' ' + _index;
            json["stat_t"] = mqttTopic(MQTT_TOPIC_RELAY, _index, false);
            json["cmd_t"] = mqttTopic(MQTT_TOPIC_RELAY, _index, true);
            json.printTo(_message);
        }
        return _message;
    }

    bool next() override {
        if (_index < _relays) {
            auto current = _index;
            ++_index;
            if ((_index > current) && (_index < _relays)) {
                _unique_id = "";
                _topic = "";
                _message = "";
                return true;
            }
        }

        return false;
    }

private:
    Context& _ctx;
    JsonObject* _root { nullptr };

    RelayContext _relay;
    unsigned char _index { 0u };
    unsigned char _relays { 0u };

    String _unique_id;
    String _topic;
    String _message;
};

#endif

// Example payload:
// {
//  "brightness": 255,
//  "color_temp": 155,
//  "color": {
//    "r": 255,
//    "g": 180,
//    "b": 200,
//    "x": 0.406,
//    "y": 0.301,
//    "h": 344.0,
//    "s": 29.412
//  },
//  "effect": "colorloop",
//  "state": "ON",
//  "transition": 2,
//  "white_value": 150
// }

// Notice that we only support JSON schema payloads, leaving it to the user to configure special
// per-channel topics, as those don't really fit into the HASS idea of lights controls for a single device

#if LIGHT_PROVIDER != LIGHT_PROVIDER_NONE

class LightDiscovery : public Discovery {
public:
    explicit LightDiscovery(Context& ctx) :
        _ctx(ctx)
    {}

    JsonObject& root() {
        if (!_root) {
            _root = &_ctx.makeObject();
        }

        return *_root;
    }

    bool ok() const override {
        return true;
    }

    bool next() override {
        return false;
    }

    const String& uniqueId() {
        if (!_unique_id.length()) {
            _unique_id = _ctx.identifier() + '_' + F("light");
        }

        return _unique_id;
    }

    const String& topic() override {
        if (!_topic.length()) {
            _topic = _ctx.prefix();
            _topic += F("/light/");
            _topic += uniqueId();
            _topic += F("/config");
        }

        return _topic;
    }

    const String& message() override {
        if (!_message.length()) {
            auto& json = root();

            json["schema"] = "json";
            json["uniq_id"] = uniqueId();

            json["name"] = _ctx.name() + ' ' + F("Light");

            json["stat_t"] = mqttTopic(MQTT_TOPIC_LIGHT_JSON, false);
            json["cmd_t"] = mqttTopic(MQTT_TOPIC_LIGHT_JSON, true);

            json["avty_t"] = mqttTopic(MQTT_TOPIC_STATUS, false);
            json["pl_avail"] = quote(mqttPayloadStatus(true));
            json["pl_not_avail"] = quote(mqttPayloadStatus(false));

            // send `true` for every payload we support sending / receiving
            // already enabled by default: "state", "transition"

            json["brightness"] = true;

            // Note that since we send back the values immediately, HS mode sliders
            // *will jump*, as calculations of input do not always match the output.
            // (especially, when gamma table is used, as we modify the results)
            // In case or RGB, channel values input is expected to match the output exactly.

            if (lightHasColor()) {
                if (lightUseRGB()) {
                    json["rgb"] = true;
                } else {
                    json["hs"] = true;
                }
            }

            // Mired is only an input, we never send this value back
            // (...besides the internally pinned value, ref. MQTT_TOPIC_MIRED. not used here though)
            // - in RGB mode, we convert the temperature into a specific color
            // - in CCT mode, white channels are used

            if (lightHasColor() || lightUseCCT()) {
                auto range = lightMiredsRange();
                json["min_mirs"] = range.cold();
                json["max_mirs"] = range.warm();
                json["color_temp"] = true;
            }

            json.printTo(_message);
        }

        return _message;
    }

private:
    Context& _ctx;
    JsonObject* _root { nullptr };

    String _unique_id;
    String _topic;
    String _message;
};

bool heartbeat(heartbeat::Mask mask) {
    // TODO: mask json payload specifically?
    // or, find a way to detach masking from the system setting / don't use heartbeat timer
    if (mask & heartbeat::Report::Light) {
        DynamicJsonBuffer buffer(512);
        JsonObject& root = buffer.createObject();

        auto state = lightState();
        root["state"] = state ? "ON" : "OFF";

        if (state) {
            root["brightness"] = lightBrightness();

            if (lightUseCCT()) {
                root["white_value"] = lightColdWhite();
            }

            if (lightColor()) {
                auto& color = root.createNestedObject("color");
                if (lightUseRGB()) {
                    auto rgb = lightRgb();
                    color["r"] = rgb.red();
                    color["g"] = rgb.green();
                    color["b"] = rgb.blue();
                } else {
                    auto hsv = lightHsv();
                    color["h"] = hsv.hue();
                    color["s"] = hsv.saturation();
                }
            }
        }

        String message;
        root.printTo(message);

        String topic = mqttTopic(MQTT_TOPIC_LIGHT_JSON, false);
        mqttSendRaw(topic.c_str(), message.c_str(), false);
    }

    return true;
}

void publishLightJson() {
    heartbeat(static_cast<heartbeat::Mask>(heartbeat::Report::Light));
}

void receiveLightJson(char* payload) {
    DynamicJsonBuffer buffer(1024);
    JsonObject& root = buffer.parseObject(payload);
    if (!root.success()) {
        return;
    }

    if (!root.containsKey("state")) {
        return;
    }

    auto state = root["state"].as<String>();
    if (state == F("ON")) {
        lightState(true);
    } else if (state == F("OFF")) {
        lightState(false);
    } else {
        return;
    }

    unsigned long transition { lightTransitionTime() };
    if (root.containsKey("transition")) {
        auto seconds = root["transition"].as<float>();
        if (seconds > 0) {
            transition = static_cast<unsigned long>(seconds * 1000.0);
        }
    }

    if (root.containsKey("color_temp")) {
        lightMireds(root["color_temp"].as<long>());
    }

    if (root.containsKey("brightness")) {
        lightBrightness(root["brightness"].as<long>());
    }

    if (lightHasColor() && root.containsKey("color")) {
        JsonObject& color = root["color"];
        if (lightUseRGB()) {
            lightRgb({
                color["r"].as<long>(),
                color["g"].as<long>(),
                color["b"].as<long>()});
        } else {
            lightHs(
                color["h"].as<long>(),
                color["s"].as<long>());
        }
    }

    if (lightUseCCT() && root.containsKey("white_value")) {
        lightColdWhite(root["white_value"].as<long>());
    }

    lightUpdate({transition, lightTransitionStep()});
}

#endif

#if SENSOR_SUPPORT

class SensorDiscovery : public Discovery {
public:
    SensorDiscovery() = delete;
    explicit SensorDiscovery(Context& ctx) :
        _ctx(ctx),
        _magnitudes(magnitudeCount())
    {}

    JsonObject& root() {
        if (!_root) {
            _root = &_ctx.makeObject();
        }

        return *_root;
    }

    bool ok() const {
        return _index < _magnitudes;
    }

    const String& topic() override {
        if (!_topic.length()) {
            _topic = _ctx.prefix();
            _topic += F("/sensor/");
            _topic += uniqueId();
            _topic += F("/config");
        }

        return _topic;
    }

    const String& message() override {
        if (!_message.length()) {
            auto& json = root();
            json["dev"] = _ctx.device();
            json["uniq_id"] = uniqueId();

            json["name"] = _ctx.name() + ' ' + name() + ' ' + localId();
            json["stat_t"] = mqttTopic(magnitudeTopicIndex(_index).c_str(), false);
            json["unit_of_meas"] = magnitudeUnits(_index);

            json.printTo(_message);
        }

        return _message;
    }

    const String& name() {
        if (!_name.length()) {
            _name = magnitudeTopic(magnitudeType(_index));
        }

        return _name;
    }

    unsigned char localId() const {
        return magnitudeIndex(_index);
    }

    const String& uniqueId() {
        if (!_unique_id.length()) {
            _unique_id = _ctx.identifier() + '_' + name() + '_' + localId();
        }

        return _unique_id;
    }

    bool next() override {
        if (_index < _magnitudes) {
            auto current = _index;
            ++_index;
            if ((_index > current) && (_index < _magnitudes)) {
                _unique_id = "";
                _name = "";
                _topic = "";
                _message = "";
                return true;
            }
        }

        return false;
    }

private:
    Context& _ctx;
    JsonObject* _root { nullptr };

    unsigned char _index { 0u };
    unsigned char _magnitudes { 0u };

    String _unique_id;
    String _name;
    String _topic;
    String _message;
};

#endif

// Reworked discovery class. Try to send and wait for MQTT QoS 1 publish ACK to continue.
// Topic and message are generated on demand and most of JSON payload is cached for re-use to save RAM.

class DiscoveryTask {
public:
    using Entity = std::unique_ptr<Discovery>;
    using Entities = std::forward_list<Entity>;

    static constexpr int Retries { 5 };
    static constexpr unsigned long WaitShortMs { 100ul };
    static constexpr unsigned long WaitLongMs { 1000ul };

    DiscoveryTask(bool enabled) :
        _enabled(enabled)
    {}

    void add(Entity&& entity) {
        _entities.push_front(std::move(entity));
    }

    template <typename T>
    void add() {
        _entities.push_front(std::make_unique<T>(_ctx));
    }

    bool retry() {
        if (_retry < 0) {
            return false;
        }

        return (--_retry > 0);
    }

    Context& context() {
        return _ctx;
    }

    bool done() const {
        return _entities.empty();
    }

    bool ok() const {
        if ((_retry > 0) && !_entities.empty()) {
            auto& entity = _entities.front();
            return entity->ok();
        }

        return false;
    }

    template <typename T>
    bool send(T&& action) {
        while (!_entities.empty()) {
            auto& entity = _entities.front();
            if (!entity->ok()) {
                _entities.pop_front();
                _ctx.reset();
                continue;
            }

            const auto* topic = entity->topic().c_str();
            const auto* msg = _enabled
                ? entity->message().c_str()
                : "";

            if (action(topic, msg)) {
                if (!entity->next()) {
                    _retry = Retries;
                    _entities.pop_front();
                    _ctx.reset();
                }
                return true;
            }

            return false;
        }

        return false;
    }

private:
    bool _enabled { false };

    int _retry { Retries };
    Context _ctx { makeContext() };
    Entities _entities;
};

namespace internal {

using TaskPtr = std::shared_ptr<DiscoveryTask>;
using FlagPtr = std::shared_ptr<bool>;

bool retain { false };
bool enabled { false };

enum class State {
    Initial,
    Pending,
    Sent
};

State state { State::Initial };
Ticker timer;

void send(TaskPtr ptr, FlagPtr flag_ptr);

void stop(bool done) {
    timer.detach();
    if (done) {
        DEBUG_MSG_P(PSTR("[HA] Stopping discovery\n"));
        state = State::Sent;
    } else {
        DEBUG_MSG_P(PSTR("[HA] Discovery error\n"));
        state = State::Pending;
    }
}

void schedule(unsigned long wait, TaskPtr ptr, FlagPtr flag_ptr) {
    internal::timer.once_ms_scheduled(wait, [ptr, flag_ptr]() {
        send(ptr, flag_ptr);
    });
}

void schedule(TaskPtr ptr, FlagPtr flag_ptr) {
    schedule(DiscoveryTask::WaitShortMs, ptr, flag_ptr);
}

void schedule(TaskPtr ptr) {
    schedule(DiscoveryTask::WaitShortMs, ptr, std::make_shared<bool>(true));
}

void send(TaskPtr ptr, FlagPtr flag_ptr) {
    auto& task = *ptr;
    if (!mqttConnected() || task.done()) {
        stop(true);
        return;
    }

    auto& flag = *flag_ptr;
    if (!flag) {
        if (task.retry()) {
            schedule(ptr, flag_ptr);
        } else {
            stop(false);
        }
        return;
    }

    uint16_t pid { 0u };
    auto res = task.send([&](const char* topic, const char* message) {
        pid = ::mqttSendRaw(topic, message, internal::retain, 1);
        return pid > 0;
    });

#if MQTT_LIBRARY == MQTT_LIBRARY_ASYNCMQTTCLIENT
    // - async fails when disconneted and when it's buffers are filled, which should be resolved after $LATENCY
    // and the time it takes for the lwip to process it. future versions use queue, but could still fail when low on RAM
    // - lwmqtt will fail when disconnected (already checked above) and *will* disconnect in case publish fails.
    // ::publish() will wait for the puback, so we don't have to do it ourselves. not tested.
    // - pubsub will fail when it can't buffer the payload *or* the underlying WiFiClient calls fail. also not tested.

    if (res) {
        flag = false;
        mqttOnPublish(pid, [flag_ptr]() {
            (*flag_ptr) = true;
        });
    }
#endif

    auto wait = res
        ? DiscoveryTask::WaitShortMs
        : DiscoveryTask::WaitLongMs;

    if (res || task.retry()) {
        schedule(wait, ptr, flag_ptr);
        return;
    }

    stop(false);
}

} // namespace internal

void publishDiscovery() {
    if (!mqttConnected() || internal::timer.active() || (internal::state != internal::State::Pending)) {
        return;
    }

    auto task = std::make_shared<DiscoveryTask>(internal::enabled);

#if LIGHT_PROVIDER != LIGHT_PROVIDER_NONE
    task->add<LightDiscovery>();
#endif
#if RELAY_SUPPORT
    task->add<RelayDiscovery>();
#endif
#if SENSOR_SUPPORT
    task->add<SensorDiscovery>();
#endif

    // only happens when nothing is configured to do the add()
    if (task->done()) {
        return;
    }

    internal::schedule(task);
}

void configure() {
    bool current = internal::enabled;
    internal::enabled = getSetting("haEnabled", 1 == HOMEASSISTANT_ENABLED);
    internal::retain = getSetting("haRetain", 1 == HOMEASSISTANT_RETAIN);

    if (internal::enabled != current) {
        internal::state = internal::State::Pending;
    }

    homeassistant::publishDiscovery();
}

void mqttCallback(unsigned int type, const char* topic, char* payload) {
    if (MQTT_DISCONNECT_EVENT == type) {
        if (internal::state == internal::State::Sent) {
            internal::state = internal::State::Pending;
        }
        internal::timer.detach();
        return;
    }

    if (MQTT_CONNECT_EVENT == type) {
#if LIGHT_PROVIDER != LIGHT_PROVIDER_NONE
        ::mqttSubscribe(MQTT_TOPIC_LIGHT_JSON);
#endif
        ::schedule_function(publishDiscovery);
        return;
    }

#if LIGHT_PROVIDER != LIGHT_PROVIDER_NONE
    if (type == MQTT_MESSAGE_EVENT) {
        String t = ::mqttMagnitude(topic);
        if (t.equals(MQTT_TOPIC_LIGHT_JSON)) {
            receiveLightJson(payload);
        }
        return;
    }
#endif
}

namespace web {

#if WEB_SUPPORT

void onVisible(JsonObject& root) {
    root["haVisible"] = 1;
}

void onConnected(JsonObject& root) {
    root["haPrefix"] = getSetting("haPrefix", HOMEASSISTANT_PREFIX);
    root["haEnabled"] = getSetting("haEnabled", 1 == HOMEASSISTANT_ENABLED);
    root["haRetain"] = getSetting("haRetain", 1 == HOMEASSISTANT_RETAIN);
}

bool onKeyCheck(const char* key, JsonVariant& value) {
    return (strncmp(key, "ha", 2) == 0);
}

#endif

} // namespace web
} // namespace homeassistant

// This module no longer implements .yaml generation, since we can't:
// - use unique_id in the device config
// - have abbreviated keys
// - have mqtt reliably return the correct status & command payloads when it is disabled
//   (yet? needs reworked configuration section or making functions read settings directly)

void haSetup() {
#if WEB_SUPPORT
    wsRegister()
        .onVisible(homeassistant::web::onVisible)
        .onConnected(homeassistant::web::onConnected)
        .onKeyCheck(homeassistant::web::onKeyCheck);
#endif

#if LIGHT_PROVIDER != LIGHT_PROVIDER_NONE
    lightSetReportListener(homeassistant::publishLightJson);
    mqttHeartbeat(homeassistant::heartbeat);
#endif
    mqttRegister(homeassistant::mqttCallback);

#if TERMINAL_SUPPORT
    terminalRegisterCommand(F("HA.SEND"), [](const terminal::CommandContext& ctx) {
        using namespace homeassistant::internal;
        state = State::Pending;
        homeassistant::publishDiscovery();
        terminalOK(ctx);
    });
#endif

    espurnaRegisterReload(homeassistant::configure);
    homeassistant::configure();
}

#endif // HOMEASSISTANT_SUPPORT
