import { DatabaseSync } from 'node:sqlite';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import crypto from 'node:crypto';
import { DATA_DIR, DB_FILE, ensureDirs } from './paths.js';
import { REPORTING_VIEWS_SQL, SCHEMA_SQL } from './schema.js';
import { seedDefaults } from './seeds.js';

// Machine-wide daemon lock.
//
// globalThis flags only dedupe within one V8 isolate. Next.js dev bundles
// lib/db.js into multiple isolates (per route/runtime), so each used to start
// its own cron + heartbeat loop — N isolates meant N loops all self-POSTing the
// tick routes (observed ~3 heartbeat POSTs/sec instead of 1 per 5s, which pegged
// the dev server and overheated the machine). The lockfile is visible to every
// isolate AND process on the host, so exactly one owner runs each loop.
//
// It lives in os.tmpdir(), NOT under DATA_DIR/the project tree, so the per-tick
// mtime refresh can never trip the Next dev file-watcher and reintroduce the
// recompile storm this is meant to stop. The name is keyed to DATA_DIR so two
// madarX instances with different data dirs don't fight over one lock.
//
// Liveness is by mtime: the owner refreshes the lock every tick; a lock older
// than `staleMs` means the owner died (e.g. kill -9 with no cleanup) and is
// reclaimed. Worst case is one overlapping tick during reclaim, which the
// heartbeat worker's own task leasing already tolerates.
function daemonLockPath(name) {
  const key = crypto.createHash('sha1').update(DATA_DIR).digest('hex').slice(0, 12);
  return path.join(os.tmpdir(), `madarx-daemon-${key}-${name}.lock`);
}

function acquireDaemonLock(name, intervalMs) {
  const lockPath = daemonLockPath(name);
  const staleMs = Math.max(intervalMs * 3, 15000);
  const claim = () => {
    const fd = fs.openSync(lockPath, 'wx'); // O_EXCL — fails if it already exists
    try { fs.writeSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() })); }
    finally { fs.closeSync(fd); }
  };
  try {
    claim();
    return lockPath;
  } catch (err) {
    if (err.code !== 'EEXIST') return null;
  }
  let fresh = false;
  try { fresh = (Date.now() - fs.statSync(lockPath).mtimeMs) < staleMs; } catch { /* vanished */ }
  if (fresh) return null; // a live owner is running this loop
  try {
    fs.rmSync(lockPath, { force: true }); // reclaim a stale lock
    claim();
    return lockPath;
  } catch {
    return null; // a sibling won the reclaim race
  }
}

function refreshDaemonLock(lockPath) {
  try { const now = new Date(); fs.utimesSync(lockPath, now, now); } catch { /* best-effort */ }
}

let _db = null;
let _path = null;

