import { mkdtemp, readFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, it } from "vitest";
import {
  ContractValidationError,
  EXIT_CODE_BY_FAILURE,
  failureForExitCode,
  parseConverterInvocation,
  parseConverterResult,
  resolveWithinRoot,
  writeResultManifestAtomic,
  type ConverterInvocationV1,
  type ConverterResultV1,
} from "../src/index.js";

const sha = "a".repeat(64);
const invocation: ConverterInvocationV1 = {
  contract_version: "1.0",
  invocation_id: "inv-1",
  job_id: "job-1",
  attempt_id: "attempt-1",
  roots: { input_root: "/jobs/1/input", work_root: "/jobs/1/work", output_root: "/jobs/1/output" },
  source: { kind: "path", path: "source.pdf" },
  source_sha256: sha,
  options: { quality_profile: "visual-semantic-v1", preserve_links: true, semantic_html: true, javascript: "none" },
  deadline_at: "2026-08-23T12:00:00Z",
  cancellation_file: "cancel.requested",
  result_manifest: "converter-result.v1.json",
};
const success: ConverterResultV1 = {
  contract_version: "1.0",
  invocation_id: "inv-1",
  job_id: "job-1",
  attempt_id: "attempt-1",
  terminal_sequence: 1,
  converter_version: "mupdf-1.0.0",
  source_sha256: sha,
  started_at: "2026-08-23T11:00:00Z",
  completed_at: "2026-08-23T11:01:00Z",
  status: "succeeded",
  page_count: 2,
  entry_html: "index.html",
  asset_count: 3,
  output_bytes: 400,
  warnings: [{ code: "FONT_SUBSTITUTED", message: "One font was substituted.", page: 1 }],
  quality_profile: "visual-semantic-v1",
};

describe("converter contract v1", () => {
  it("accepts the fixed invocation and success schemas", () => {
    expect(parseConverterInvocation(invocation)).toBe(invocation);
    expect(parseConverterResult(success)).toBe(success);
  });

  it("rejects traversal, non-fixed options, and unknown fields", () => {
    expect(() => parseConverterInvocation({ ...invocation, source: { kind: "path", path: "../secret.pdf" } })).toThrow(ContractValidationError);
    expect(() => parseConverterInvocation({ ...invocation, options: { ...invocation.options, javascript: "allowed" } })).toThrow(ContractValidationError);
    expect(() => parseConverterInvocation({ ...invocation, secret: true })).toThrow(/secret is not allowed/);
    expect(() => resolveWithinRoot("/jobs/1/input", "../secret.pdf")).toThrow(ContractValidationError);
    expect(resolveWithinRoot("/jobs/1/input", "nested/source.pdf")).toBe("/jobs/1/input/nested/source.pdf");
  });

  it("keeps warning and terminal result variants closed", () => {
    expect(() => parseConverterResult({ ...success, warnings: [{ code: "OTHER", message: "x" }] })).toThrow(/code is invalid/);
    expect(() => parseConverterResult({ ...success, terminal_sequence: 2 })).toThrow(/terminal_sequence/);
    expect(() => parseConverterResult({ ...success, status: "partial" })).toThrow(ContractValidationError);
  });

  it("maps every public failure and closes unknown exit statuses", () => {
    for (const [failure, exit] of Object.entries(EXIT_CODE_BY_FAILURE)) expect(failureForExitCode(exit)).toBe(failure);
    expect(failureForExitCode(0)).toBe("INTERNAL_ERROR");
    expect(failureForExitCode(255)).toBe("INTERNAL_ERROR");
  });

  it("publishes a complete manifest exactly once", async () => {
    const directory = await mkdtemp(join(tmpdir(), "converter-contract-"));
    const finalPath = join(directory, "converter-result.v1.json");
    try {
      await writeResultManifestAtomic(finalPath, success);
      expect(JSON.parse(await readFile(finalPath, "utf8"))).toEqual(success);
      await expect(writeResultManifestAtomic(finalPath, { ...success, output_bytes: 999 })).rejects.toMatchObject({ code: "EEXIST" });
      expect(JSON.parse(await readFile(finalPath, "utf8"))).toEqual(success);
    } finally {
      await rm(directory, { recursive: true, force: true });
    }
  });
});
