// engine/work-items-store.js — SQL-backed implementation of the
// per-file work-items array. Same external contract as the legacy
// JSON-file reader:
//
//   readWorkItemsForScope(scope)        -> [wi, wi, ...]
//   applyWorkItemsMutation(scope, fn)   -> runs fn against the array,
//                                          diffs by id, applies in one
//                                          transaction, returns
//                                          { wrote, result }.
//
// `scope` is 'central' for the top-level work-items.json or a project
// name for projects/<name>/work-items.json. Mirrors the file convention
// established by migration 003.
//
// All callers go through shared.mutateWorkItems and
// queries.getWorkItems — those are thin wrappers over the helpers here.

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

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;
}

// Map an absolute filePath → scope key. The store doesn't know about the
// caller's file layout; we derive scope from the path's last two segments
// because every work-items file in this codebase sits at either
// <root>/work-items.json (central) or <root>/projects/<name>/work-items.json.
function scopeForFilePath(filePath) {
  const norm = String(filePath).replace(/\\/g, '/');
  const m = norm.match(/projects\/([^/]+)\/work-items\.json$/);
  if (m) return m[1];
  // Anything else — including the central work-items.json under MINIONS_DIR
  // and any one-off test fixture file — lands in 'central'. Tests that
  // need to keep multiple fixture files distinct should put them under a
  // synthetic projects/<name>/ subtree.
  return 'central';
}

// JSON fallback path resolution. Used both for the empty-SQL fallback
// (test seeding pattern from Phase 1) and for the post-write JSON mirror.
function _filePathForScope(scope) {
  const shared = require('./shared');
  if (scope === 'central') {
    return path.join(shared.MINIONS_DIR, 'work-items.json');
  }
  return path.join(shared.MINIONS_DIR, 'projects', scope, 'work-items.json');
}

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

function _readJsonArrayFallback(scope) {
  const fp = _filePathForScope(scope);
  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(`[work-items-store] corrupt JSON in ${fp}: ${e.message}`);
    } catch { /* console may be wrapped in tests */ }
    return [];
  }
}

function readWorkItemsForScope(scope) {
  const { getDb } = require('./db');
  const db = getDb();

  _resyncScopeIfJsonDiverged(db, scope);

  const rows = db.prepare(`
    SELECT data FROM work_items
    WHERE scope = ?
    ORDER BY rowid
  `).all(scope);

  if (rows.length === 0) {
    // SQL empty AND JSON has content means a test seeded via fs.writeFileSync
    // (legacy helper) or a fresh install pre-migration. Returning the JSON
    // keeps those call sites working without touching every helper.
    const fallback = _readJsonArrayFallback(scope);
    if (fallback.length > 0) return fallback;
    return [];
  }

  const out = [];
  for (const row of rows) {
    const wi = _parseRow(row);
    if (wi) out.push(wi);
  }
  return out;
}

// If the JSON mirror's mtime advanced since our last mirror write, the
// file was rewritten outside the store (test cleanup + safeWrite, manual
// operator edit, external tool). Treat the JSON as canonical and rebuild
// SQL for that scope from it. Called from BOTH read and write paths so
// readers don't see stale SQL state and writers don't compute diffs
// against a SQL snapshot that no longer matches what callers expect.
//
// Safe to call inside `withTransaction` — DELETE + INSERT against a
// scope is bounded by the JSON's row count.
function _resyncScopeIfJsonDiverged(db, scope) {
  const jsonPath = _filePathForScope(scope);
  const currentHash = _fileContentHash(jsonPath);
  const lastHash = _lastMirrorHashByScope.get(scope);
  if (currentHash == null) return;
  if (lastHash != null && currentHash === lastHash) return;
  if (lastHash == null) {
    const sqlHas = db.prepare('SELECT 1 FROM work_items WHERE scope = ? LIMIT 1').get(scope);
    if (sqlHas) {
      _lastMirrorHashByScope.set(scope, currentHash);
      return;
    }
  }
  if (lastHash == null) {
    const sqlHas = db.prepare('SELECT 1 FROM work_items WHERE scope = ? LIMIT 1').get(scope);
    if (sqlHas) {
      _lastMirrorHashByScope.set(scope, currentHash);
      return;
    }
  }
  _hydrateScopeFromJson(db, scope);
  _lastMirrorHashByScope.set(scope, currentHash);
}