export function getDb() {
  if (_db && _path === DB_FILE) return _db;
  if (_db) {
    try { _db.close(); } catch {}
    _db = null;
  }
  ensureDirs();
  const db = new DatabaseSync(DB_FILE);
  db.exec('PRAGMA journal_mode=WAL;');
  db.exec('PRAGMA busy_timeout=5000;');
  db.exec('PRAGMA foreign_keys=ON;');
  db.exec(SCHEMA_SQL);

  for (const sql of [
    'ALTER TABLE artifacts ADD COLUMN title TEXT',
    'ALTER TABLE artifacts ADD COLUMN content TEXT',
    'ALTER TABLE artifacts ADD COLUMN source_md TEXT',
    'ALTER TABLE agents ADD COLUMN system_prompt TEXT',
    'ALTER TABLE agents ADD COLUMN memory TEXT',
    'ALTER TABLE agents ADD COLUMN model TEXT',
    'ALTER TABLE runs ADD COLUMN output TEXT',
    'ALTER TABLE hermes_jobs ADD COLUMN cwd TEXT',
    'ALTER TABLE hermes_jobs ADD COLUMN timeout_ms INTEGER',
    'ALTER TABLE connectors ADD COLUMN type TEXT',
    'ALTER TABLE connectors ADD COLUMN capabilities TEXT',
    'ALTER TABLE connectors ADD COLUMN metadata TEXT',
    'ALTER TABLE schedules ADD COLUMN last_queued_at TEXT',
    'ALTER TABLE schedules ADD COLUMN last_queued_window TEXT',
    'ALTER TABLE heartbeat_tasks ADD COLUMN approval_request_id TEXT',
    'ALTER TABLE agents ADD COLUMN reports_to TEXT',
    'ALTER TABLE agents ADD COLUMN role TEXT',
    'ALTER TABLE agents ADD COLUMN title TEXT',
    'ALTER TABLE agents ADD COLUMN status_reason TEXT',
    'ALTER TABLE agents ADD COLUMN autonomy_mode TEXT',
    'ALTER TABLE agents ADD COLUMN budget_monthly_cents INTEGER DEFAULT 0',
    'ALTER TABLE agents ADD COLUMN spent_monthly_cents INTEGER DEFAULT 0',
    'ALTER TABLE agents ADD COLUMN permissions_json TEXT',
    'ALTER TABLE agents ADD COLUMN metadata_json TEXT',
    'ALTER TABLE agents ADD COLUMN project_id TEXT',
    'ALTER TABLE agents ADD COLUMN template_slug TEXT',
    'ALTER TABLE secrets ADD COLUMN project_id TEXT',
    'ALTER TABLE audit_logs ADD COLUMN project_id TEXT',
    'ALTER TABLE audit_logs ADD COLUMN correlation_id TEXT',
    'ALTER TABLE tool_egress ADD COLUMN project_id TEXT',
    'ALTER TABLE activity_log ADD COLUMN project_id TEXT',
    'ALTER TABLE artifacts ADD COLUMN project_id TEXT',
    'ALTER TABLE heartbeat_tasks ADD COLUMN project_id TEXT',
    'ALTER TABLE transcripts ADD COLUMN engine TEXT',
    'ALTER TABLE transcripts ADD COLUMN language TEXT',
    'ALTER TABLE transcripts ADD COLUMN duration_seconds REAL',
    'ALTER TABLE approval_requests ADD COLUMN project_id TEXT',
    'ALTER TABLE projects ADD COLUMN folder_path TEXT',
    'ALTER TABLE tasks ADD COLUMN due_at TEXT',
    'ALTER TABLE tasks ADD COLUMN tags_json TEXT',
    'ALTER TABLE tasks ADD COLUMN estimate_minutes INTEGER',
    'ALTER TABLE tasks ADD COLUMN watchers_json TEXT',
    'ALTER TABLE tasks ADD COLUMN recurrence TEXT',
    'ALTER TABLE tasks ADD COLUMN acceptance_criteria TEXT',
    'ALTER TABLE tasks ADD COLUMN recurred_from_task_id TEXT',
    'ALTER TABLE artifacts ADD COLUMN task_id TEXT',
    // Privacy spec
    'ALTER TABLE artifacts ADD COLUMN is_protected INTEGER NOT NULL DEFAULT 0',
    // Auth spec — projects
    'ALTER TABLE projects ADD COLUMN org_id TEXT',
    // Auth spec — artifacts ACL/encryption
    'ALTER TABLE artifacts ADD COLUMN owner_user_id TEXT',
    "ALTER TABLE artifacts ADD COLUMN sharing_scope TEXT NOT NULL DEFAULT 'private'",
    'ALTER TABLE artifacts ADD COLUMN encrypted INTEGER NOT NULL DEFAULT 0',
    'ALTER TABLE artifacts ADD COLUMN encryption_key_id TEXT',
    // Auth spec — audit
    'ALTER TABLE audit_logs ADD COLUMN user_id TEXT',
    'ALTER TABLE audit_logs ADD COLUMN org_id TEXT',
    'ALTER TABLE audit_logs ADD COLUMN ip TEXT',
    'ALTER TABLE audit_logs ADD COLUMN user_agent TEXT',
    'ALTER TABLE audit_logs ADD COLUMN action_target_id TEXT',
    'ALTER TABLE audit_logs ADD COLUMN action_target_type TEXT',
    // Auth spec — activity_log
    'ALTER TABLE activity_log ADD COLUMN user_id TEXT',
    'ALTER TABLE activity_log ADD COLUMN org_id TEXT',
    'ALTER TABLE activity_log ADD COLUMN ip TEXT',
    // Auth spec — service principal propagation
    'ALTER TABLE heartbeat_tasks ADD COLUMN acting_on_behalf_of_user_id TEXT',
    'ALTER TABLE schedules ADD COLUMN created_by_user_id TEXT',
    // Scheduler backfill: per-schedule policy for catching up missed windows.
    "ALTER TABLE schedules ADD COLUMN catchup_policy TEXT DEFAULT 'fire_all'",
    // Folder organisation for artifacts (parity with disk_files).
    'ALTER TABLE artifacts ADD COLUMN folder_id TEXT',
    // Dashboard spaces — server-side replacement for the localStorage store.
    'ALTER TABLE artifacts ADD COLUMN space_id TEXT',
    // Per-agent default publish space — scheduled + manual runs land here.
    'ALTER TABLE agents ADD COLUMN default_space_id TEXT',
    // Artifact dedupe: idempotency key derived from heartbeat task or
    // (agent_id, title, project_id, date bucket). Prevents heartbeat retries
    // and same-day duplicate runs from spawning multiple artifacts.
    'ALTER TABLE artifacts ADD COLUMN heartbeat_task_id TEXT',
    'ALTER TABLE artifacts ADD COLUMN dedupe_key TEXT',
    // P0: share link token stored alongside HMAC so owners can reconstruct /share/<token> URLs
    'ALTER TABLE artifact_share_links ADD COLUMN token TEXT',
    // Explicit "pin to dashboard space" marker so the artifact bypasses the
    // dashboard's project filter (a user pinning a brixfund-project artifact
    // to a space wants it visible regardless of which project they're
    // currently viewing on the dashboard).
    'ALTER TABLE artifacts ADD COLUMN pinned_to_space_at TEXT',
    // JSON workflow authoring: persist the original spec as the source of
    // truth so agent-authored / API-authored workflows survive re-seeds, and
    // record who authored each definition.
    'ALTER TABLE process_definitions ADD COLUMN source_spec_json TEXT',
    'ALTER TABLE process_definitions ADD COLUMN schema_version INTEGER NOT NULL DEFAULT 1',
    'ALTER TABLE process_definitions ADD COLUMN created_by_user_id TEXT',
    'ALTER TABLE process_definitions ADD COLUMN created_by_agent_id TEXT',
    'ALTER TABLE tool_allowlists ADD COLUMN org_id TEXT',
    'ALTER TABLE tool_allowlists ADD COLUMN created_by_user_id TEXT',
    // Standing deliverable contract: what "done"/good output looks like for
    // this agent. Default expected-output, overridable per task or per call.
    'ALTER TABLE agents ADD COLUMN expected_output TEXT',
    // Mines: per-project default dashboard space. Artifacts generated for the
    // project (manual or by a mines agent) are pinned to this space so they
    // surface as windows on the dashboard board.
    'ALTER TABLE mines_projects ADD COLUMN default_space_id TEXT',
  ]) {
    try { db.exec(sql); } catch (err) {
      if (!/duplicate column name/i.test(String(err?.message || err))) throw err;
    }
  }
  db.exec('CREATE INDEX IF NOT EXISTS idx_agents_reports_to ON agents(reports_to);');
  db.exec('CREATE INDEX IF NOT EXISTS idx_tasks_due_at ON tasks(due_at);');
  db.exec('DROP INDEX IF EXISTS idx_secrets_one_active;');
  db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_secrets_one_active ON secrets(name, scope, COALESCE(project_id, '')) WHERE status = 'active';");
  db.exec('CREATE INDEX IF NOT EXISTS idx_audit_project_at ON audit_logs(project_id, at DESC);');
  db.exec('CREATE INDEX IF NOT EXISTS idx_egress_project_at ON tool_egress(project_id, at DESC);');
  db.exec('CREATE INDEX IF NOT EXISTS idx_activity_project ON activity_log(project_id, created_at DESC);');
  db.exec('CREATE INDEX IF NOT EXISTS idx_artifacts_project ON artifacts(project_id, created_at DESC);');
  db.exec('CREATE INDEX IF NOT EXISTS idx_artifacts_task ON artifacts(task_id, created_at DESC);');
  db.exec('CREATE INDEX IF NOT EXISTS idx_artifacts_folder ON artifacts(project_id, folder_id);');
  db.exec('CREATE INDEX IF NOT EXISTS idx_artifacts_space ON artifacts(space_id);');
  db.exec([
    'CREATE INDEX IF NOT EXISTS idx_artifacts_heartbeat_task ON artifacts(heartbeat_task_id);',
    'CREATE INDEX IF NOT EXISTS idx_artifacts_dedupe_key ON artifacts(dedupe_key);',
  ].join('\n'));

  // ── Privacy + Auth foundation indexes ──────────────────────────────────────
  db.exec('CREATE INDEX IF NOT EXISTS idx_artifacts_is_protected ON artifacts(is_protected, project_id);');
  db.exec('CREATE INDEX IF NOT EXISTS idx_projects_org ON projects(org_id);');
  db.exec('CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_logs(user_id, at DESC);');
  db.exec('CREATE INDEX IF NOT EXISTS idx_audit_org ON audit_logs(org_id, at DESC);');
  db.exec('CREATE INDEX IF NOT EXISTS idx_audit_target ON audit_logs(action_target_type, action_target_id, at DESC);');

  // ── Append-only enforcement on audit_logs ──────────────────────────────────
  // audit_logs rows must never be mutated or deleted — they are an immutable
  // compliance trail. These triggers enforce that at the SQLite engine level
  // so no application code path (including future refactors) can bypass them.
  db.exec([
    'CREATE TRIGGER IF NOT EXISTS audit_logs_no_update',
    'BEFORE UPDATE ON audit_logs',
    'BEGIN',
    "  SELECT RAISE(ABORT, 'audit_logs rows are immutable');",
    'END;',
  ].join('\n'));
  db.exec([
    'CREATE TRIGGER IF NOT EXISTS audit_logs_no_delete',
    'BEFORE DELETE ON audit_logs',
    'BEGIN',
    "  SELECT RAISE(ABORT, 'audit_logs rows cannot be deleted');",
    'END;',
  ].join('\n'));

  // ── secrets table migration (phase B rotation fix) ─────────────────────────
  // Earlier builds had UNIQUE(name, scope, status) as a table-level constraint,
  // which prevented rotating the same secret more than once (the second rotation
  // created two 'rotated' rows, violating the constraint). The fix replaces it
  // with a partial unique index that only covers status='active'.
  //
  // This migration detects the old sqlite_autoindex covering three columns on the
  // secrets table and, if found, rebuilds the table in-place: copies only the
  // most-recent active row per (name, scope), drops the old table, renames the
  // new one, then recreates the required indexes. Wrapped in a transaction so
  // the DB is never left in a partially-migrated state.
  //
  // Safe to re-run: if the autoindex is absent (fresh install or already migrated)
  // the block is skipped entirely.
  (function migrateSecretsConstraint() {
    try {
      const indexes = db.prepare("PRAGMA index_list('secrets')").all();
      const hasOldAutoindex = indexes.some(idx =>
        idx.origin === 'u' &&
        idx.name.startsWith('sqlite_autoindex_secrets_') &&
        db.prepare(`PRAGMA index_info('${idx.name}')`).all().length === 3
      );
      if (!hasOldAutoindex) return; // fresh install or already migrated

      db.exec('BEGIN');
      db.exec([
        'CREATE TABLE secrets_new (',
        '  id TEXT PRIMARY KEY,',
        "  name TEXT NOT NULL,",
        "  scope TEXT NOT NULL DEFAULT 'global',",
        '  value_encrypted BLOB NOT NULL,',
        '  iv BLOB NOT NULL,',
        '  auth_tag BLOB NOT NULL,',
        "  status TEXT NOT NULL DEFAULT 'active',",
        '  source TEXT,',
        '  metadata_json TEXT,',
        '  last_used_at TEXT,',
        '  created_at TEXT NOT NULL,',
        '  rotated_at TEXT',
        ')',
      ].join('\n'));
      // Preserve only the current active row per (name, scope).
      // Rotated/revoked rows from the old schema are dropped — they are
      // superseded and cannot be decrypted without their original IVs anyway.
      db.exec([
        'INSERT INTO secrets_new',
        '  SELECT id, name, scope, value_encrypted, iv, auth_tag, status,',
        '         source, metadata_json, last_used_at, created_at, rotated_at',
        '  FROM secrets',
        "  WHERE status = 'active'",
      ].join('\n'));
      db.exec('DROP TABLE secrets');
      db.exec('ALTER TABLE secrets_new RENAME TO secrets');
      db.exec([
        'CREATE UNIQUE INDEX IF NOT EXISTS idx_secrets_one_active',
        "  ON secrets(name, scope) WHERE status = 'active'",
      ].join('\n'));
      db.exec([
        'CREATE INDEX IF NOT EXISTS idx_secrets_name_status',
        '  ON secrets(name, status)',
      ].join('\n'));
      db.exec('COMMIT');
    } catch (err) {
      try { db.exec('ROLLBACK'); } catch {}
      throw err;
    }
  })();

  db.exec(REPORTING_VIEWS_SQL);

  _db = db;
  _path = DB_FILE;
  seedDefaults(db);
  startInProcDaemons();
  return db;
}

