/**
 * engine/qa-runs.js — Lifecycle + persistence for QA validation runs.
 *
 * One run record represents a single dispatch of a validation runbook against
 * a managed target. Records carry status, timing, work-item linkage, and an
 * artifacts array (screenshots, logs, videos captured during the run).
 *
 * Persistence: SQLite (engine/state.db, `qa_runs` table) as source of truth
 * since Phase 8 (PRD: migrate-qa-state-to-sqlite). The legacy JSON sidecar
 * at engine/qa-runs.json is dual-written on every mutation when
 * `engine.qaDualWriteJson` is true (default) so external tooling and the
 * rollback path keep working. Artifacts live on disk under
 * engine/qa-artifacts/<runId>/ — the run record stores relative paths
 * (`type`, `path`, `label`, `capturedAt`), and the dashboard exposes them
 * through GET /api/qa/artifacts/<runId>/<file>.
 *
 * Concurrency: every mutation goes through shared.mutateQaRuns, which routes
 * to small-state-store.applyQaRunsMutation inside a SQLite transaction
 * (BEGIN IMMEDIATE) plus a content-hash divergence rehydrate on read.
 * Callbacks are synchronous and never await.
 *
 * Status state machine:
 *
 *   pending ──▶ running ──▶ passed
 *                       ╲──▶ failed
 *                       ╲──▶ errored
 *
 * Illegal transitions throw with a descriptive error. Terminal statuses
 * (passed / failed / errored) cannot transition further. `deleteRun` is
 * only allowed once a run is terminal.
 */

const fs = require('fs');
const path = require('path');
const shared = require('./shared');
const { mutateQaRuns, uid, ts, log } = shared;

// Cap qa-runs.json so the file (and qa_runs table) doesn't grow unboundedly
// over months of nightly QA dispatch. Mirrors the 2500-entry cap on
// engine/log.json. createRun trims oldest-by-createdAt when crossing the
// threshold; terminal-status runs that have already shipped completion
// notifications are safe to drop.
const QA_RUNS_MAX_RECORDS = 2000;

const QA_RUN_STATUS = Object.freeze({
  PENDING: 'pending',
  RUNNING: 'running',
  PASSED: 'passed',
  FAILED: 'failed',
  ERRORED: 'errored',
});

const TERMINAL_STATUSES = new Set([
  QA_RUN_STATUS.PASSED,
  QA_RUN_STATUS.FAILED,
  QA_RUN_STATUS.ERRORED,
]);

// Allowed forward transitions. Anything not enumerated here is rejected.
//
// PR #2697 review fix (W-mpeiwz6k0005bf34-c — Ripley): the lifecycle hook in
// engine/lifecycle.js parses the agent's qa-run-result.json sidecar and calls
// completeRun({status: 'passed'|'failed'|'errored'}) directly. It never calls
// markRunning, because the agent may crash before writing the sidecar (in
// which case the hook still needs to mark the run errored from `pending`).
// Allowing pending → {passed,failed,errored} keeps the production path from
// throwing "illegal transition" inside the hook's try/catch and leaving the
// run perma-pending. The state machine still rejects double-completion
// (terminal → terminal) so race-y double-writes can't silently overwrite.
const ALLOWED_TRANSITIONS = {
  [QA_RUN_STATUS.PENDING]: new Set([
    QA_RUN_STATUS.RUNNING,
    QA_RUN_STATUS.PASSED,
    QA_RUN_STATUS.FAILED,
    QA_RUN_STATUS.ERRORED,
  ]),
  [QA_RUN_STATUS.RUNNING]: new Set([
    QA_RUN_STATUS.PASSED,
    QA_RUN_STATUS.FAILED,
    QA_RUN_STATUS.ERRORED,
  ]),
  [QA_RUN_STATUS.PASSED]: new Set(),
  [QA_RUN_STATUS.FAILED]: new Set(),
  [QA_RUN_STATUS.ERRORED]: new Set(),
};

// Dynamic paths — respect MINIONS_TEST_DIR for test isolation. Kept exported
// for back-compat with tests that probe the JSON sidecar on disk.
function qaRunsPath() {
  return path.join(shared.MINIONS_DIR, 'engine', 'qa-runs.json');
}

function qaArtifactsDir() {
  return path.join(shared.MINIONS_DIR, 'engine', 'qa-artifacts');
}

