import { spawn, type ChildProcess } from "node:child_process";
import { readFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { z } from "zod";
import { canonicalize } from "../../schema/src/canonical.js";
import type { Finding } from "../../schema/src/records/finding.js";
import { parseMachineResult } from "../../cli/src/machine-output.js";
import {
  corpusLabelSchema,
  type CorpusLabelManifest,
  type PlantedLabel,
} from "./labels.js";

const fixturePackageSchema = z.looseObject({
  scripts: z.record(z.string(), z.string()).optional(),
});

const READY_SIGNAL = "invariantum-corpus-ready";
const TARGET_READY_TIMEOUT_MS = 10_000;
const REPOSITORY_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
const CLI_BIN = join(REPOSITORY_ROOT, "packages/cli/bin/invariantum.mjs");

export type CorpusMatch = {
  detectorClass: string;
  label: PlantedLabel;
  finding: Finding;
};

export type CorpusScore = {
  truePositives: CorpusMatch[];
  falseNegatives: PlantedLabel[];
  blockingFalsePositives: Finding[];
  advisoryHits: CorpusMatch[];
  advisoryMisses: PlantedLabel[];
  advisoryFalsePositives: Finding[];
  unmatchedFindings: Finding[];
};

export type CorpusClassScore = {
  truePositives: number;
  falseNegatives: number;
  blockingFalsePositives: Finding[];
  advisoryHits: number;
  advisoryMisses: number;
  advisoryFalsePositives: Finding[];
  unmatchedFindings: Finding[];
};

type ProcessResult = {
  exitCode: number | null;
  signal: NodeJS.Signals | null;
  stdout: string;
  stderr: string;
};

function appendOutput(chunks: string[], chunk: Buffer | string): void {
  chunks.push(typeof chunk === "string" ? chunk : chunk.toString("utf8"));
}

function runProcess(command: string, args: string[], cwd: string): Promise<ProcessResult> {
  return new Promise((resolveProcess, reject) => {
    const child = spawn(command, args, {
      cwd,
      env: process.env,
      stdio: ["ignore", "pipe", "pipe"],
    });
    const stdout: string[] = [];
    const stderr: string[] = [];

    child.stdout.on("data", (chunk: Buffer | string) => {
      appendOutput(stdout, chunk);
    });
    child.stderr.on("data", (chunk: Buffer | string) => {
      appendOutput(stderr, chunk);
    });
    child.once("error", reject);
    child.once("close", (exitCode, signal) => {
      resolveProcess({
        exitCode,
        signal,
        stdout: stdout.join(""),
        stderr: stderr.join(""),
      });
    });
  });
}

function terminate(child: ChildProcess): Promise<void> {
  if (child.exitCode !== null || child.signalCode !== null) {
    return Promise.resolve();
  }

  return new Promise((resolveTerminate) => {
    const timeout = setTimeout(() => {
      if (child.exitCode === null && child.signalCode === null) {
        signalProcessTree(child, "SIGKILL");
      }
    }, 2_000);
    child.once("close", () => {
      clearTimeout(timeout);
      resolveTerminate();
    });
    signalProcessTree(child, "SIGTERM");
  });
}

function signalProcessTree(child: ChildProcess, signal: NodeJS.Signals): void {
  if (process.platform !== "win32" && child.pid !== undefined) {
    process.kill(-child.pid, signal);
    return;
  }
  child.kill(signal);
}

async function launchFixtureTarget(fixtureDir: string): Promise<ChildProcess | undefined> {
  const packagePath = join(fixtureDir, "package.json");
  let packageSource: string;
  try {
    packageSource = await readFile(packagePath, "utf8");
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code === "ENOENT") {
      return undefined;
    }
    throw error;
  }

  const packageJson = fixturePackageSchema.parse(JSON.parse(packageSource));
  const startScript = packageJson.scripts?.start;
  if (startScript === undefined) {
    return undefined;
  }

  const child = spawn(startScript, {
    cwd: fixtureDir,
    env: process.env,
    stdio: ["ignore", "pipe", "pipe"],
    shell: true,
    ...(process.platform === "win32" ? {} : { detached: true }),
  });
  const output: string[] = [];
  child.stdout.on("data", (chunk: Buffer | string) => {
    appendOutput(output, chunk);
  });
  child.stderr.on("data", (chunk: Buffer | string) => {
    appendOutput(output, chunk);
  });

  await new Promise<void>((resolveReady, reject) => {
    const timeout = setTimeout(() => {
      reject(new Error(`fixture target did not emit ${READY_SIGNAL}: ${output.join("")}`));
    }, TARGET_READY_TIMEOUT_MS);
    const settle = (callback: () => void): void => {
      clearTimeout(timeout);
      callback();
    };
    const checkReady = (): void => {
      if (output.join("").includes(READY_SIGNAL)) {
        settle(resolveReady);
      }
    };

    child.stdout.on("data", checkReady);
    child.stderr.on("data", checkReady);
    child.once("error", (error) => {
      settle(() => {
        reject(error);
      });
    });
    child.once("close", (code, signal) => {
      settle(() => {
        reject(new Error(`fixture target exited before readiness: code=${String(code)} signal=${String(signal)} output=${output.join("")}`));
      });
    });
  }).catch(async (error: unknown) => {
    await terminate(child);
    throw error;
  });

  return child;
}

