/*

TUYA MODULE

Copyright (C) 2019 by Maxim Prokhorov <prokhorov dot max at outlook dot com>

*/

// ref: https://docs.tuya.com/en/mcu/mcu-protocol.html

#include "tuya.h"

#if TUYA_SUPPORT

#include "broker.h"
#include "light.h"
#include "relay.h"
#include "rpc.h"

#include <functional>
#include <queue>
#include <StreamString.h>

#include "tuya_types.h"
#include "tuya_transport.h"
#include "tuya_dataframe.h"
#include "tuya_protocol.h"
#include "tuya_util.h"

namespace Tuya {

    constexpr size_t SERIAL_SPEED { 9600u };

    constexpr unsigned char SWITCH_MAX { 8u };
    constexpr unsigned char DIMMER_MAX { 5u };

    constexpr uint32_t DISCOVERY_TIMEOUT { 1500u };

    constexpr uint32_t HEARTBEAT_SLOW { 9000u };
    constexpr uint32_t HEARTBEAT_FAST { 3000u };

    // --------------------------------------------

    struct dp_states_filter_t {
        using type = unsigned char;
        static const type NONE = 0;
        static const type BOOL = 1 << 0;
        static const type INT  = 1 << 1;
        static const type ALL = (INT | BOOL);

        static type clamp(type value) {
            return constrain(value, NONE, ALL);
        }
    };

    size_t getHeartbeatInterval(Heartbeat hb) {
        switch (hb) {
            case Heartbeat::FAST:
                return HEARTBEAT_FAST;
            case Heartbeat::SLOW:
                return HEARTBEAT_SLOW;
            case Heartbeat::NONE:
            default:
                return 0;
        }
    }

    uint8_t getWiFiState() {

        uint8_t state = wifiState();
        if (state & WIFI_STATE_SMARTCONFIG) return 0x00;
        if (state & WIFI_STATE_AP) return 0x01;
        if (state & WIFI_STATE_STA) return 0x04;

        return 0x02;
    }

    // TODO: is v2 required to modify pin assigments?
    void updatePins(uint8_t led, uint8_t rst) {
        setSetting("ledGPIO0", led);
        setSetting("btnGPIO0", rst);
        //espurnaReload();
    }

    // --------------------------------------------

    States<bool> switchStates(SWITCH_MAX);
    #if LIGHT_PROVIDER == LIGHT_PROVIDER_TUYA
        States<uint32_t> channelStates(DIMMER_MAX);
    #endif

    // Handle DP data from the MCU, mapping incoming DP ID to the specific relay / channel ID

    void applySwitch() {
        for (unsigned char id=0; id < switchStates.size(); ++id) {
            relayStatus(id, switchStates[id].value);
        }
    }

    #if LIGHT_PROVIDER == LIGHT_PROVIDER_TUYA
        void applyChannel() {
            for (unsigned char id=0; id < channelStates.size(); ++id) {
                lightChannel(id, channelStates[id].value);
            }
            lightUpdate(true, true);
        }
    #endif

    // --------------------------------------------

    Transport tuyaSerial(TUYA_SERIAL);
    std::queue<DataFrame> outputFrames;

    DiscoveryTimeout discoveryTimeout(DISCOVERY_TIMEOUT);
    bool transportDebug = false;
    bool configDone = false;
    bool reportWiFi = false;
    dp_states_filter_t::type filterDP = dp_states_filter_t::NONE;

    String product;

    void showProduct() {
        if (product.length()) DEBUG_MSG_P(PSTR("[TUYA] Product: %s\n"), product.c_str());
    }

    inline void dataframeDebugSend(const char* tag, const DataFrame& frame) {
        if (!transportDebug) return;
        StreamString out;
        Output writer(out, frame.length);
        writer.writeHex(frame.serialize());
        DEBUG_MSG("[TUYA] %s: %s\n", tag, out.c_str());
    }

    void sendHeartbeat(Heartbeat hb, State state) {

        static uint32_t last = 0;
        if (millis() - last > getHeartbeatInterval(hb)) {
            outputFrames.emplace(Command::Heartbeat);
            last = millis();
        }

    }

    void sendWiFiStatus() {
        if (!reportWiFi) return;
        outputFrames.emplace(
            Command::WiFiStatus, std::initializer_list<uint8_t> { getWiFiState() }
        );
    }