function qaArtifactsDirForRun(runId) {
  return path.join(qaArtifactsDir(), runId);
}

function isValidStatus(status) {
  return Object.values(QA_RUN_STATUS).includes(status);
}

function validateTransition(from, to) {
  if (!isValidStatus(from)) throw new Error(`qa-runs: invalid source status "${from}"`);
  if (!isValidStatus(to)) throw new Error(`qa-runs: invalid target status "${to}"`);
  const allowed = ALLOWED_TRANSITIONS[from];
  if (!allowed.has(to)) {
    throw new Error(`qa-runs: illegal status transition ${from} -> ${to}`);
  }
}

function normalizeArtifact(a) {
  if (!a || typeof a !== 'object') return null;
  const type = typeof a.type === 'string' ? a.type : '';
  const p = typeof a.path === 'string' ? a.path : '';
  if (!type || !p) return null;
  return {
    type,
    path: p,
    label: typeof a.label === 'string' ? a.label : '',
    capturedAt: typeof a.capturedAt === 'string' && a.capturedAt ? a.capturedAt : ts(),
  };
}

/**
 * Create a new run record in 'pending' status. Also pre-creates the per-run
 * artifact directory so the agent can drop files into it.
 *
 * @param {object} opts
 * @param {string} opts.runbookId - id of the runbook being executed
 * @param {string} opts.targetName - managed-spawn target name being validated
 * @param {string} [opts.project] - project name (optional)
 * @param {string} [opts.workItemId] - originating work-item id (optional)
 * @returns {object} the created run record
 */
function createRun({ runbookId, targetName, project, workItemId } = {}) {
  if (!runbookId || typeof runbookId !== 'string') throw new Error('qa-runs: runbookId is required');
  if (!targetName || typeof targetName !== 'string') throw new Error('qa-runs: targetName is required');

  const run = {
    id: 'qarun-' + uid(),
    runbookId,
    targetName,
    project: project || null,
    workItemId: workItemId || null,
    status: QA_RUN_STATUS.PENDING,
    startedAt: null,
    completedAt: null,
    artifacts: [],
    summary: null,
    createdAt: ts(),
  };

  mutateQaRuns((runs) => {
    if (!Array.isArray(runs)) runs = [];
    runs.push(run);
    // Rotation: drop oldest-by-createdAt when over the cap. Cheap because
    // this runs only on createRun, not on every read.
    if (runs.length > QA_RUNS_MAX_RECORDS) {
      runs.sort((a, b) => ((a && a.createdAt) || '').localeCompare((b && b.createdAt) || ''));
      runs = runs.slice(runs.length - QA_RUNS_MAX_RECORDS);
    }
    return runs;
  });

  // Pre-create the artifact directory outside the lock — directory creation
  // is idempotent and slow file I/O must never run while holding the lock.
  try { fs.mkdirSync(qaArtifactsDirForRun(run.id), { recursive: true }); } catch (e) {
    log('warn', `qa-runs: mkdir artifacts dir failed for ${run.id}: ${e.message}`);
  }

  return run;
}

/**
 * Transition pending -> running. Stamps startedAt. Throws on illegal source state.
 * @param {string} id
 * @returns {object} updated run
 */
function markRunning(id) {
  if (!id) throw new Error('qa-runs: id is required');
  let captured = null;
  let transitionError = null;
  mutateQaRuns((runs) => {
    if (!Array.isArray(runs)) runs = [];
    const run = runs.find(r => r && r.id === id);
    if (!run) { transitionError = new Error(`qa-runs: run not found: ${id}`); return runs; }
    try { validateTransition(run.status, QA_RUN_STATUS.RUNNING); }
    catch (e) { transitionError = e; return runs; }
    run.status = QA_RUN_STATUS.RUNNING;
    run.startedAt = ts();
    captured = run;
    return runs;
  });
  if (transitionError) throw transitionError;
  return captured;
}

/**
 * Transition running -> passed|failed|errored. Stamps completedAt, records
 * summary, appends artifacts. Throws on illegal source state.
 *
 * @param {string} id
 * @param {object} opts
 * @param {string} opts.status - 'passed' | 'failed' | 'errored'
 * @param {string} [opts.summary] - human-readable summary
 * @param {Array<object>} [opts.artifacts] - artifact records {type, path, label?, capturedAt?}
 * @returns {object} updated run
 */
