// engine/watches-store.js — SQL-backed implementation of engine/watches.json.
//
// Mirrors the Phase 2/3 work_items / pull_requests stores: a single
// table (no per-scope split — watches.json is a flat array, not
// per-project), diff-then-apply mutator, JSON dual-write mirror with
// (mtime, size) fingerprint for external-edit detection.
//
//   readWatches()           -> [watch, watch, ...]
//   applyWatchesMutation(fn) -> diff-then-apply, returns { wrote, result }

const fs = require('fs');
const path = require('path');

function _toMs(v) {
  if (v == null) return null;
  if (typeof v === 'number') return Number.isFinite(v) ? v : null;
  const parsed = Date.parse(v);
  return Number.isFinite(parsed) ? parsed : null;
}

function _watchesFilePath() {
  const shared = require('./shared');
  return path.join(shared.MINIONS_DIR, 'engine', 'watches.json');
}

function _parseRow(row) {
  if (!row || !row.data) return null;
  try { return JSON.parse(row.data); }
  catch { return null; }
}

function _readJsonArrayFallback() {
  const fp = _watchesFilePath();
  let raw;
  try { raw = fs.readFileSync(fp, 'utf8'); }
  catch { return []; }
  try {
    const parsed = JSON.parse(raw);
    return Array.isArray(parsed) ? parsed : [];
  } catch (e) {
    try {
      // eslint-disable-next-line no-console
      console.warn(`[watches-store] corrupt JSON in ${fp}: ${e.message}`);
    } catch { /* console may be wrapped */ }
    return [];
  }
}

// Content-hash fingerprint — (mtime, size) is too coarse: same-size
// content swaps (e.g. last_checked timestamps that always have the same
// byte length) inside the same ms tick collide on both axes. Hashing the
// bytes is the unambiguous signal. SHA-1 on a few-KB file is sub-ms.
let _lastMirrorHash = null;

const crypto = require('crypto');

function _fileContentHash(filePath) {
  try {
    const buf = fs.readFileSync(filePath);
    return crypto.createHash('sha1').update(buf).digest('hex');
  }
  catch { return null; }
}

function _hydrateFromJson(db) {
  const items = _readJsonArrayFallback();
  db.prepare('DELETE FROM watches').run();
  if (items.length === 0) return;
  const now = Date.now();
  const ins = db.prepare(`
    INSERT INTO watches (id, target, target_type, condition, status, owner, created_at, last_checked, last_triggered, data, updated_at)
    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    ON CONFLICT(id) DO NOTHING
  `);
  for (const w of items) {
    if (!w || !w.id) continue;
    ins.run(
      String(w.id),
      typeof w.target === 'string' ? w.target : (w.target == null ? null : JSON.stringify(w.target)),
      w.targetType || null,
      w.condition || null,
      String(w.status || 'active'),
      w.owner || null,
      _toMs(w.created_at),
      _toMs(w.last_checked),
      _toMs(w.last_triggered),
      JSON.stringify(w),
      now,
    );
  }
}

function _resyncIfJsonDiverged(db) {
  const jsonPath = _watchesFilePath();
  const currentHash = _fileContentHash(jsonPath);
  if (currentHash == null) return;
  if (_lastMirrorHash != null && currentHash === _lastMirrorHash) return;
  if (_lastMirrorHash == null) {
    const sqlHas = db.prepare('SELECT 1 FROM watches LIMIT 1').get();
    if (sqlHas) {
      _lastMirrorHash = currentHash;
      return;
    }
  }
  _hydrateFromJson(db);
  _lastMirrorHash = currentHash;
}

// Read all watch rows from SQL only. No JSON fallback — used by the
// mirror writer to avoid resurrecting deleted rows: after the mutation
// deletes a watch from SQL but BEFORE the mirror lands, the JSON still
// holds the pre-mutation content, so a fallback read would round-trip
// the deleted row right back into JSON.
function _readWatchesFromSqlOnly(db) {
  const rows = db.prepare('SELECT data FROM watches ORDER BY rowid').all();
  const out = [];
  for (const row of rows) {
    const w = _parseRow(row);
    if (w) out.push(w);
  }
  return out;
}