    void pushOrUpdateState(const Type type, const DataFrame& frame) {
        if (Type::BOOL == type) {
            const DataProtocol<bool> proto(frame);
            switchStates.pushOrUpdate(proto.id(), proto.value());
            //DEBUG_MSG_P(PSTR("[TUYA] apply BOOL id=%02u value=%s\n"), proto.id(), proto.value() ? "true" : "false");
        } else if (Type::INT == type) {
            #if LIGHT_PROVIDER == LIGHT_PROVIDER_TUYA
                const DataProtocol<uint32_t> proto(frame);
                channelStates.pushOrUpdate(proto.id(), proto.value());
                //DEBUG_MSG_P(PSTR("[TUYA] apply  INT id=%02u value=%u\n"), proto.id(), proto.value());
            #endif
        }
    }

    // XXX: sometimes we need to ignore incoming state, when not in discovery mode
    // ref: https://github.com/xoseperez/espurna/issues/1729#issuecomment-509234195
    void updateState(const Type type, const DataFrame& frame) {
        if (Type::BOOL == type) {
            if (filterDP & dp_states_filter_t::BOOL) return;
            const DataProtocol<bool> proto(frame);
            switchStates.update(proto.id(), proto.value());
        } else if (Type::INT == type) {
            if (filterDP & dp_states_filter_t::INT) return;
            #if LIGHT_PROVIDER == LIGHT_PROVIDER_TUYA
                const DataProtocol<uint32_t> proto(frame);
                channelStates.update(proto.id(), proto.value());
            #endif
        }
    }

    void processDP(State state, const DataFrame& frame) {

        // TODO: do not log protocol errors without transport debug enabled
        if (!frame.length) {
            DEBUG_MSG_P(PSTR("[TUYA] DP frame must have data\n"));
            return;
        }

        const Type type {dataType(frame)};
        if (Type::UNKNOWN == type) {
            if (frame.length >= 2) {
                DEBUG_MSG_P(PSTR("[TUYA] Unknown DP id=%u type=%u\n"), frame[0], frame[1]);
            } else {
                DEBUG_MSG_P(PSTR("[TUYA] Invalid DP frame\n"));
            }
            return;
        }

        if (State::DISCOVERY == state) {
            discoveryTimeout.feed();
            pushOrUpdateState(type, frame);
        } else {
            updateState(type, frame);
        }

    }

    void processFrame(State& state, const Transport& buffer) {

        const DataFrame frame(buffer);

        dataframeDebugSend("<=", frame);

        // initial packet has 0, do the initial setup
        // all after that have 1. might be a good idea to re-do the setup when that happens on boot
        if (frame.commandEquals(Command::Heartbeat) && (frame.length == 1)) {
            if (State::HEARTBEAT == state) {
                if ((frame[0] == 0) || !configDone) {
                    DEBUG_MSG_P(PSTR("[TUYA] Starting configuration ...\n"));
                    state = State::QUERY_PRODUCT;
                    return;
                } else {
                    DEBUG_MSG_P(PSTR("[TUYA] Already configured\n"));
                    state = State::IDLE;
                }
            }
            sendWiFiStatus();
            return;
        }

        if (frame.commandEquals(Command::QueryProduct) && frame.length) {
            if (product.length()) {
                product = "";
            }
            product.reserve(frame.length);
            for (unsigned int n = 0; n < frame.length; ++n) {
                product += static_cast<char>(frame[n]);
            }
            showProduct();
            state = State::QUERY_MODE;
            return;
        }

        if (frame.commandEquals(Command::QueryMode)) {
            // first and second byte are GPIO pin for WiFi status and RST respectively
            if (frame.length == 2) {
                DEBUG_MSG_P(PSTR("[TUYA] Mode: ESP only, led=GPIO%02u rst=GPIO%02u\n"), frame[0], frame[1]);
                updatePins(frame[0], frame[1]);
            // ... or nothing. we need to report wifi status to the mcu via Command::WiFiStatus
            } else if (!frame.length) {
                DEBUG_MSG_P(PSTR("[TUYA] Mode: ESP & MCU\n"));
                reportWiFi = true;
                sendWiFiStatus();
            }
            state = State::QUERY_DP;
            return;
        }

        if (frame.commandEquals(Command::WiFiResetCfg) && !frame.length) {
            DEBUG_MSG_P(PSTR("[TUYA] WiFi reset request\n"));
            outputFrames.emplace(Command::WiFiResetCfg);
            return;
        }

        if (frame.commandEquals(Command::WiFiResetSelect) && (frame.length == 1)) {
            DEBUG_MSG_P(PSTR("[TUYA] WiFi configuration mode request: %s\n"),
                (frame[0] == 0) ? "Smart Config" : "AP");
            outputFrames.emplace(Command::WiFiResetSelect);
            return;
        }

        if (frame.commandEquals(Command::ReportDP) && frame.length) {
            processDP(state, frame);
            if (state == State::DISCOVERY) return;
            if (state == State::HEARTBEAT) return;
            state = State::IDLE;
            return;
        }

    }