export function resetDbHandle() {
  if (_db) { try { _db.close(); } catch {} }
  _db = null;
  _path = null;
}

export function nowIso() { return new Date().toISOString(); }

export function newId(prefix = 'id') {
  return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}

// Auto-start cron + heartbeat worker in-process after the first getDb() call.
//
// Both daemons share the same SQLite handle and live-events bus as the Next.js
// API routes, so they are guaranteed to see each other's writes immediately.
//
// Escape hatch: set MADARX_DISABLE_INPROC_DAEMON=1 when running the daemons as
// separate processes via `npm run daemon` / `npm run heartbeat:worker`.
//
// Skipped in test environments (NODE_ENV=test, or an explicit data-dir override
// is set — MADARX_DATA_DIR / the legacy VILLA515_DATA_DIR). A real CLI install
// sets MADARX_HOME (not the explicit override), so it still starts the daemons.
//
// Each process starts at most one cron interval and one heartbeat interval,
// guarded by globalThis flags to survive Next.js HMR module reloads.
function startInProcDaemons() {
  if (process.env.MADARX_DISABLE_INPROC_DAEMON === '1') return;
  if (process.env.NODE_ENV === 'test') return;
  if (process.env.MADARX_DATA_DIR || process.env.VILLA515_DATA_DIR) return;

  const cronIntervalMs = Number(process.env.CRON_DAEMON_INTERVAL_MS || 30000);
  const hbIntervalMs = Number(process.env.HEARTBEAT_WORKER_INTERVAL_MS || 5000);

  // In-process daemons under Next.js dev (Webpack/Turbopack bundling) had a
  // load-bearing bug: `await import('./heartbeat-worker.js')` inside a
  // setInterval callback could resolve to a stale module snapshot. The first
  // claimed process_stage task after server boot dispatched against a
  // half-wired module graph and silently dropped the work — task was marked
  // complete with `{}` payload but execution row stayed in 'queued' forever.
  //
  // Fix: route the tick through the existing HTTP endpoint instead of doing
  // an internal dynamic import. The route handler is fully managed by Next's
  // dev server (route compilation, fresh module graph per request) and was
  // already verified to work end-to-end. Slight overhead per tick (one
  // localhost request) is acceptable for a 5s/30s schedule.
  //
  // Standalone daemons (scripts/*-daemon.mjs) live in their own Node process
  // with no bundler in the path — they kept their direct dynamic import.

  const host = process.env.MADARX_INPROC_HOST || '127.0.0.1';
  const port = process.env.PORT || process.env.MADARX_PORT || '8787';
  const baseUrl = `http://${host}:${port}`;
  const POKE_TIMEOUT_MS = Number(process.env.MADARX_INPROC_POKE_TIMEOUT_MS || 5000);

  async function pokeRoute(path) {
    // Lazy-import the shared inproc token so this module stays decoupled
    // from node:crypto for callers that never spin the daemons (tests,
    // standalone scripts).
    const { getInprocToken, INPROC_TOKEN_HEADER } = await import('./inproc-token.js');
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), POKE_TIMEOUT_MS);
    try {
      const res = await fetch(`${baseUrl}${path}`, {
        method: 'POST',
        headers: {
          'content-type': 'application/json',
          [INPROC_TOKEN_HEADER]: getInprocToken(),
        },
        body: '{}',
        signal: controller.signal,
      });
      if (!res.ok) throw new Error(`route ${path} returned ${res.status}`);
      return await res.json();
    } finally {
      clearTimeout(timer);
    }
  }

  // Per-isolate fast-path (globalThis) + machine-wide lock (acquireDaemonLock)
  // together guarantee exactly one cron loop and one heartbeat loop per host.
  function startDaemonLoop({ name, flag, intervalMs, route, tag }) {
    if (globalThis[flag]) return;

    const tryStart = () => {
      if (globalThis[flag]) return true;
      const lock = acquireDaemonLock(name, intervalMs);
      if (!lock) return false;
      globalThis[flag] = true;
      process.once('exit', () => { try { fs.rmSync(lock, { force: true }); } catch {} });
      // Circuit breaker: an orphaned web child (supervisor died via SIGKILL /
      // terminal force-close) keeps this loop alive — next-server's own handles
      // outlive the unref'd timer — while its HTTP target is gone, spamming
      // `fetch failed` indefinitely. After ~30s of consecutive failures, treat
      // the server as dead, stop ticking, and release the lock so a live owner
      // can take over.
      let consecutiveFailures = 0;
      const failureLimit = Math.max(6, Math.ceil(30000 / intervalMs));
      const handle = setInterval(async () => {
        refreshDaemonLock(lock);
        try {
          await pokeRoute(route);
          consecutiveFailures = 0;
        } catch (err) {
          consecutiveFailures++;
          console.warn(`[${tag}:inproc] tick error:`, err?.message || err);
          if (consecutiveFailures >= failureLimit) {
            console.warn(`[${tag}:inproc] ${consecutiveFailures} consecutive failures — server gone, stopping loop`);
            clearInterval(handle);
            globalThis[flag] = false;
            try { fs.rmSync(lock, { force: true }); } catch {}
          }
        }
      }, intervalMs);
      // unref so the daemon never keeps a process alive on its own. The Next
      // server stays up via its listening socket, so the loop runs there. But a
      // short-lived CLI/script that merely imports lib/db.js (getDb auto-starts
      // this) will now EXIT when its work is done instead of becoming an
      // immortal tick-poster — the exact bug that spawned 8 zombie node procs
      // each POSTing /api/heartbeat/worker every 5s for a day.
      if (typeof handle.unref === 'function') handle.unref();
      return true;
    };

    if (tryStart()) return;
    // Another owner holds the lock (or a stale lock still looks fresh). Retry
    // once after the staleness window so a hard-killed owner (kill -9, no
    // cleanup) is taken over instead of leaving the daemon dead.
    const staleMs = Math.max(intervalMs * 3, 15000);
    const retry = setTimeout(tryStart, staleMs + Math.floor(Math.random() * 1000));
    if (typeof retry.unref === 'function') retry.unref();
  }

  startDaemonLoop({ name: 'cron', flag: '__madarxCronStarted', intervalMs: cronIntervalMs, route: '/api/cron/tick', tag: 'cron' });
  startDaemonLoop({ name: 'heartbeat', flag: '__madarxHeartbeatStarted', intervalMs: hbIntervalMs, route: '/api/heartbeat/worker', tag: 'heartbeat' });
}
