// SPDX-FileCopyrightText: 2025  Amal Mazrah <mazrah@ruc.dk>
// SPDX-FileCopyrightText: 2025  Jonas Smedegaard <dr@jones.dk>
// SPDX-FileCopyrightText: 2025  Mennatullah Hatim Kassim <stud-mennatulla@ruc.dk>
// SPDX-FileCopyrightText: 2025  Noor Ahmad <noora@ruc.dk>
// SPDX-FileCopyrightText: 2025  Tanishka Suwalka <tanishkas@ruc.dk>
// SPDX-License-Identifier: GPL-3.0-or-later

/// Mussel vote - an Arduino sketch to monitor mussel biosensors
///
/// @version 0.0.3
/// @see <https://app.radicle.xyz/nodes/seed.radicle.garden/rad:z2tFBF4gN7ziG9oXtUytVQNYe3VhQ>
/// @see <https://moodle.ruc.dk/course/view.php?id=23504>

// arduino-esp32 Logging system
// activate in Arduino IDE: Tools -> Core Debug Level
// special: set Core Debug Level to Error for plot-friendly output
#define CONFIG_ARDUHAL_ESP_LOG 1
#define LOG_LOCAL_LEVEL CORE_DEBUG_LEVEL
#include <esp32-hal-log.h>
#undef ARDUHAL_LOG_FORMAT
#define ARDUHAL_LOG_FORMAT(letter, format) \
 ARDUHAL_LOG_COLOR_##letter "[" #letter "] %s(): " format \
 ARDUHAL_LOG_RESET_COLOR "\r\n", __FUNCTION__

// arduino-esp32 Bluetooth Low Energy (BLE) networking stack
#include <BLEDevice.h>
#include <BLEScan.h>
#include <BLEAdvertisedDevice.h>
#include <BLEEddystoneTLM.h>
#include <BLEBeacon.h>
#define SCAN_INTERVAL 100
#define SCAN_WINDOW 99
#define SCAN_TIME_SEC 1

// stack sizes for voters and ballots-per-voter
#define VOTER_MAX 10
#define BALLOT_MAX 5

// Validity timing thresholds
const unsigned long VOTE_TIME_TOLERANCE = 1 * 60 * 1000;   // 1 minute

// track vote outcome
#define LED_PIN   LED_BUILTIN
#define LED1_PIN  2   // Blue
#define LED2_PIN  33  // Green
bool waterIsDrinkable = true;  // true means LED off, false means LED on

// Classify gape state
enum MusselGapState {
  Closed,
  Open
};

// Data structures
struct Vote {
  unsigned long timestamp;
  int measure;
};

struct Voter {
  String id;               // Mussel ID
  Vote votes[BALLOT_MAX];  // Last 5 sensor readings
  int voteCount = 0;       // Number of readings stored
};

// Global array of mussel voters
Voter voters[VOTER_MAX];
int voterCount = 0;

// pointer to control Bluetooth networking
BLEScan *pBLEScan;

/// Find index of mussel ID in the voters array
int findVoterIndex(const String& id) {
  for (int i = 0; i < voterCount; i++) {
    if (voters[i].id == id) return i;
  }
  return -1;  // Not found
}

/// Add or update data for a mussel voting ballot
void collectBallotData(
  const String& id, unsigned long timestamp, int gape_measure
) {
  int index = findVoterIndex(id);

  // If mussel not found, add new
  if (index == -1) {
    if (voterCount >= VOTER_MAX) {
      log_i("Ignored: Max mussel limit reached (%s)",
        id.c_str());
      return;
    }
    voters[voterCount].id = id;
    voters[voterCount].voteCount = 0;
    index = voterCount++;
  }

  Voter &voter = voters[index];

  // Maintain a fixed number of stored ballots (FIFO logic)
  if (voter.voteCount >= BALLOT_MAX) {
    for (int i = 1; i < BALLOT_MAX; i++) {
      voter.votes[i - 1] = voter.votes[i];
    }
    voter.voteCount = BALLOT_MAX - 1;
  }

  // Store the new draft ballot at the end
  voter.votes[voter.voteCount++] = {timestamp, gape_measure};
  log_i("Vote stored: Time: %lu, Mussel: %s, Gape: %d",
    timestamp, id.c_str(), gape_measure);
}

/// Clean outdated voting ballots and mussels with no valid ballots
void cleanOldBallotData() {
  unsigned long now = millis();

  for (int i = 0; i < voterCount; ) {
    Voter &voter = voters[i];
    int newCount = 0;

    // Shift valid ballots to the front
    for (int j = 0; j < voter.voteCount; j++) {
      unsigned long age = now - voter.votes[j].timestamp;

      if (age < VOTE_TIME_TOLERANCE) {
        voter.votes[newCount++] = voter.votes[j];
      } else {
        log_i("Dropped old ballot for Mussel %s (age: %lums)",
              voter.id.c_str(), age);
      }
    }

    voter.voteCount = newCount;

    // If all ballots are dropped, remove the mussel
    if (voter.voteCount == 0) {
      log_i("Removing Mussel %s - No valid ballots left",
        voter.id.c_str());

      for (int k = i; k < voterCount - 1; k++) {
        voters[k] = voters[k + 1];
      }
      voterCount--;
      continue;  // Do not increment i since the list shifted
    }

    i++;  // Only increment if we didn't remove current voter
  }
}

