/**
 * cli:run-smoke — execute.ts
 *
 * Runtime smoke test : boot dotnet + npm dev, probe every page + every
 * API endpoint declared by the PRD slice, fail on any 4xx/5xx.
 *
 * Exported helpers (for unit tests, no process spawn) :
 *   - deriveProbesFromSlice(slice) — extract page-route probes from
 *     pageSpec.filePath entries.
 *   - extractApiUrlsFromSource(source, file) — extract `'/api/...'`
 *     literals from a generated service / hook file.
 *
 * The main `runSmoke(args)` orchestrates the full flow ; in dryRun
 * mode it skips the boot + probe phases and returns the derived probe
 * list verbatim so callers can lock the URL contract in tests.
 */
import { readFileSync, existsSync } from 'node:fs';
import path from 'node:path';
import { spawn, type ChildProcess } from 'node:child_process';
import { findFiles } from '../../../../lib/fs.js';
import type {
  RunSmokeArgs,
  SmokeProbe,
  SmokeProbeResult,
  SmokeReport,
} from './types.js';

interface PrdSlicePageSpec {
  filePath?: string;
  view?: string;
  entity?: string;
  screenCode?: string;
}

interface PrdSliceShape {
  pageSpecs?: PrdSlicePageSpec[];
}

/**
 * Derive the list of expected page-route probes from `pageSpec.filePath`.
 *
 * Convention : the page file path encodes the route literally. A file at
 * `src/pages/{appCode}/{module}/{section}/{Entity}{View}Page.tsx`
 * exposes a route under `/{appCode}/{module}/{section}/...`. The view
 * suffix tells us whether to probe the index (`list`, `dashboard`) or
 * a parametric path (`detail`, `form`).
 *
 * For list / dashboard / home views : `/{appCode}/{module}/{section}`.
 * For detail / form : `/{appCode}/{module}/{section}/<id-or-new>`. We
 * synthesise a sentinel id `00000000-0000-0000-0000-000000000000` so
 * the probe lands on the actual route (the page is rendered ; the
 * data fetch then returns a 404, which the page handles via the
 * emptyState — that's an OK probe outcome at the routing level).
 */
export function deriveProbesFromSlice(slice: PrdSliceShape): SmokeProbe[] {
  const probes: SmokeProbe[] = [];
  for (const ps of slice.pageSpecs ?? []) {
    if (!ps.filePath) continue;
    const segments = parsePageFilePath(ps.filePath);
    if (!segments) continue;
    const { appCode, module, section, view } = segments;
    const base = `/${appCode}/${module}/${section}`;
    const url =
      view === 'detail' || view === 'form'
        ? `${base}/00000000-0000-0000-0000-000000000000`
        : base;
    probes.push({
      source: 'pageSpec',
      url,
      verb: 'GET',
      expectedBy: ps.filePath,
    });
  }
  return probes;
}

interface PagePathSegments {
  appCode: string;
  module: string;
  section: string;
  view: string;
}

/** Parse `src/pages/{appCode}/{module}/{section}/{Entity}{View}Page.tsx`. */
function parsePageFilePath(filePath: string): PagePathSegments | null {
  const norm = filePath.replace(/\\/g, '/');
  const match = /^src\/pages\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)Page\.tsx$/.exec(norm);
  if (!match) return null;
  const [, appCode, module, section, entityPlusView] = match;
  // Heuristic : the View suffix is the trailing PascalCase token of the
  // file's base name. We map known suffixes to their lowercase view code.
  const suffixes = ['List', 'Detail', 'Form', 'Dashboard', 'Card', 'Kanban', 'AppHome', 'ModuleHome', 'SectionHome'];
  let view = 'list';
  for (const s of suffixes) {
    if (entityPlusView.endsWith(s)) {
      view = s.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
      break;
    }
  }
  return { appCode: appCode.toLowerCase(), module, section, view };
}