// Enumerate scopes that have a JSON file on disk — used as the SQL-
// unavailable fallback for `readAllWorkItems`. Walks `<MINIONS_DIR>/
// projects/*/work-items.json` plus the central `<MINIONS_DIR>/work-
// items.json`. Silently swallows missing dirs (fresh installs).
function _enumerateJsonScopes() {
  const shared = require('./shared');
  const scopes = [];
  const centralPath = path.join(shared.MINIONS_DIR, 'work-items.json');
  if (fs.existsSync(centralPath)) scopes.push('central');
  const projectsDir = path.join(shared.MINIONS_DIR, 'projects');
  try {
    for (const d of fs.readdirSync(projectsDir, { withFileTypes: true })) {
      if (!d.isDirectory()) continue;
      const fp = path.join(projectsDir, d.name, 'work-items.json');
      if (fs.existsSync(fp)) scopes.push(d.name);
    }
  } catch { /* projects dir missing on fresh install */ }
  return scopes;
}

// Read all rows across all scopes — used by queries.getWorkItems which
// needs to surface central + every project's items in a single shot,
// tagged with their source scope.
function readAllWorkItems() {
  const { getDb } = require('./db');
  const db = getDb();

  // Pick up any external JSON edits for every scope SQL knows about.
  // Also resync 'central' explicitly so first-time reads on a JSON-only
  // install hydrate the central scope. Per-project scopes get checked
  // lazily on next read or mutation of that scope.
  try { _resyncScopeIfJsonDiverged(db, 'central'); } catch {}
  const knownScopes = db.prepare('SELECT DISTINCT scope FROM work_items').all().map(r => r.scope);
  for (const s of knownScopes) {
    if (s === 'central') continue;
    try { _resyncScopeIfJsonDiverged(db, s); } catch {}
  }

  const rows = db.prepare('SELECT data, scope FROM work_items ORDER BY rowid').all();
  const out = [];
  for (const row of rows) {
    const wi = _parseRow(row);
    if (!wi) continue;
    // Preserve the legacy `_source` convention from queries.getWorkItems.
    wi._source = row.scope;
    out.push(wi);
  }
  return out;
}

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

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

function _applyWorkItemsDiff(db, diff) {
  if (diff.toUpsert.length === 0 && diff.toDelete.length === 0) return false;
  const now = Date.now();
  const upsertStmt = db.prepare(`
    INSERT INTO work_items (id, scope, status, type, agent, parent_id, created_at, completed_at, data, updated_at)
    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    ON CONFLICT(scope, id) DO UPDATE SET
      status        = excluded.status,
      type          = excluded.type,
      agent         = excluded.agent,
      parent_id     = excluded.parent_id,
      created_at    = excluded.created_at,
      completed_at  = excluded.completed_at,
      data          = excluded.data,
      updated_at    = excluded.updated_at
  `);
  const deleteStmt = db.prepare('DELETE FROM work_items WHERE scope = ? AND id = ?');

  for (const wi of diff.toUpsert) {
    upsertStmt.run(
      String(wi.id),
      diff.scope,
      String(wi.status || 'pending'),
      wi.type || null,
      wi.dispatched_to || wi.agent || null,
      wi.parent_id || null,
      _toMs(wi.created_at),
      _toMs(wi.completed_at || wi.completedAt),
      JSON.stringify(wi),
      now,
    );
  }
  for (const id of diff.toDelete) {
    deleteStmt.run(diff.scope, id);
  }
  return true;
}

