// Responsibility: bounded, read-only static audit for CaseRun TypeScript source modules.

import { readFile, readdir } from "node:fs/promises";
import path from "node:path";

import ts from "typescript";

const DEFAULT_SOURCE_DIR = "tools/src";
const DEFAULT_MAX_LINES = 2000;
const MAX_TEXT_FINDINGS = 20;
const MAX_JSON_FINDINGS = 100;

type AuditCategory = "type_check" | "dependency" | "duplicate_helper" | "compressed_declaration" | "line_count";

export type CaseRunAuditFinding = {
  code: string;
  category: AuditCategory;
  file: string;
  line: number;
  message: string;
  details?: Record<string, unknown>;
};

export type CaseRunAuditReport = {
  ok: boolean;
  action: "case.audit";
  status: "passed" | "failed";
  scope: {
    root: string;
    sourceDir: string;
    fileCount: number;
    maxLines: number;
  };
  checks: Record<AuditCategory, { status: "passed" | "failed"; findingCount: number }>;
  findings: CaseRunAuditFinding[];
  findingCount: number;
  findingsTruncated: boolean;
};

type SourceModule = {
  absolutePath: string;
  relativePath: string;
  sourceFile: ts.SourceFile;
  text: string;
  lineCount: number;
};

type AuditOptions = {
  root?: string;
  sourceDir?: string;
  maxLines?: number;
  maxFindings?: number;
};

export async function mainCaseRunAuditCli(argv = process.argv.slice(2)) {
  const json = argv.includes("--json");
  try {
    const parsed = parseAuditArgs(argv);
    if (parsed.help) {
      process.stdout.write(`${renderCaseRunAuditHelp()}\n`);
      process.exitCode = 0;
      return;
    }
    const report = await auditCaseRunSources({
      root: parsed.root,
      sourceDir: parsed.sourceDir,
      maxLines: parsed.maxLines,
      maxFindings: json ? MAX_JSON_FINDINGS : MAX_TEXT_FINDINGS,
    });
    process.stdout.write(json ? `${JSON.stringify(report, null, 2)}\n` : `${renderCaseRunAuditText(report)}\n`);
    process.exitCode = report.ok ? 0 : 2;
  } catch (error) {
    const failure = auditFailure(error);
    process.stdout.write(json ? `${JSON.stringify(failure, null, 2)}\n` : `${renderAuditFailureText(failure)}\n`);
    process.exitCode = 1;
  }
}

export async function auditCaseRunSources(options: AuditOptions = {}): Promise<CaseRunAuditReport> {
  const root = path.resolve(options.root ?? process.cwd());
  const sourceDir = normalizeSourceDir(options.sourceDir ?? DEFAULT_SOURCE_DIR);
  const maxLines = positiveInteger(options.maxLines ?? DEFAULT_MAX_LINES, "maxLines");
  const maxFindings = positiveInteger(options.maxFindings ?? MAX_JSON_FINDINGS, "maxFindings");
  const modules = await loadSourceModules(root, sourceDir);
  if (modules.length === 0) throw cliError("caserun_audit_scope_empty", "no CaseRun TypeScript source modules were found", { root, sourceDir });

  const findings = [
    ...typeCheckFindings(modules),
    ...dependencyFindings(modules),
    ...duplicateHelperFindings(modules),
    ...compressedDeclarationFindings(modules),
    ...lineCountFindings(modules, maxLines),
  ].sort(compareFindings);
  const boundedFindings = findings.slice(0, maxFindings);
  const checks = checkSummary(findings);
  return {
    ok: findings.length === 0,
    action: "case.audit",
    status: findings.length === 0 ? "passed" : "failed",
    scope: { root, sourceDir, fileCount: modules.length, maxLines },
    checks,
    findings: boundedFindings,
    findingCount: findings.length,
    findingsTruncated: boundedFindings.length !== findings.length,
  };
}