function readWatches() {
  const { getDb } = require('./db');
  let db;
  try { db = getDb(); }
  catch { return _readJsonArrayFallback(); }

  _resyncIfJsonDiverged(db);

  const out = _readWatchesFromSqlOnly(db);
  if (out.length === 0) {
    // SQL is empty for first-time hydrate: trust the JSON if it has
    // content. After hydrate ran, SQL is empty iff the user really has
    // no watches.
    const fallback = _readJsonArrayFallback();
    if (fallback.length > 0) return fallback;
    return [];
  }
  return out;
}

function _indexById(arr) {
  const out = new Map();
  for (const w of arr) {
    if (!w || !w.id) continue;
    out.set(String(w.id), w);
  }
  return out;
}

function _computeWatchesDiff(before, after) {
  const beforeMap = _indexById(before);
  const afterMap = _indexById(after);
  const toUpsert = [];
  const toDelete = [];
  for (const [id, w] of afterMap) {
    const prev = beforeMap.get(id);
    if (!prev) { toUpsert.push(w); continue; }
    if (JSON.stringify(prev) !== JSON.stringify(w)) toUpsert.push(w);
  }
  for (const [id] of beforeMap) {
    if (!afterMap.has(id)) toDelete.push(id);
  }
  return { toUpsert, toDelete };
}

function _applyWatchesDiff(db, diff) {
  if (diff.toUpsert.length === 0 && diff.toDelete.length === 0) return false;
  const now = Date.now();
  const upsertStmt = db.prepare(`
    INSERT INTO watches (id, target, target_type, condition, status, owner, created_at, last_checked, last_triggered, data, updated_at)
    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    ON CONFLICT(id) DO UPDATE SET
      target         = excluded.target,
      target_type    = excluded.target_type,
      condition      = excluded.condition,
      status         = excluded.status,
      owner          = excluded.owner,
      created_at     = excluded.created_at,
      last_checked   = excluded.last_checked,
      last_triggered = excluded.last_triggered,
      data           = excluded.data,
      updated_at     = excluded.updated_at
  `);
  const deleteStmt = db.prepare('DELETE FROM watches WHERE id = ?');

  for (const w of diff.toUpsert) {
    upsertStmt.run(
      String(w.id),
      typeof w.target === 'string' ? w.target : (w.target == null ? null : JSON.stringify(w.target)),
      w.targetType || null,
      w.condition || null,
      String(w.status || 'active'),
      w.owner || null,
      _toMs(w.created_at),
      _toMs(w.last_checked),
      _toMs(w.last_triggered),
      JSON.stringify(w),
      now,
    );
  }
  for (const id of diff.toDelete) {
    deleteStmt.run(id);
  }
  return true;
}

function applyWatchesMutation(mutator) {
  const { getDb, withTransaction } = require('./db');
  let db;
  try { db = getDb(); }
  catch (e) {
    throw new Error(`engine/watches-store: SQLite unavailable (${e.message}); cannot mutate watches`);
  }

  return withTransaction(db, () => {
    _resyncIfJsonDiverged(db);
    const before = readWatches();
    const beforeSnapshot = JSON.parse(JSON.stringify(before));
    const next = mutator(before);
    const after = (next === undefined || next === null)
      ? before
      : (Array.isArray(next) ? next : before);
    const diff = _computeWatchesDiff(beforeSnapshot, after);
    const wrote = _applyWatchesDiff(db, diff);
    return { wrote, result: after };
  });
}

function _mirrorJsonFromSql(filePath) {
  try {
    const shared = require('./shared');
    const { getDb } = require('./db');
    // Read SQL directly — bypass the JSON fallback so a deletion that
    // leaves SQL empty doesn't resurrect from the stale JSON content.
    const items = _readWatchesFromSqlOnly(getDb());
    const target = filePath || _watchesFilePath();
    shared.safeWrite(target, items);
    const h = _fileContentHash(target);
    if (h != null) _lastMirrorHash = h;
  } catch {
    // Mirror failures are non-fatal — SQL has already committed.
  }
}

function _resetWatchesTableForTest() {
  const { getDb } = require('./db');
  try { getDb().exec('DELETE FROM watches'); } catch { /* not initialized */ }
  _lastMirrorHash = null;
}

module.exports = {
  readWatches,
  applyWatchesMutation,
  _mirrorJsonFromSql,
  _resetWatchesTableForTest,
};