// Apply a mutation to the work-items array for a given scope. Wrapped in
// a single transaction so the diff and apply can't race against a
// concurrent reader/writer.
//
// Returns { wrote, result }:
//   wrote  — true iff at least one INSERT/UPDATE/DELETE landed
//   result — the post-mutation array (legacy return shape of
//            mutateJsonFileLocked)
// Content-hash fingerprint of the JSON mirror per scope, so external
// edits are detected unambiguously. (mtime, size) is too coarse: same-
// length timestamp swaps (last_checked / last_triggered / completed_at)
// preserve both axes. SHA-1 is sub-ms on a few-hundred-KB file.
const _lastMirrorHashByScope = new Map();

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 _hydrateScopeFromJson(db, scope) {
  const jsonItems = _readJsonArrayFallback(scope);
  // DELETE before re-insert: callers that wrote a smaller JSON file
  // (test cleanup() removing items) must end up with a smaller SQL state.
  db.prepare('DELETE FROM work_items WHERE scope = ?').run(scope);
  if (jsonItems.length === 0) return;
  const now = Date.now();
  const ins = db.prepare(`
    INSERT INTO work_items (id, scope, status, type, agent, parent_id, created_at, completed_at, data, updated_at)
    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    ON CONFLICT(scope, id) DO NOTHING
  `);
  for (const wi of jsonItems) {
    if (!wi || !wi.id) continue;
    ins.run(
      String(wi.id),
      scope,
      String(wi.status || 'pending'),
      wi.type || null,
      wi.dispatched_to || wi.agent || null,
      wi.parent_id || null,
      _toMs(wi.created_at),
      _toMs(wi.completed_at || wi.completedAt),
      JSON.stringify(wi),
      now,
    );
  }
}

function applyWorkItemsMutation(scope, mutator) {
  const { getDb, withTransaction } = require('./db');
  const db = getDb();

  return withTransaction(db, () => {
    // Re-hydrate SQL from JSON if the file was touched outside the
    // store (external edit, test re-seed, first-time hydrate after a
    // fresh install). Without this, the diff would compare against a
    // stale SQL snapshot and the mirror write would overwrite the JSON
    // with that stale state.
    _resyncScopeIfJsonDiverged(db, scope);

    const before = readWorkItemsForScope(scope);
    // Strip the _source decoration before handing to the mutator — it's
    // added by readAllWorkItems only and would round-trip as garbage into
    // the data column otherwise.
    for (const wi of before) { if (wi && wi._source) delete wi._source; }
    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 = _computeWorkItemsDiff(scope, beforeSnapshot, after);
    const wrote = _applyWorkItemsDiff(db, diff);
    return { wrote, result: after };
  });
}

// Mirror SQL → JSON file. Phase 2 keeps the file as a read-only derivative
// so legacy direct-readers keep working; SQL is the source of truth.
function _mirrorJsonFromSql(scope, filePath) {
  try {
    const shared = require('./shared');
    const { getDb } = require('./db');
    // Read SQL directly for this scope — bypass the JSON fallback so a
    // mutation that empties SQL for the scope doesn't resurrect stale
    // JSON content. (See pull-requests-store and watches-store for the
    // same defense.)
    const db = getDb();
    const rows = db.prepare('SELECT data FROM work_items WHERE scope = ? ORDER BY rowid').all(scope);
    const items = [];
    for (const row of rows) {
      const wi = _parseRow(row);
      if (!wi) continue;
      if (wi._source) delete wi._source;
      items.push(wi);
    }
    const target = filePath || _filePathForScope(scope);
    shared.safeWrite(target, items);
    const h = _fileContentHash(target);
    if (h != null) _lastMirrorHashByScope.set(scope, h);
  } catch {
    // Mirror failures are non-fatal: SQL has already committed.
  }
}

// Test seam.
function _resetWorkItemsTableForTest() {
  const { getDb } = require('./db');
  try { getDb().exec('DELETE FROM work_items'); } catch { /* not initialized */ }
}

// Drop every SQL row for `scope` and forget its mirror-hash. Called by
// removeProject after the JSON file is archived so safeJson shim reads no
// longer surface the removed project's records.
function dropScope(scope) {
  try {
    const { getDb } = require('./db');
    getDb().prepare('DELETE FROM work_items WHERE scope = ?').run(scope);
    _lastMirrorHashByScope.delete(scope);
  } catch { /* db unavailable */ }
}

module.exports = {
  scopeForFilePath,
  readWorkItemsForScope,
  readAllWorkItems,
  applyWorkItemsMutation,
  dropScope,
  _filePathForScope,
  _mirrorJsonFromSql,
  _resetWorkItemsTableForTest,
};