export async function readManifest(fixtureDir: string): Promise<CorpusLabelManifest> {
  const labelsPath = join(fixtureDir, "labels.json");
  let source: string;
  try {
    source = await readFile(labelsPath, "utf8");
  } catch (error) {
    throw new Error(`cannot read corpus labels at ${labelsPath}`, { cause: error });
  }

  try {
    return corpusLabelSchema.parse(JSON.parse(source));
  } catch (error) {
    throw new Error(`invalid corpus labels at ${labelsPath}`, { cause: error });
  }
}

function matchesLabel(finding: Finding, label: PlantedLabel): boolean {
  return (
    finding.class === label.detectorClass
    && finding.lane === label.expectedLane
    && canonicalize({ target: finding.target, context: finding.context }) === canonicalize(label.scope)
  );
}

export function scoreFindings(manifest: CorpusLabelManifest, findings: Finding[]): CorpusScore {
  const truePositives: CorpusMatch[] = [];
  const falseNegatives: PlantedLabel[] = [];
  const advisoryHits: CorpusMatch[] = [];
  const advisoryMisses: PlantedLabel[] = [];

  for (const label of manifest.planted) {
    const finding = findings.find((candidate) => matchesLabel(candidate, label));
    if (finding === undefined) {
      if (label.expectedLane === "blocking") {
        falseNegatives.push(label);
      } else {
        advisoryMisses.push(label);
      }
      continue;
    }

    const match: CorpusMatch = {
      detectorClass: label.detectorClass,
      label,
      finding,
    };
    if (label.expectedLane === "blocking") {
      truePositives.push(match);
    } else {
      advisoryHits.push(match);
    }
  }

  const unmatchedFindings = findings.filter(
    (finding) => !manifest.planted.some((label) => matchesLabel(finding, label)),
  );
  const blockingFalsePositives = unmatchedFindings.filter(
    (finding) => finding.lane === "blocking",
  );
  const advisoryFalsePositives = unmatchedFindings.filter(
    (finding) => finding.lane === "advisory",
  );

  return {
    truePositives,
    falseNegatives,
    blockingFalsePositives,
    advisoryHits,
    advisoryMisses,
    advisoryFalsePositives,
    unmatchedFindings,
  };
}

function parseCliResult(stdout: string, stderr: string): Finding[] {
  const lines = stdout.trim().split("\n").filter((line) => line.length > 0);
  if (lines.length !== 1) {
    throw new Error(`verify emitted invalid machine output: ${stderr}`);
  }

  try {
    return parseMachineResult(JSON.parse(lines[0] ?? "")).findings;
  } catch (error) {
    throw new Error(`verify emitted invalid machine result: ${stderr}`, { cause: error });
  }
}

export async function runCorpusFixture(fixtureDir: string): Promise<CorpusScore> {
  const absoluteFixtureDir = resolve(fixtureDir);
  const manifest = await readManifest(absoluteFixtureDir);
  const target = await launchFixtureTarget(absoluteFixtureDir);

  try {
    const result = await runProcess(
      process.execPath,
      [CLI_BIN, "verify", "--scope=universal", "--config=invariantum.config.json", "--output=json"],
      absoluteFixtureDir,
    );
    if (result.signal !== null) {
      throw new Error(`verify terminated by signal ${result.signal}: ${result.stderr}`);
    }
    return scoreFindings(manifest, parseCliResult(result.stdout, result.stderr));
  } finally {
    if (target !== undefined) {
      await terminate(target);
    }
  }
}

function classScore(): CorpusClassScore {
  return {
    truePositives: 0,
    falseNegatives: 0,
    blockingFalsePositives: [],
    advisoryHits: 0,
    advisoryMisses: 0,
    advisoryFalsePositives: [],
    unmatchedFindings: [],
  };
}

function scoreClass(scores: Record<string, CorpusClassScore>, detectorClass: string): CorpusClassScore {
  const existing = scores[detectorClass];
  if (existing !== undefined) {
    return existing;
  }
  const created = classScore();
  scores[detectorClass] = created;
  return created;
}

export function aggregateCorpusScores(scores: CorpusScore[]): Record<string, CorpusClassScore> {
  const aggregate: Record<string, CorpusClassScore> = {};

  for (const score of scores) {
    for (const match of score.truePositives) {
      scoreClass(aggregate, match.detectorClass).truePositives += 1;
    }
    for (const label of score.falseNegatives) {
      scoreClass(aggregate, label.detectorClass).falseNegatives += 1;
    }
    for (const match of score.advisoryHits) {
      scoreClass(aggregate, match.detectorClass).advisoryHits += 1;
    }
    for (const label of score.advisoryMisses) {
      scoreClass(aggregate, label.detectorClass).advisoryMisses += 1;
    }
    for (const finding of score.blockingFalsePositives) {
      scoreClass(aggregate, finding.class).blockingFalsePositives.push(finding);
    }
    for (const finding of score.advisoryFalsePositives) {
      scoreClass(aggregate, finding.class).advisoryFalsePositives.push(finding);
    }
    for (const finding of score.unmatchedFindings) {
      scoreClass(aggregate, finding.class).unmatchedFindings.push(finding);
    }
  }

  return aggregate;
}