    void processSerial(State& state) {

        while (tuyaSerial.available()) {

            tuyaSerial.read();

            if (tuyaSerial.done()) {
                processFrame(state, tuyaSerial);
                tuyaSerial.reset();
            }

            if (tuyaSerial.full()) {
                tuyaSerial.rewind();
                tuyaSerial.reset();
            }
        }

    }

    // Push local state data, mapping it to the appropriate DP

    void tuyaSendSwitch(unsigned char id) {
        if (id >= switchStates.size()) return;
        outputFrames.emplace(
            Command::SetDP, DataProtocol<bool>(switchStates[id].dp, switchStates[id].value).serialize()
        );
    }

    void tuyaSendSwitch(unsigned char id, bool value) {
        if (id >= switchStates.size()) return;
        if (value == switchStates[id].value) return;
        switchStates[id].value = value;
        tuyaSendSwitch(id);
    }

    #if LIGHT_PROVIDER == LIGHT_PROVIDER_TUYA
        void tuyaSendChannel(unsigned char id) {
            if (id >= channelStates.size()) return;
            outputFrames.emplace(
                Command::SetDP, DataProtocol<uint32_t>(channelStates[id].dp, channelStates[id].value).serialize()
            );
        }

        void tuyaSendChannel(unsigned char id, unsigned int value) {
            if (id >= channelStates.size()) return;
            if (value == channelStates[id].value) return;
            channelStates[id].value = value;
            tuyaSendChannel(id);
        }
    #endif

    void brokerCallback(const String& topic, unsigned char id, unsigned int value) {

        // Only process status messages for switches and channels
        if (!topic.equals(MQTT_TOPIC_CHANNEL)
            && !topic.equals(MQTT_TOPIC_RELAY)) {
            return;
        }

        #if (RELAY_PROVIDER == RELAY_PROVIDER_LIGHT) && (LIGHT_PROVIDER == LIGHT_PROVIDER_TUYA)
            if (topic.equals(MQTT_TOPIC_CHANNEL)) {
                if (lightState(id) != switchStates[id].value) {
                    tuyaSendSwitch(id, value > 0);
                }
                if (lightState(id)) tuyaSendChannel(id, value);
                return;
            }

            if (topic.equals(MQTT_TOPIC_RELAY)) {
                if (lightState(id) != switchStates[id].value) {
                    tuyaSendSwitch(id, bool(value));
                }
                if (lightState(id)) tuyaSendChannel(id, value);
                return;
            }
        #elif (LIGHT_PROVIDER == LIGHT_PROVIDER_TUYA)
            if (topic.equals(MQTT_TOPIC_CHANNEL)) {
                tuyaSendChannel(id, value);
                return;
            }

            if (topic.equals(MQTT_TOPIC_RELAY)) {
                tuyaSendSwitch(id, bool(value));
                return;
            }
        #else
            if (topic.equals(MQTT_TOPIC_RELAY)) {
                tuyaSendSwitch(id, bool(value));
                return;
            }
        #endif

    }

    // Main loop state machine. Process input data and manage output queue

    void tuyaLoop() {

        static State state = State::INIT;

        // running this before anything else to quickly switch to the required state
        processSerial(state);

        // go through the initial setup step-by-step, as described in
        // https://docs.tuya.com/en/mcu/mcu-protocol.html#21-basic-protocol
        switch (state) {
            // flush serial buffer before transmitting anything
            // send fast heartbeat until mcu responds with something
            case State::INIT:
                tuyaSerial.rewind();
                state = State::HEARTBEAT;
            case State::HEARTBEAT:
                sendHeartbeat(Heartbeat::FAST, state);
                break;
            // general info about the device (which we don't care about)
            case State::QUERY_PRODUCT:
            {
                outputFrames.emplace(Command::QueryProduct);
                state = State::IDLE;
                break;
            }
            // whether we control the led & button or not
            // TODO: make updatePins() do something!
            case State::QUERY_MODE:
            {
                outputFrames.emplace(Command::QueryMode);
                state = State::IDLE;
                break;
            }
            // full read-out of the data protocol values
            case State::QUERY_DP:
            {
                DEBUG_MSG_P(PSTR("[TUYA] Starting discovery\n"));
                outputFrames.emplace(Command::QueryDP);
                discoveryTimeout.feed();
                state = State::DISCOVERY;
                break;
            }
            // parse known data protocols until discovery timeout expires
            case State::DISCOVERY:
            {
                if (discoveryTimeout) {
                    DEBUG_MSG_P(PSTR("[TUYA] Discovery finished\n"));
                    relaySetupDummy(switchStates.size(), true);
                    #if LIGHT_PROVIDER == LIGHT_PROVIDER_TUYA
                        lightSetupChannels(channelStates.size());
                    #endif
                    configDone = true;
                    state = State::IDLE;
                }
                break;
            }
            // initial config is done, only doing heartbeat periodically
            case State::IDLE:
            {
                if (switchStates.changed()) applySwitch();
                #if LIGHT_PROVIDER == LIGHT_PROVIDER_TUYA
                    if (channelStates.changed()) applyChannel();
                #endif
                sendHeartbeat(Heartbeat::SLOW, state);
                break;
            }
        }

        if (TUYA_SERIAL && !outputFrames.empty()) {
            const DataFrame frame = std::move(outputFrames.front());
            dataframeDebugSend("=>", frame);
            tuyaSerial.write(frame.serialize());
            outputFrames.pop();
        }

    }

