WiFiUDP ntpUDP;

uint32_t lastTimeTriggerChecked = 0;
TimeTrigger* lastTimeTrigger = nullptr;
TimeTrigger* activeTimeTrigger = nullptr;

uint32_t lastTimezoneUpdate = 0;
AsyncClient* timezoneClient = nullptr;
char* response = nullptr;
uint16_t responseSize = 0;

static const uint16_t ResponseMaxSize = 1024;


void initMotionPins()
{
  if (!motionTriggerSettings->enabled())
    return;

  for (uint8_t i = 0; i < motionTriggerSettings->triggerCount(); i++)
  {
    MotionTrigger* trigger = motionTriggerSettings->trigger(i);
    if (trigger->enabled)
      pinMode(trigger->pin, INPUT);
  }
}


void parseResponse()
{
  if (response == nullptr || responseSize == 0)
    return;

  _dln("Timezone :: response:");
  _dln(response);

  char* data = response;
  if (strncmp(data, "HTTP/1.", 7) != 0)
  {
    _dln("Timezone :: not an HTTP response");
    return;
  }

  data += 9;
  if (strncmp(data, "200", 3) != 0)
  {
    _dln("Timezone :: invalid HTTP status code");
    return;
  }

  data = strstr(data, "\r\n\r\n");
  if (data == nullptr)
  {
    _dln("Timezone :: end of HTTP headers not found");
    return;
  }

  data += 4;

  DynamicJsonBuffer jsonBuffer(JSON_OBJECT_SIZE(5) + 200);
  JsonObject& root = jsonBuffer.parseObject(data);

  if (!sameStr(root["status"], "OK"))
  {
    _dln("Timezone :: invalid status in response");
    return;
  }

  timezoneOffset = root["rawOffset"];
  hasTimezone = true;
}


void updateTimezone()
{
  if (timezoneClient != nullptr)
    return;

  timezoneClient = new AsyncClient();
  if (!timezoneClient)
    return;

  timezoneClient->onError([](void* arg, AsyncClient* client, int error)
  {
    _d("Timezone :: error ");
    _dln(error);

    timezoneClient = nullptr;
    delete client;

    lastTimezoneUpdate = currentTime;
  }, nullptr);

  timezoneClient->onConnect([](void* arg, AsyncClient* client)
  {
    response = (char*)malloc(ResponseMaxSize + 1);
    responseSize = 0;
    timezoneClient->onError(nullptr, nullptr);

    client->onDisconnect([](void * arg, AsyncClient * c)
    {
      timezoneClient = nullptr;
      delete c;

      lastTimezoneUpdate = currentTime;

      parseResponse();
      free(response);
      response = nullptr;
    }, nullptr);

    client->onData([](void* arg, AsyncClient* c, void* data, size_t len)
    {
      uint16_t copyLen = responseSize == ResponseMaxSize ? 0 :
                         responseSize + len > ResponseMaxSize ? ResponseMaxSize - responseSize :
                         len;

      if (copyLen > 0)
      {
        memcpy(response + responseSize, data, copyLen);
        responseSize += copyLen;
        response[responseSize] = 0;
      }
    }, nullptr);


    uint32_t timestamp = ntpClient->getEpochTime();
    #ifdef MapsAPIViaProxyScript
    String request = String("GET ") + TimezoneProxyScriptPath + "?location=" +
    #else
    String request = "GET /maps/api/timezone/json?location=" +
    #endif
                     String(systemSettings->latitude(), 7) + "," + String(systemSettings->longitude(), 7) +
                     "8&timestamp=" +
                     String(timestamp);

    if (systemSettings->mapsAPIKey() != nullptr)
      request = request + "&key=" + systemSettings->mapsAPIKey();

    _d("Timezone :: request: ");
    _dln(request);

    #ifdef MapsAPIViaProxyScript
    request = request + " HTTP/1.0\r\nHost: " + TimezoneProxyScriptHost + "\r\n\r\n";
    #else
    request = request + " HTTP/1.0\r\nHost: maps.googleapis.com\r\n\r\n";
    #endif

    client->write(request.c_str());
  }, nullptr);

  _d("Timezone :: available heap: ");
  _dln(ESP.getFreeHeap());

  #ifdef MapsAPIViaProxyScript
  if(!timezoneClient->connect(TimezoneProxyScriptHost, 80))
  #else
  if(!timezoneClient->connect("maps.googleapis.com", 443, true))
  #endif
  {
    _dln("Timezone :: failed to connect to host");

    AsyncClient * client = timezoneClient;
    timezoneClient = nullptr;
    delete client;

    lastTimezoneUpdate = currentTime;
  }
}