export function renderCaseRunAuditText(report: CaseRunAuditReport) {
  const checkOrder: AuditCategory[] = ["type_check", "dependency", "duplicate_helper", "compressed_declaration", "line_count"];
  const lines = [
    `CaseRun static audit: ${report.status}`,
    `scope: ${report.scope.sourceDir} files=${report.scope.fileCount} maxLines=${report.scope.maxLines}`,
    ...checkOrder.map((category) => `${category}: ${report.checks[category].status} findings=${report.checks[category].findingCount}`),
  ];
  for (const finding of report.findings) lines.push(`${finding.code} ${finding.file}:${finding.line} ${finding.message}`);
  if (report.findingsTruncated) lines.push(`findings: showing=${report.findings.length} total=${report.findingCount}`);
  return lines.join("\n");
}

function renderCaseRunAuditHelp() {
  return [
    "CaseRun static audit",
    "usage: hwlab-cli case audit [--json] [--root PATH] [--source-dir PATH] [--max-lines N]",
    "exit codes: 0 passed, 1 command failure, 2 audit findings",
  ].join("\n");
}

async function loadSourceModules(root: string, sourceDir: string) {
  const absoluteSourceDir = path.resolve(root, sourceDir);
  const entries = await readdir(absoluteSourceDir, { withFileTypes: true });
  const fileNames = entries
    .filter((entry) => entry.isFile() && /^hwlab-caserun-.*\.ts$/u.test(entry.name))
    .map((entry) => entry.name)
    .sort();
  return Promise.all(fileNames.map(async (fileName): Promise<SourceModule> => {
    const absolutePath = path.join(absoluteSourceDir, fileName);
    const text = await readFile(absolutePath, "utf8");
    return {
      absolutePath,
      relativePath: repoRelative(root, absolutePath),
      sourceFile: ts.createSourceFile(absolutePath, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS),
      text,
      lineCount: text.length === 0 ? 0 : text.split(/\r?\n/u).length,
    };
  }));
}

function typeCheckFindings(modules: SourceModule[]) {
  const moduleByPath = new Map(modules.map((module) => [path.resolve(module.absolutePath), module]));
  const program = ts.createProgram([...moduleByPath.keys()], {
    allowImportingTsExtensions: true,
    allowJs: false,
    module: ts.ModuleKind.ESNext,
    moduleResolution: ts.ModuleResolutionKind.Bundler,
    noEmit: true,
    skipLibCheck: true,
    strict: false,
    target: ts.ScriptTarget.ES2022,
  });
  return ts.getPreEmitDiagnostics(program)
    .filter((diagnostic) => diagnostic.file && moduleByPath.has(path.resolve(diagnostic.file.fileName)))
    .filter(isActionableDiagnostic)
    .map((diagnostic): CaseRunAuditFinding => {
      const module = moduleByPath.get(path.resolve(diagnostic.file!.fileName))!;
      const line = diagnostic.start === undefined ? 1 : diagnostic.file!.getLineAndCharacterOfPosition(diagnostic.start).line + 1;
      return {
        code: `caserun_type_ts${diagnostic.code}`,
        category: "type_check",
        file: module.relativePath,
        line,
        message: ts.flattenDiagnosticMessageText(diagnostic.messageText, " "),
        details: { diagnosticCode: diagnostic.code },
      };
    });
}

function isActionableDiagnostic(diagnostic: ts.Diagnostic) {
  if (diagnostic.category !== ts.DiagnosticCategory.Error) return false;
  const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, " ");
  if (diagnostic.code === 2307 && /Cannot find module '(node:|typescript|kafkajs|yaml|pg|playwright)/u.test(message)) return false;
  if ([2580, 2591].includes(diagnostic.code) && /Cannot find name '(Buffer|process)'/u.test(message)) return false;
  if (diagnostic.code === 2552 && /Cannot find name 'Buffer'/u.test(message)) return false;
  if ([2867, 2868].includes(diagnostic.code) && /Cannot find name 'Bun'/u.test(message)) return false;
  if (diagnostic.code === 2503 && /Cannot find namespace 'NodeJS'/u.test(message)) return false;
  return true;
}