    // Predefined DP<->SWITCH, DP<->CHANNEL associations
    // Respective provider setup should be called before state restore,
    // so we can use dummy values

    void initBrokerCallback() {
        static bool done = false;
        if (done) {
            return;
        }

        ::StatusBroker::Register(brokerCallback);
        done = true;
    }

    void tuyaSetupSwitch() {

        initBrokerCallback();

        for (unsigned char n = 0; n < switchStates.capacity(); ++n) {
            if (!hasSetting({"tuyaSwitch", n})) break;
            const auto dp = getSetting({"tuyaSwitch", n}, 0);
            switchStates.pushOrUpdate(dp, false);
        }
        relaySetupDummy(switchStates.size());

        if (switchStates.size()) configDone = true;

    }

    void tuyaSyncSwitchStatus() {
        for (unsigned char n = 0; n < switchStates.size(); ++n) {
            switchStates[n].value = relayStatus(n);
        }
    }

    #if LIGHT_PROVIDER == LIGHT_PROVIDER_TUYA
        void tuyaSetupLight() {

            initBrokerCallback();

            for (unsigned char n = 0; n < channelStates.capacity(); ++n) {
                if (!hasSetting({"tuyaChannel", n})) break;
                const auto dp = getSetting({"tuyaChannel", n}, 0);
                channelStates.pushOrUpdate(dp, 0);
            }
            lightSetupChannels(channelStates.size());

            if (channelStates.size()) configDone = true;

        }
    #endif

    void tuyaSetup() {

        // Print all known DP associations

        #if TERMINAL_SUPPORT

            terminalRegisterCommand(F("TUYA.SHOW"), [](const terminal::CommandContext&) {
                static const char fmt[] PROGMEM = "%12s%u => dp=%u value=%u\n";
                showProduct();

                for (unsigned char id=0; id < switchStates.size(); ++id) {
                    DEBUG_MSG_P(fmt, "tuyaSwitch", id, switchStates[id].dp, switchStates[id].value);
                }

                #if LIGHT_PROVIDER == LIGHT_PROVIDER_TUYA
                    for (unsigned char id=0; id < channelStates.size(); ++id) {
                        DEBUG_MSG_P(fmt, "tuyaChannel", id, channelStates[id].dp, channelStates[id].value);
                    }
                #endif
            });

            terminalRegisterCommand(F("TUYA.SAVE"), [](const terminal::CommandContext&) {
                DEBUG_MSG_P(PSTR("[TUYA] Saving current configuration ...\n"));
                for (unsigned char n=0; n < switchStates.size(); ++n) {
                    setSetting({"tuyaSwitch", n}, switchStates[n].dp);
                }
                #if LIGHT_PROVIDER == LIGHT_PROVIDER_TUYA
                    for (unsigned char n=0; n < channelStates.size(); ++n) {
                        setSetting({"tuyaChannel", n}, channelStates[n].dp);
                    }
                #endif
            });

        #endif

        // Filtering for incoming data
        auto filter_raw = getSetting("tuyaFilter", dp_states_filter_t::NONE);
        filterDP = dp_states_filter_t::clamp(filter_raw);

        // Print all IN and OUT messages

        transportDebug = getSetting("tuyaDebug", true);

        // Install main loop method and WiFiStatus ping (only works with specific mode)

        TUYA_SERIAL.begin(SERIAL_SPEED);

        ::espurnaRegisterLoop(tuyaLoop);
        ::wifiRegister([](justwifi_messages_t code, char * parameter) {
            if ((MESSAGE_CONNECTED == code) || (MESSAGE_DISCONNECTED == code)) {
                sendWiFiStatus();
            }
        });
    }

}

#endif // TUYA_SUPPORT