void updateNTPClient()
{
  if (ntpClient == nullptr && WiFi.status() == WL_CONNECTED &&
      systemSettings->ntpServer() != nullptr && systemSettings->ntpInterval() > 0)
  {
    _dln("NTP :: initializing NTP client");

    ntpClient = new NTPClient(ntpUDP, systemSettings->ntpServer(), 0, systemSettings->ntpInterval() * 60 * 1000);
    ntpClient->begin();
  }


  // Only update if we're not in the middle of a transition, as it will block
  // the loop until the NTP server responds or times out (up to a second)
  if (ntpClient != nullptr && !stairs->inTransition())
  {
    ntpClient->update();

    // Lat/lng 0,0 is off the African coast, I think we can safely assume nobody
    // will have WiFi enabled stair lighting at that location.
    if (timezoneClient == nullptr && systemSettings->latitude() && systemSettings->longitude())
    {
      uint32_t interval = hasTimezone ? systemSettings->ntpInterval() * 60 * 1000 : TimezoneRetryInterval;
      if (lastTimezoneUpdate == 0 || currentTime - lastTimezoneUpdate > interval)
      {
        updateTimezone();
        lastTimezoneUpdate = currentTime;
      }
    }
  }
}


uint32_t lastTimeElementsChecked = 0;
uint32_t epochTime = 0;
tmElements_t timeElements;
bool isDayTime = true;


tmElements_t* getTimeElements()
{
  if (ntpClient == nullptr || !hasTimezone)
    return nullptr;

  if (lastTimeElementsChecked != 0 && currentTime - lastTimeElementsChecked < 10000)
    return epochTime > 0 ? &timeElements : nullptr;

  lastTimeElementsChecked = currentTime;
  epochTime = ntpClient->getEpochTime();
  if (epochTime == 0)
  {
    _dln("Triggers:: time not synchronised yet");
    return nullptr;
  }

  _dln("Triggers:: updating time elements");
  breakTime(epochTime + timezoneOffset, timeElements);


  // TODO this is a copy of what is in time.cpp. This code, and probably a lot more
  // in this file, could use some cleanup.
  Dusk2Dawn location(systemSettings->latitude(), systemSettings->longitude(), timezoneOffset / 3600.0f);

  // DST is always hardcoded as false, since it is already included in timezoneOffset
  int16_t sunriseMinutes = location.sunrise(timeElements.Year, timeElements.Month, timeElements.Day, false);
  int16_t sunsetMinutes = location.sunset(timeElements.Year, timeElements.Month, timeElements.Day, false);

  int16_t timeMinutes = (timeElements.Hour * 60) + timeElements.Minute;
  isDayTime = timeMinutes >= sunriseMinutes && timeMinutes <= sunsetMinutes;

  _d("Triggers:: isDayTime = "); _dln(isDayTime);

  return &timeElements;
}


void updateTimeTrigger()
{
  if (ntpClient == nullptr || !hasTimezone || !timeTriggerSettings->enabled())
  {
    activeTimeTrigger = nullptr;
    return;
  }

  if (timeTriggerSettingsChanged)
  {
    // Time trigger settings changed, activeTimeTrigger pointer is considered
    // invalid, force recheck
    timeTriggerSettingsChanged = false;
  }
  else if (currentTime - lastTimeTriggerChecked < 10000)
    return;


  lastTimeTriggerChecked = currentTime;
  tmElements_t* time = getTimeElements();
  if (time == nullptr)
  {
    activeTimeTrigger = nullptr;
    return;
  }

  activeTimeTrigger = timeTriggerSettings->getActiveTrigger(*time);

  #ifdef SerialDebug
  _d("Triggers:: active time trigger: ");
  if (activeTimeTrigger != nullptr)
  {
    _d(activeTimeTrigger->brightness);
    _d(" @ ");

    switch (activeTimeTrigger->triggerType)
    {
      case RelativeToSunrise: _d("sunrise "); break;
      case RelativeToSunset: _d("sunset "); break;
    }
    _dln(activeTimeTrigger->time);
  }
  else
    _dln("null");
  #endif
}