function dependencyFindings(modules: SourceModule[]) {
  const byAbsolutePath = new Map(modules.map((module) => [path.resolve(module.absolutePath), module]));
  const edges = new Map(modules.map((module) => [module.absolutePath, runtimeDependencies(module, byAbsolutePath)]));
  const findings: CaseRunAuditFinding[] = [];
  for (const [from, targets] of edges) {
    for (const target of targets) {
      if (path.basename(target) !== "hwlab-caserun-runtime.ts" || path.basename(from) === "hwlab-caserun-lib.ts") continue;
      const module = byAbsolutePath.get(path.resolve(from))!;
      findings.push({
        code: "caserun_runtime_reverse_dependency",
        category: "dependency",
        file: module.relativePath,
        line: importLineFor(module, target),
        message: "CaseRun source module must not depend on the runtime orchestrator",
        details: { target: byAbsolutePath.get(path.resolve(target))?.relativePath ?? target },
      });
    }
  }
  for (const cycle of dependencyCycles(edges)) {
    const first = byAbsolutePath.get(path.resolve(cycle[0]))!;
    findings.push({
      code: "caserun_dependency_cycle",
      category: "dependency",
      file: first.relativePath,
      line: 1,
      message: `runtime dependency cycle: ${cycle.map((item) => path.basename(item)).join(" -> ")}`,
      details: { cycle: cycle.map((item) => byAbsolutePath.get(path.resolve(item))?.relativePath ?? item) },
    });
  }
  return findings;
}

function runtimeDependencies(module: SourceModule, modules: Map<string, SourceModule>) {
  const dependencies = new Set<string>();
  for (const statement of module.sourceFile.statements) {
    if (!ts.isImportDeclaration(statement) && !ts.isExportDeclaration(statement)) continue;
    if (!statement.moduleSpecifier || !ts.isStringLiteral(statement.moduleSpecifier)) continue;
    if (isTypeOnlyDependency(statement)) continue;
    const specifier = statement.moduleSpecifier.text;
    if (!specifier.startsWith(".")) continue;
    const resolved = resolveModulePath(module.absolutePath, specifier);
    if (resolved && modules.has(path.resolve(resolved))) dependencies.add(path.resolve(resolved));
  }
  return [...dependencies].sort();
}

function isTypeOnlyDependency(statement: ts.ImportDeclaration | ts.ExportDeclaration) {
  if (ts.isExportDeclaration(statement)) return statement.isTypeOnly;
  const clause = statement.importClause;
  if (!clause) return false;
  if (clause.isTypeOnly) return true;
  if (clause.name) return false;
  const bindings = clause.namedBindings;
  return Boolean(bindings && ts.isNamedImports(bindings) && bindings.elements.length > 0 && bindings.elements.every((element) => element.isTypeOnly));
}

function resolveModulePath(sourcePath: string, specifier: string) {
  const candidate = path.resolve(path.dirname(sourcePath), specifier);
  if (path.extname(candidate)) return candidate;
  return `${candidate}.ts`;
}

function importLineFor(module: SourceModule, target: string) {
  for (const statement of module.sourceFile.statements) {
    if ((!ts.isImportDeclaration(statement) && !ts.isExportDeclaration(statement)) || !statement.moduleSpecifier || !ts.isStringLiteral(statement.moduleSpecifier)) continue;
    if (path.resolve(resolveModulePath(module.absolutePath, statement.moduleSpecifier.text) ?? "") === path.resolve(target)) return lineOf(module.sourceFile, statement);
  }
  return 1;
}

function dependencyCycles(edges: Map<string, string[]>) {
  const cycles = new Map<string, string[]>();
  const visiting = new Set<string>();
  const visited = new Set<string>();
  const stack: string[] = [];
  const visit = (node: string) => {
    if (visiting.has(node)) {
      const start = stack.indexOf(node);
      const cycle = [...stack.slice(start), node];
      const key = canonicalCycle(cycle);
      cycles.set(key, cycle);
      return;
    }
    if (visited.has(node)) return;
    visiting.add(node);
    stack.push(node);
    for (const target of edges.get(node) ?? []) visit(target);
    stack.pop();
    visiting.delete(node);
    visited.add(node);
  };
  for (const node of edges.keys()) visit(node);
  return [...cycles.values()];
}