/**
 * Extract `/api/...` URL literals from a generated source file.
 *
 * Matches :
 *   api.get(`${API_PATH}/dashboard/consolidated`, …)
 *   api.post('/api/budgets/budgets', …)
 *   axios.put<…>(`${BASE}/${id}`, …)
 *
 * Returns each unique URL with its inferred HTTP verb. Template literals
 * with `${...}` interpolations are normalised : the variable substitution
 * is replaced by a sentinel (`/_/`) so the probe URL hits a real route
 * (relying on the server's route table to match parametric segments).
 */
export function extractApiUrlsFromSource(source: string, fromFile: string): SmokeProbe[] {
  const probes: SmokeProbe[] = [];
  const callRe = /\bapi\.|smartstackClient|axios|apiClient/;
  if (!callRe.test(source)) return probes;
  // Statically resolve `const NAME = '<literal>'` declarations so template
  // literals referencing them (\${API_PATH}/...) can be expanded into the
  // concrete URL the runtime will hit.
  const stringConsts = new Map<string, string>();
  const constRe = /\bconst\s+(\w+)\s*=\s*['"]([^'"]+)['"]\s*;?/g;
  let cm: RegExpExecArray | null;
  while ((cm = constRe.exec(source)) !== null) {
    stringConsts.set(cm[1], cm[2]);
  }
  // Common shape : `<obj>.<verb>(<url>, ...)`. Match the URL literal
  // (either single/double-quoted or back-ticked) right after the verb.
  // The optional `<...>` generic args are matched as a whole optional
  // group (the original `<[^>]*>?` only made the closing `>` optional and
  // broke on calls without generics).
  const verbRe = /\.(get|post|put|delete|patch)\s*(?:<[^>]*>)?\s*\(\s*([`'"][^`'"]+[`'"])/gi;
  let m: RegExpExecArray | null;
  while ((m = verbRe.exec(source)) !== null) {
    const verbStr = m[1].toUpperCase() as SmokeProbe['verb'];
    const raw = m[2].slice(1, -1);
    const url = expandTemplateLiteral(raw, stringConsts);
    if (!url.startsWith('/')) continue;
    probes.push({
      source: 'apiClient',
      url,
      verb: verbStr,
      expectedBy: fromFile,
    });
  }
  return probes;
}

/**
 * Expand `${VAR}` placeholders inside a template literal :
 *  - if `VAR` is a known string const in scope, substitute its value ;
 *  - otherwise substitute the sentinel `_` (so the resulting URL still
 *    has a sane shape for the route-table probe).
 *
 * When the leading placeholder doesn't resolve (no matching const) AND
 * the URL would start with a bare sentinel, we prepend `/api/` so the
 * probe lands on the backend host (most apiClient URLs start with
 * `${API_PATH}` which is itself `/api/...`).
 */
function expandTemplateLiteral(raw: string, scope: Map<string, string>): string {
  const expanded = raw.replace(/\$\{([^}]+)\}/g, (_, expr) => {
    const trimmed = expr.trim();
    return scope.get(trimmed) ?? '_';
  });
  return expanded.replace(/^_(?=\/)/, '/api/_');
}

/** Find every generated service / hook file and extract API URLs from it. */
export async function discoverApiProbes(projectPath: string): Promise<SmokeProbe[]> {
  const webRoot = await findWebRoot(projectPath);
  if (!webRoot) return [];
  const srcFiles = await findFiles('src/services/api/**/*.ts', { cwd: webRoot });
  const hookFiles = await findFiles('src/features/**/hooks/**/*.ts', { cwd: webRoot });
  const candidates = [...srcFiles, ...hookFiles];
  const probes: SmokeProbe[] = [];
  for (const f of candidates) {
    if (!existsSync(f)) continue;
    const src = readFileSync(f, 'utf8');
    probes.push(...extractApiUrlsFromSource(src, path.relative(projectPath, f)));
  }
  return dedupeProbes(probes);
}

function dedupeProbes(probes: SmokeProbe[]): SmokeProbe[] {
  const seen = new Set<string>();
  const out: SmokeProbe[] = [];
  for (const p of probes) {
    const k = `${p.verb} ${p.url}`;
    if (seen.has(k)) continue;
    seen.add(k);
    out.push(p);
  }
  return out;
}

async function findWebRoot(projectPath: string): Promise<string | null> {
  // Convention : `<projectPath>/web/<app>-web` (cf. SmartStack scaffolders).
  const webDir = path.join(projectPath, 'web');
  if (!existsSync(webDir)) return null;
  // Pick the first sub-dir matching *-web.
  const candidates = await findFiles('*-web/package.json', { cwd: webDir });
  if (candidates.length === 0) return null;
  return path.dirname(candidates[0]);
}

/**
 * Probe a single URL on the host (anonymous HTTP). Returns a structured
 * result with status code + duration. Wraps fetch with a short timeout
 * so a hung backend doesn't deadlock the smoke run.
 */
async function probe(url: string, verb: SmokeProbe['verb'], hostBase: string, timeoutMs: number): Promise<{ status: number | null; durationMs: number; reason?: string }> {
  const start = Date.now();
  const controller = new AbortController();
  const t = setTimeout(() => controller.abort(), timeoutMs);
  try {
    const res = await fetch(`${hostBase}${url}`, { method: verb, signal: controller.signal });
    return { status: res.status, durationMs: Date.now() - start };
  } catch (err) {
    const message = err instanceof Error ? err.message : String(err);
    return {
      status: null,
      durationMs: Date.now() - start,
      reason: message.includes('aborted') ? 'timeout' : 'network-error',
    };
  } finally {
    clearTimeout(t);
  }
}

/**
 * Run the full smoke test : derive probe list, optionally boot + probe,
 * return the structured report. Boot orchestration is intentionally
 * isolated behind `dryRun: false` so unit tests can lock the URL
 * extraction without spawning processes.
 */
export async function runSmoke(args: RunSmokeArgs): Promise<SmokeReport> {
  // 1. Load PRD slice.
  if (!existsSync(args.prdSlice)) {
    throw new Error(`prdSlice not found at ${args.prdSlice}`);
  }
  const slice = JSON.parse(readFileSync(args.prdSlice, 'utf8')) as PrdSliceShape;

  // 2. Derive expected probes.
  const pageProbes = deriveProbesFromSlice(slice);
  const apiProbes = await discoverApiProbes(args.projectPath);
  const allProbes = dedupeProbes([...pageProbes, ...apiProbes]);

  if (args.dryRun) {
    const probes: SmokeProbeResult[] = allProbes.map((p) => ({
      ...p,
      status: null,
      durationMs: 0,
      failed: false,
      failureReason: 'dry-run',
    }));
    return {
      passed: true,
      backendBootMs: null,
      frontendBootMs: null,
      totalProbes: probes.length,
      passedProbes: 0,
      failedProbes: 0,
      skippedProbes: probes.length,
      probes,
      failures: [],
    };
  }

  // 3. Boot orchestration (Wave 2.3, 2026-05-27).
  //
  //    When selfBoot=true (default): spawn `dotnet run --project src/<Ns>.Api`
  //    + `npm run dev` in background, poll their health endpoints, run the
  //    probes, then tear both processes down on the way out (try/finally
  //    block ensures cleanup even if probes throw).
  //
  //    When selfBoot=false (legacy): the caller manages the processes
  //    externally — we skip the boot phase and probe directly.
  const frontendHost = `http://localhost:${args.frontendPort}`;
  const backendHost = `http://localhost:${args.backendPort}`;

  let backendBootMs: number | null = null;
  let frontendBootMs: number | null = null;
  let backendProc: ChildProcess | null = null;
  let frontendProc: ChildProcess | null = null;

  try {
    if (args.selfBoot) {
      // Best-effort discovery of the .NET project entrypoint.
      const apiCsproj = await firstFile('src/*.Api/*.Api.csproj', args.projectPath)
        ?? await firstFile('src/**/*.Api.csproj', args.projectPath);
      const webRoot = await firstFile('web/*-web/package.json', args.projectPath);

      if (apiCsproj) {
        const t0 = Date.now();
        backendProc = spawn('dotnet', ['run', '--project', apiCsproj], {
          cwd: args.projectPath,
          stdio: 'ignore',
          env: { ...process.env, ASPNETCORE_URLS: backendHost },
          shell: process.platform === 'win32',
          detached: false,
        });
        const ok = await waitForHttp(`${backendHost}/health`, args.timeoutMs);
        backendBootMs = ok ? Date.now() - t0 : null;
        if (!ok) {
          return failReport('backend boot timeout', allProbes, backendBootMs, frontendBootMs);
        }
      }

      if (webRoot) {
        const t0 = Date.now();
        frontendProc = spawn('npm', ['run', 'dev', '--', '--port', String(args.frontendPort)], {
          cwd: path.dirname(webRoot),
          stdio: 'ignore',
          shell: process.platform === 'win32',
          detached: false,
        });
        const ok = await waitForHttp(frontendHost, args.timeoutMs);
        frontendBootMs = ok ? Date.now() - t0 : null;
        if (!ok) {
          return failReport('frontend boot timeout', allProbes, backendBootMs, frontendBootMs);
        }
      }
    }

    const results: SmokeProbeResult[] = [];
    for (const p of allProbes) {
      const isApi = p.url.startsWith('/api/');
      const host = isApi ? backendHost : frontendHost;
      const { status, durationMs, reason } = await probe(p.url, p.verb, host, args.timeoutMs);
      const failed = status === null
        ? true
        : status >= 400 && status < 600;
      const failureReason = !failed
        ? undefined
        : reason ?? (status && status >= 500 ? 'status-5xx' : 'status-4xx');
      results.push({ ...p, status, durationMs, failed, failureReason });
    }

    const failed = results.filter((r) => r.failed);
    return {
      passed: failed.length === 0,
      backendBootMs,
      frontendBootMs,
      totalProbes: results.length,
      passedProbes: results.length - failed.length,
      failedProbes: failed.length,
      skippedProbes: 0,
      probes: results,
      failures: failed,
    };
  } finally {
    // Best-effort teardown. SIGTERM → wait up to 5 s → SIGKILL.
    await teardown(backendProc);
    await teardown(frontendProc);
  }
}

async function firstFile(glob: string, cwd: string): Promise<string | null> {
  const matches = await findFiles(glob, { cwd });
  return matches[0] ?? null;
}

async function waitForHttp(url: string, timeoutMs: number): Promise<boolean> {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    try {
      const ctrl = new AbortController();
      const t = setTimeout(() => ctrl.abort(), 2000);
      const res = await fetch(url, { signal: ctrl.signal });
      clearTimeout(t);
      if (res.status < 500) return true;
    } catch {
      // connection refused / ECONNRESET / abort — keep polling.
    }
    await sleep(500);
  }
  return false;
}

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function teardown(proc: ChildProcess | null): Promise<void> {
  if (!proc || proc.killed) return;
  try {
    proc.kill('SIGTERM');
    const exited = await Promise.race([
      new Promise<boolean>((resolve) => proc.once('exit', () => resolve(true))),
      sleep(5000).then(() => false),
    ]);
    if (!exited) proc.kill('SIGKILL');
  } catch {
    // best-effort
  }
}

function failReport(
  reason: string,
  allProbes: SmokeProbe[],
  backendBootMs: number | null,
  frontendBootMs: number | null,
): SmokeReport {
  const skipped: SmokeProbeResult[] = allProbes.map((p) => ({
    ...p,
    status: null,
    durationMs: 0,
    failed: true,
    failureReason: reason,
  }));
  return {
    passed: false,
    backendBootMs,
    frontendBootMs,
    totalProbes: skipped.length,
    passedProbes: 0,
    failedProbes: skipped.length,
    skippedProbes: 0,
    probes: skipped,
    failures: skipped,
  };
}