function completeRun(id, { status, summary, artifacts } = {}) {
  if (!id) throw new Error('qa-runs: id is required');
  if (!TERMINAL_STATUSES.has(status)) {
    throw new Error(`qa-runs: completeRun status must be one of ${[...TERMINAL_STATUSES].join(', ')}`);
  }
  const normalized = Array.isArray(artifacts)
    ? artifacts.map(normalizeArtifact).filter(Boolean)
    : [];

  let captured = null;
  let transitionError = null;
  mutateQaRuns((runs) => {
    if (!Array.isArray(runs)) runs = [];
    const run = runs.find(r => r && r.id === id);
    if (!run) { transitionError = new Error(`qa-runs: run not found: ${id}`); return runs; }
    try { validateTransition(run.status, status); }
    catch (e) { transitionError = e; return runs; }
    run.status = status;
    run.completedAt = ts();
    if (typeof summary === 'string') run.summary = summary;
    if (normalized.length) {
      if (!Array.isArray(run.artifacts)) run.artifacts = [];
      run.artifacts.push(...normalized);
    }
    captured = run;
    return runs;
  });
  if (transitionError) throw transitionError;
  return captured;
}

/**
 * Lookup a single run by id.
 * @param {string} id
 * @returns {object|null}
 */
function getRun(id) {
  if (!id) return null;
  const runs = _readRuns();
  const run = runs.find(r => r && r.id === id);
  return run || null;
}

// Internal helper — reads via the small-state store (SQL → JSON fallback)
// when available, falling back to direct JSON read if SQLite is unavailable.
function _readRuns() {
  try {
    const store = require('./small-state-store');
    const arr = store.readQaRuns();
    if (Array.isArray(arr)) return arr;
  } catch { /* fall back */ }
  return shared.safeJsonArr(qaRunsPath());
}

/**
 * List runs, newest first, optionally filtered by status, capped by limit.
 * @param {object} [opts]
 * @param {number} [opts.limit] - max records to return (no cap when omitted)
 * @param {string} [opts.status] - filter by status
 * @returns {Array<object>}
 */
function listRuns({ limit, status } = {}) {
  let runs = _readRuns();
  if (!Array.isArray(runs)) return [];
  if (status) {
    if (!isValidStatus(status)) return [];
    runs = runs.filter(r => r && r.status === status);
  }
  // Newest first by createdAt (fallback to id for ties — uid() is monotonic).
  runs = runs.slice().sort((a, b) => {
    const ac = (a && a.createdAt) || '';
    const bc = (b && b.createdAt) || '';
    if (ac === bc) return ((b && b.id) || '').localeCompare((a && a.id) || '');
    return ac < bc ? 1 : -1;
  });
  const n = Number(limit);
  if (Number.isFinite(n) && n > 0) runs = runs.slice(0, Math.floor(n));
  return runs;
}

/**
 * All runs linked to a work item, newest first.
 * @param {string} wi - work-item id
 * @returns {Array<object>}
 */
function getRunsForWorkItem(wi) {
  if (!wi) return [];
  const runs = _readRuns();
  return runs
    .filter(r => r && r.workItemId === wi)
    .sort((a, b) => {
      const ac = a.createdAt || '';
      const bc = b.createdAt || '';
      return ac < bc ? 1 : -1;
    });
}

/**
 * Back-fill workItemId on an existing run record. Used by the qa-validate
 * dispatch endpoint (dashboard.js handleQaRunbookRun) when the WI is created
 * after the run record so the dashboard can join the two. No-op (returns
 * null) when the run id is unknown.
 *
 * @param {string} id - run id
 * @param {string|null} workItemId - work-item id (or null to clear)
 * @returns {object|null} updated run, or null if not found
 */
function setRunWorkItemId(id, workItemId) {
  if (!id) return null;
  let captured = null;
  mutateQaRuns((runs) => {
    if (!Array.isArray(runs)) runs = [];
    const run = runs.find(r => r && r.id === id);
    if (run) {
      run.workItemId = workItemId || null;
      captured = run;
    }
    return runs;
  });
  return captured;
}