function canonicalCycle(cycle: string[]) {
  const nodes = cycle.slice(0, -1).map((item) => path.basename(item));
  const rotations = nodes.map((_, index) => [...nodes.slice(index), ...nodes.slice(0, index)].join("|"));
  return rotations.sort()[0] ?? "";
}

function duplicateHelperFindings(modules: SourceModule[]) {
  const helpers = new Map<string, { module: SourceModule; node: ts.FunctionDeclaration; fingerprint: string }[]>();
  for (const module of modules) {
    for (const statement of module.sourceFile.statements) {
      if (!ts.isFunctionDeclaration(statement) || !statement.name || !statement.body || !isPureHelper(statement)) continue;
      const name = statement.name.text;
      const entries = helpers.get(name) ?? [];
      entries.push({ module, node: statement, fingerprint: helperFingerprint(statement, module.sourceFile) });
      helpers.set(name, entries);
    }
  }
  const findings: CaseRunAuditFinding[] = [];
  for (const [name, entries] of helpers) {
    const byFingerprint = new Map<string, typeof entries>();
    for (const entry of entries) {
      const matches = byFingerprint.get(entry.fingerprint) ?? [];
      matches.push(entry);
      byFingerprint.set(entry.fingerprint, matches);
    }
    for (const matches of byFingerprint.values()) {
      if (matches.length < 2) continue;
      for (const entry of matches) findings.push({
        code: "caserun_duplicate_pure_helper",
        category: "duplicate_helper",
        file: entry.module.relativePath,
        line: lineOf(entry.module.sourceFile, entry.node),
        message: `duplicate top-level pure helper: ${name}`,
        details: { declarations: matches.map((match) => `${match.module.relativePath}:${lineOf(match.module.sourceFile, match.node)}`) },
      });
    }
  }
  return findings;
}

function isPureHelper(node: ts.FunctionDeclaration) {
  if (node.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword) || node.asteriskToken) return false;
  let impure = false;
  const visit = (child: ts.Node) => {
    if (ts.isAwaitExpression(child) || ts.isYieldExpression(child) || ts.isThrowStatement(child) || ts.isNewExpression(child) || ts.isDeleteExpression(child)) impure = true;
    if (ts.isBinaryExpression(child) && isAssignmentOperatorKind(child.operatorToken.kind)) impure = true;
    if (ts.isPrefixUnaryExpression(child) || ts.isPostfixUnaryExpression(child)) {
      if (child.operator === ts.SyntaxKind.PlusPlusToken || child.operator === ts.SyntaxKind.MinusMinusToken) impure = true;
    }
    if (!impure) ts.forEachChild(child, visit);
  };
  visit(node.body!);
  return !impure;
}

function isAssignmentOperatorKind(kind: ts.SyntaxKind) {
  return kind >= ts.SyntaxKind.FirstAssignment && kind <= ts.SyntaxKind.LastAssignment;
}

function helperFingerprint(node: ts.FunctionDeclaration, sourceFile: ts.SourceFile) {
  return `${node.typeParameters?.length ?? 0}:${node.parameters.length}:${node.body!.getText(sourceFile).replace(/\s+/gu, " ").trim()}`;
}

function compressedDeclarationFindings(modules: SourceModule[]) {
  const findings: CaseRunAuditFinding[] = [];
  for (const module of modules) {
    const statementsByLine = new Map<number, ts.Statement[]>();
    for (const statement of module.sourceFile.statements) {
      const line = lineOf(module.sourceFile, statement);
      const statements = statementsByLine.get(line) ?? [];
      statements.push(statement);
      statementsByLine.set(line, statements);
    }
    for (const [line, statements] of statementsByLine) {
      if (statements.length < 2 || !statements.some(isDeclarationStatement)) continue;
      findings.push({
        code: "caserun_compressed_top_level_declaration",
        category: "compressed_declaration",
        file: module.relativePath,
        line,
        message: "multiple top-level declarations share one physical line",
        details: { statementCount: statements.length },
      });
    }
  }
  return findings;
}