/// Classify mussel state based on topmost vote
void alignVotes() {
  for (int i = 0; i < voterCount; i++) {
    Voter &voter = voters[i];

    // Skip mussels with no data
    if (voter.voteCount == 0) {
      log_i("Mussel ID: %s - No data",
        voter.id.c_str());
      continue;
    }

    // Use latest vote to determine state
    Vote latest = voter.votes[voter.voteCount - 1];
    String state = (latest.measure >= 0 && latest.measure < 40)
      ? "Closed"
      : (latest.measure >= 40 && latest.measure <= 90)
        ? "Open"
        : "Invalid reading";

    log_i("Mussel ID: %s | Latest Gape: %d | State: %s",
      voter.id.c_str(), latest.measure, state.c_str());
  }
}

/// Decide whether a voting ballot is valid based on age
const char* qualifyBallot(
  unsigned long voteTimestamp, unsigned long now
) {
  unsigned long age = now - voteTimestamp;

  if (age <= VOTE_TIME_TOLERANCE) {
    log_i("VALID: Ballot is within 1 minute (age: %lums)",
      age);
    return "valid";
  } else {
    log_i("INVALID: Ballot is older than 1 minute (age: %lums)",
      age);
    return "invalid";
  }
}

/// Resolve the outcome of the voting procedure
void concludeVote() {
  int openVotes = 0;
  int totalValidVotes = 0;

  // First, align votes and qualify them
  for (int i = 0; i < voterCount; i++) {
    Voter &voter = voters[i];

    // Skip mussels with no data
    if (voter.voteCount == 0) continue;

    // Use latest vote to determine state
    Vote latest = voter.votes[voter.voteCount - 1];
    unsigned long now = millis();

    // Check if the vote is valid using the qualify function
    const char* validity = qualifyBallot(latest.timestamp, now);

    if (strcmp(validity, "valid") == 0) {
      totalValidVotes++;  // Count valid votes

      // Align and classify mussel state
      String state = (latest.measure >= 0 && latest.measure < 40)
        ? "Closed"
        : (latest.measure >= 40 && latest.measure <= 90)
          ? "Open"
          : "Invalid reading";

      // Count valid "Open" votes
      if (state == "Open") {
        openVotes++;
      }
    }
  }

  // Determine the threshold (half of total valid votes, rounded down)
  int threshold = totalValidVotes / 2;
  waterIsDrinkable = (openVotes >= threshold);

  // Same or more "Open" votes than the threshold means water is ok
  if (openVotes >= threshold) {
    log_i("YES, water is drinkable (open: %d, valid: %d)",
          openVotes, totalValidVotes);
  } else {
    log_i("NO, water is not drinkable (open: %d, valid: %d)",
          openVotes, totalValidVotes);
  }
}

// Bluetooth beacon discovery callbacks
class MyAdvertisedDeviceCallbacks : public BLEAdvertisedDeviceCallbacks {

  // decode name and temperature from Eddystone TLM advertisement
  void onResult(BLEAdvertisedDevice advertisedDevice) {
    if (advertisedDevice.haveName()
      && advertisedDevice.getFrameType() == BLE_EDDYSTONE_TLM_FRAME
    ) {
      BLEEddystoneTLM EddystoneTLM(&advertisedDevice);

      // misuse error-only log level for plot-friendly output
#if ARDUHAL_LOG_LEVEL == ARDUHAL_LOG_LEVEL_ERROR
      String id_mangled = advertisedDevice.getName();
      id_mangled.replace(' ', '_');
      id_mangled.replace(':', '=');
      Serial.println(id_mangled + ":" + EddystoneTLM.getTemp());
#endif

      unsigned long now = millis();
      String musselID = advertisedDevice.getName();
      int gape = EddystoneTLM.getTemp();

      collectBallotData(musselID, now, gape);
    }
  }
};

void setup() {
  // enable logging to serial
  Serial.begin(115200);
  esp_log_level_set("*", ESP_LOG_DEBUG);

  // start with LED off (HIGH = off on active-low boards)
  pinMode(LED_PIN, OUTPUT);
  digitalWrite(LED_PIN, HIGH);

  pinMode(LED1_PIN, OUTPUT);
  pinMode(LED2_PIN, OUTPUT);
  digitalWrite(LED1_PIN, LOW);
  digitalWrite(LED2_PIN, LOW);

  // setup Bluetooth
  BLEDevice::init("");
  pBLEScan = BLEDevice::getScan();
  pBLEScan->setAdvertisedDeviceCallbacks(
    new MyAdvertisedDeviceCallbacks());
  pBLEScan->setActiveScan(true);
  pBLEScan->setInterval(SCAN_INTERVAL);
  pBLEScan->setWindow(SCAN_WINDOW);
}

void loop() {

  pBLEScan->start(SCAN_TIME_SEC, false);
  pBLEScan->clearResults();

  alignVotes();
  concludeVote();
  cleanOldBallotData();  // Tidy the voter stack

  digitalWrite(LED_PIN, waterIsDrinkable ? LOW : HIGH);

  // LED Logic: GREEN if drinkable, RED if not
  if (waterIsDrinkable) {
    digitalWrite(LED2_PIN, HIGH); // GREEN ON
    digitalWrite(LED1_PIN, LOW);  // RED OFF
    Serial.println("Water is DRINKABLE - GREEN LED ON");
  } else {
    digitalWrite(LED2_PIN, LOW);  // GREEN OFF
    digitalWrite(LED1_PIN, HIGH); // RED ON
    Serial.println("Water is NOT DRINKABLE - RED LED ON");
  }

  delay(500);
}