uint32_t activeMotionStart = 0;
uint16_t activeMotionBrightness = 0;
MotionDirection activeMotionDirection = Nondirectional;
bool lastMotion = false;


void updateMotionTrigger()
{
  if (!motionTriggerSettings->enabled() || !motionTriggerSettings->triggerCount())
  {
    activeMotionStart = 0;
    return;
  }

  if (!motionTriggerSettings->enabledDuringDay() && isDayTime)
  {
    activeMotionStart = 0;
    return;
  }


  for (uint8_t i = 0; i < motionTriggerSettings->triggerCount(); i++)
  {
    MotionTrigger* trigger = motionTriggerSettings->trigger(i);

    if (trigger->enabled && digitalRead(trigger->pin) == HIGH)
    {
      if (activeMotionStart == 0)
      {
        activeMotionDirection = trigger->direction;
        activeMotionBrightness = trigger->brightness;
      }

      activeMotionStart = currentTime;
    }
  }

  if (activeMotionStart != 0 && currentTime - activeMotionStart >= motionTriggerSettings->delay())
    activeMotionStart = 0;
}


void checkTriggers()
{
  if (!timeTriggerSettings->enabled() && activeTimeTrigger == nullptr &&
      !motionTriggerSettings->enabled() && activeMotionStart == 0)
    return;

  updateTimeTrigger();
  updateMotionTrigger();

  bool inTimeTrigger = timeTriggerSettings->enabled() &&
                       activeTimeTrigger != nullptr &&
                       activeTimeTrigger->brightness;
  bool timeTriggerChanged = activeTimeTrigger != lastTimeTrigger;
  lastTimeTrigger = activeTimeTrigger;


  bool inMotionTrigger = false;

  if (activeMotionStart > 0)
  {
    switch (motionTriggerSettings->enabledDuringTimeTrigger())
    {
      case Never:
        inMotionTrigger = !inTimeTrigger;
        break;

      case Always:
        inMotionTrigger = true;
        break;

      case Only:
        inMotionTrigger = inTimeTrigger;
        break;

    }
  }

  bool motionChanged = (activeMotionStart > 0) != lastMotion;
  lastMotion = (activeMotionStart > 0);


  if (!motionChanged && !timeTriggerChanged)
    return;


  if (motionChanged)
  {
    if (inMotionTrigger)
    {
      _dln("Triggers :: start motion trigger");

      if (activeMotionDirection == Nondirectional || motionTriggerSettings->transitionTime() == 0)
        stairs->setAll(activeMotionBrightness, motionTriggerSettings->transitionTime(), 0);
      else
        stairs->sweep(activeMotionBrightness, motionTriggerSettings->transitionTime(), activeMotionDirection == TopDown);
    }
    else
    {
      if (inTimeTrigger)
      {
        _dln("Triggers :: motion stopped, falling back to time trigger");

        // Fall back to time trigger value
        stairs->setAll(activeTimeTrigger->brightness, motionTriggerSettings->transitionTime(), 0);
      }
      else
      {
        _dln("Triggers :: motion stopped, turning off");

        // No more motion, no active time trigger, turn off
        stairs->setAll(0, motionTriggerSettings->transitionTime(), 0);
      }
    }
  }
  else if (timeTriggerChanged && !inMotionTrigger)
  {
    _dln("Triggers :: time trigger changed");

    // Set to time trigger value
    stairs->setAll(activeTimeTrigger->brightness, timeTriggerSettings->transitionTime(), 0);
  }
}