function isDeclarationStatement(statement: ts.Statement) {
  return ts.isVariableStatement(statement) || ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement) || ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement) || ts.isEnumDeclaration(statement);
}

function lineCountFindings(modules: SourceModule[], maxLines: number) {
  return modules.flatMap((module): CaseRunAuditFinding[] => module.lineCount < maxLines ? [] : [{
    code: "caserun_source_line_limit_exceeded",
    category: "line_count",
    file: module.relativePath,
    line: maxLines,
    message: `CaseRun source file must stay below ${maxLines} lines; found ${module.lineCount}`,
    details: { lineCount: module.lineCount, maxLines, comparison: "<" },
  }]);
}

function checkSummary(findings: CaseRunAuditFinding[]) {
  const categories: AuditCategory[] = ["type_check", "dependency", "duplicate_helper", "compressed_declaration", "line_count"];
  return Object.fromEntries(categories.map((category) => {
    const findingCount = findings.filter((finding) => finding.category === category).length;
    return [category, { status: findingCount === 0 ? "passed" : "failed", findingCount }];
  })) as CaseRunAuditReport["checks"];
}

function parseAuditArgs(argv: string[]) {
  const result: { help: boolean; root?: string; sourceDir?: string; maxLines?: number } = { help: false };
  for (let index = 0; index < argv.length; index += 1) {
    const argument = argv[index];
    if (argument === "--json") continue;
    if (["--help", "-h", "help"].includes(argument)) {
      result.help = true;
      continue;
    }
    const [name, inlineValue] = argument.split("=", 2);
    if (["--root", "--source-dir", "--max-lines"].includes(name)) {
      const value = inlineValue ?? argv[index + 1];
      if (!inlineValue) index += 1;
      if (!value) throw cliError("missing_required_value", `${name} requires a value`, { option: name });
      if (name === "--root") result.root = value;
      if (name === "--source-dir") result.sourceDir = value;
      if (name === "--max-lines") result.maxLines = positiveInteger(Number(value), "maxLines");
      continue;
    }
    throw cliError("unsupported_case_audit_option", `unsupported case audit option: ${argument}`, { option: argument });
  }
  return result;
}

function auditFailure(error: unknown) {
  const value = error as { code?: string; message?: string; details?: Record<string, unknown> };
  return {
    ok: false,
    action: "case.audit",
    status: "error",
    error: {
      code: value?.code ?? "caserun_audit_failed",
      message: value?.message ?? String(error),
      details: value?.details ?? {},
    },
  };
}

function renderAuditFailureText(failure: ReturnType<typeof auditFailure>) {
  return `CaseRun static audit: error\n${failure.error.code}: ${failure.error.message}`;
}

function normalizeSourceDir(value: string) {
  const normalized = value.replace(/\\/gu, "/").replace(/^\.\//u, "").replace(/\/$/u, "");
  if (!normalized || normalized === ".." || normalized.startsWith("../") || path.isAbsolute(value)) throw cliError("invalid_case_audit_source_dir", "sourceDir must be a relative path inside the audit root", { sourceDir: value });
  return normalized;
}

function positiveInteger(value: number, field: string) {
  if (!Number.isInteger(value) || value <= 0) throw cliError("invalid_positive_integer", `${field} must be a positive integer`, { field, value });
  return value;
}

function repoRelative(root: string, absolutePath: string) {
  return path.relative(root, absolutePath).replace(/\\/gu, "/");
}

function lineOf(sourceFile: ts.SourceFile, node: ts.Node) {
  return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
}

function compareFindings(left: CaseRunAuditFinding, right: CaseRunAuditFinding) {
  return left.category.localeCompare(right.category) || left.file.localeCompare(right.file) || left.line - right.line || left.code.localeCompare(right.code);
}

function cliError(code: string, message: string, details: Record<string, unknown> = {}) {
  return Object.assign(new Error(message), { code, details });
}