// W-mpxp90xs000i5732 — sandbox check for run ids reaching deleteQaRun /
// artifact path operations. Mirrors dashboard.js#_qaIsSafeSegment: rejects
// empty / overlong / nulls / separators / .. / drive letters. Defense in
// depth — createRun's uid-suffix is already safe, but operator-driven
// DELETE endpoints take ids from URL paths, so every callsite re-validates.
function _isSafeRunId(id) {
  if (!id || typeof id !== 'string') return false;
  if (id.length > 128) return false;
  if (id.indexOf('\0') >= 0) return false;
  if (id.indexOf('/') >= 0 || id.indexOf('\\') >= 0) return false;
  if (id.indexOf('..') >= 0) return false;
  if (/^[a-zA-Z]:/.test(id)) return false;
  return true;
}

/**
 * Hard-delete a terminal-status run record and best-effort remove its
 * artifact directory. Result envelope (matches PR #3008 shape so the
 * dashboard DELETE /api/qa/runs/<id> handler stays generic):
 *
 *   { ok: true,  id, artifactsRemoved: bool }
 *   { ok: false, error: 'invalid_id' | 'not_found' | 'not_terminal',
 *     currentStatus?: string }
 *
 * Caller must cancel/complete the run via the lifecycle helpers before
 * deletion — running/pending runs are refused so an operator can't vacuum a
 * live QA agent out from under itself. The artifact rmSync runs OUTSIDE
 * the lock, with a path.resolve + startsWith() belt-and-braces sandbox.
 *
 * @param {string} id - run id (must pass _isSafeRunId)
 * @returns {object} envelope (see above)
 */
function deleteQaRun(id) {
  if (!_isSafeRunId(id)) return { ok: false, error: 'invalid_id' };
  let captured = null;
  let notFound = false;
  let notTerminal = null;
  mutateQaRuns((runs) => {
    if (!Array.isArray(runs)) return runs;
    const idx = runs.findIndex(r => r && r.id === id);
    if (idx < 0) { notFound = true; return runs; }
    const run = runs[idx];
    if (!TERMINAL_STATUSES.has(run.status)) {
      notTerminal = run.status;
      return runs;
    }
    captured = run;
    runs.splice(idx, 1);
    return runs;
  });
  if (notFound) return { ok: false, error: 'not_found' };
  if (notTerminal !== null) return { ok: false, error: 'not_terminal', currentStatus: notTerminal };
  // Sandbox check + best-effort rm outside the lock.
  let artifactsRemoved = false;
  try {
    const dir = qaArtifactsDirForRun(id);
    const base = path.resolve(qaArtifactsDir());
    const resolved = path.resolve(dir);
    if (resolved.startsWith(base + path.sep) || resolved === base) {
      if (fs.existsSync(resolved)) {
        fs.rmSync(resolved, { recursive: true, force: true });
        artifactsRemoved = true;
      }
    }
  } catch (e) {
    log('warn', `qa-runs: rm artifacts dir failed for ${id}: ${e.message}`);
  }
  return { ok: true, id, artifactsRemoved, run: captured };
}

/**
 * Cheap summary helper for the dashboard /api/status fast-state slice. Returns
 * `{ total, sig }` without sorting the run list — the sidebar activity-dot
 * counter only needs to detect (a) when total advances (new run created) and
 * (b) when any status flips. `sig` joins id:status across all current runs;
 * any change to either advances the string, which is enough signal for the
 * counter. We deliberately skip the sort that listRuns() does because this
 * runs on every fast-state rebuild (~every 10 s + every mtime-tracked write).
 *
 * @returns {{ total: number, sig: string }}
 */
function summarizeRunsForStatus() {
  const runs = _readRuns();
  if (!Array.isArray(runs) || runs.length === 0) return { total: 0, sig: '' };
  let sig = '';
  for (const r of runs) {
    if (!r) continue;
    sig += (r.id || '') + ':' + (r.status || '') + ',';
  }
  return { total: runs.length, sig };
}

module.exports = {
  QA_RUN_STATUS,
  TERMINAL_STATUSES,
  ALLOWED_TRANSITIONS,
  qaRunsPath,
  qaArtifactsDir,
  qaArtifactsDirForRun,
  createRun,
  markRunning,
  completeRun,
  setRunWorkItemId,
  deleteQaRun,
  _isSafeRunId,
  getRun,
  listRuns,
  getRunsForWorkItem,
  summarizeRunsForStatus,
  // Exposed for tests:
  validateTransition,
  isValidStatus,
  QA_RUNS_MAX_RECORDS,
};
