import { link, open, rm } from "node:fs/promises";
import { dirname, isAbsolute, posix, resolve, sep } from "node:path";

export const CONVERTER_CONTRACT_VERSION = "1.0" as const;
export const RESULT_MANIFEST_FILENAME = "converter-result.v1.json" as const;
export const TERMINAL_SEQUENCE = 1 as const;

export const WARNING_CODES = [
  "FONT_SUBSTITUTED",
  "GLYPH_MAPPING_INCOMPLETE",
  "IMAGE_DOWNSAMPLED",
  "LINK_DROPPED",
  "SEMANTIC_STRUCTURE_INCOMPLETE",
  "UNSUPPORTED_PDF_FEATURE",
] as const;
export type WarningCode = (typeof WARNING_CODES)[number];

export interface ConverterWarning {
  readonly code: WarningCode;
  /** Public-safe detail; must not include source content, object keys, or host paths. */
  readonly message: string;
  readonly page?: number;
}

export const PUBLIC_FAILURE_CODES = [
  "CANCELLED",
  "DEADLINE_EXCEEDED",
  "INVALID_INVOCATION",
  "INVALID_PDF",
  "SOURCE_EXPIRED",
  "UNSUPPORTED_PDF",
  "RESOURCE_LIMIT",
  "CONVERSION_FAILED",
  "OUTPUT_INVALID",
  "INTERNAL_ERROR",
] as const;
export type PublicFailureCode = (typeof PUBLIC_FAILURE_CODES)[number];

/** Closed process status mapping. Unknown/non-contract process statuses map to INTERNAL_ERROR. */
export const EXIT_CODE_BY_FAILURE = {
  INVALID_INVOCATION: 64,
  INVALID_PDF: 65,
  SOURCE_EXPIRED: 66,
  UNSUPPORTED_PDF: 67,
  RESOURCE_LIMIT: 70,
  CONVERSION_FAILED: 71,
  OUTPUT_INVALID: 72,
  DEADLINE_EXCEEDED: 73,
  CANCELLED: 74,
  INTERNAL_ERROR: 75,
} as const satisfies Record<PublicFailureCode, number>;
export type ConverterExitCode = 0 | (typeof EXIT_CODE_BY_FAILURE)[PublicFailureCode];

export function failureForExitCode(exitCode: number): PublicFailureCode {
  const match = (Object.entries(EXIT_CODE_BY_FAILURE) as [PublicFailureCode, number][])
    .find(([, knownExit]) => knownExit === exitCode);
  return match?.[0] ?? "INTERNAL_ERROR";
}

export type QualityProfile = "visual-semantic-v1";

export interface PathRoots {
  /** Absolute, private directory containing staged input files. */
  readonly input_root: string;
  /** Absolute, private, per-attempt scratch directory. */
  readonly work_root: string;
  /** Absolute, initially empty destination directory. */
  readonly output_root: string;
}

export type ConverterSource =
  | { readonly kind: "path"; readonly path: string }
  | { readonly kind: "private_object"; readonly object_key: string };

export interface ConversionOptionsV1 {
  readonly quality_profile: QualityProfile;
  readonly preserve_links: true;
  readonly semantic_html: true;
  readonly javascript: "none";
}

export interface ConverterInvocationV1 {
  readonly contract_version: typeof CONVERTER_CONTRACT_VERSION;
  readonly invocation_id: string;
  readonly job_id: string;
  readonly attempt_id: string;
  readonly roots: PathRoots;
  readonly source: ConverterSource;
  /** Lower-case hex SHA-256 of the accepted source. */
  readonly source_sha256: string;
  readonly options: ConversionOptionsV1;
  /** Absolute instant. Converter must not begin or continue work at/after this instant. */
  readonly deadline_at: string;
  /** Relative to work_root. Presence requests cancellation. */
  readonly cancellation_file: string;
  /** Relative to work_root; atomic write destination for the sole terminal result. */
  readonly result_manifest: typeof RESULT_MANIFEST_FILENAME;
}

interface TerminalBase {
  readonly contract_version: typeof CONVERTER_CONTRACT_VERSION;
  readonly invocation_id: string;
  readonly job_id: string;
  readonly attempt_id: string;
  /** Always 1. A second terminal record for the same attempt is a protocol violation. */
  readonly terminal_sequence: typeof TERMINAL_SEQUENCE;
  readonly converter_version: string;
  readonly source_sha256: string;
  readonly started_at: string;
  readonly completed_at: string;
}

export interface ConverterSuccessV1 extends TerminalBase {
  readonly status: "succeeded";
  readonly page_count: number;
  /** Relative to output_root. */
  readonly entry_html: string;
  readonly asset_count: number;
  readonly output_bytes: number;
  readonly warnings: readonly ConverterWarning[];
  readonly quality_profile: QualityProfile;
}

export interface ConverterFailureV1 extends TerminalBase {
  readonly status: "failed";
  readonly failure: {
    readonly code: PublicFailureCode;
    /** Fixed/public-safe wording only; diagnostics belong in private structured logs. */
    readonly message: string;
    readonly retryable: boolean;
  };
  readonly warnings: readonly ConverterWarning[];
}

export type ConverterResultV1 = ConverterSuccessV1 | ConverterFailureV1;

export const CANCELLATION_CONTRACT = {
  polling_interval_ms_max: 250,
  graceful_shutdown_ms: 1_000,
  behavior: "On cancellation or deadline, stop accepting output, terminate the complete process tree, remove partial output, and emit one terminal failure manifest.",
} as const;

export const ATOMIC_MANIFEST_WRITE_CONTRACT = {
  temp_name: `.${RESULT_MANIFEST_FILENAME}.tmp`,
  behavior: "Write complete UTF-8 JSON plus newline to a same-directory temporary file, fsync the file, atomically link it to the absent final path (never replacing a terminal result), remove the temporary name, then fsync the parent directory. Never expose a partial final manifest.",
} as const;

export class ContractValidationError extends Error {
  override readonly name = "ContractValidationError";
  constructor(readonly issues: readonly string[]) {
    super(`Invalid converter contract: ${issues.join("; ")}`);
  }
}

const SHA256 = /^[a-f0-9]{64}$/;
const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const VERSION = /^[A-Za-z0-9][A-Za-z0-9.+_-]{0,127}$/;

function record(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}
function nonEmpty(value: unknown): value is string {
  return typeof value === "string" && value.length > 0;
}
function instant(value: unknown): value is string {
  return nonEmpty(value) && Number.isFinite(Date.parse(value)) && /(?:Z|[+-]\d{2}:\d{2})$/.test(value);
}
function safeRelativePath(value: unknown): value is string {
  if (!nonEmpty(value) || value.includes("\\") || value.includes("\0") || isAbsolute(value)) return false;
  const normalized = posix.normalize(value);
  return normalized === value && normalized !== "." && normalized !== ".." && !normalized.startsWith("../");
}
function absoluteNormalized(value: unknown): value is string {
  return nonEmpty(value) && isAbsolute(value) && resolve(value) === value;
}
function integerAtLeast(value: unknown, minimum: number): value is number {
  return typeof value === "number" && Number.isSafeInteger(value) && value >= minimum;
}
function exactKeys(value: Record<string, unknown>, allowed: readonly string[], where: string, issues: string[]): void {
  for (const key of Object.keys(value)) if (!allowed.includes(key)) issues.push(`${where}.${key} is not allowed`);
}
function validateIdentity(value: Record<string, unknown>, issues: string[]): void {
  for (const key of ["invocation_id", "job_id", "attempt_id"] as const) {
    if (!nonEmpty(value[key]) || !ID.test(value[key])) issues.push(`${key} is invalid`);
  }
  if (!nonEmpty(value.source_sha256) || !SHA256.test(value.source_sha256)) issues.push("source_sha256 is invalid");
}
function validateWarnings(value: unknown, issues: string[]): void {
  if (!Array.isArray(value)) { issues.push("warnings must be an array"); return; }
  value.forEach((warning, index) => {
    if (!record(warning)) { issues.push(`warnings[${index}] must be an object`); return; }
    exactKeys(warning, ["code", "message", "page"], `warnings[${index}]`, issues);
    if (!WARNING_CODES.includes(warning.code as WarningCode)) issues.push(`warnings[${index}].code is invalid`);
    if (!nonEmpty(warning.message) || warning.message.length > 240) issues.push(`warnings[${index}].message is invalid`);
    if (warning.page !== undefined && !integerAtLeast(warning.page, 1)) issues.push(`warnings[${index}].page is invalid`);
  });
}

export function parseConverterInvocation(value: unknown): ConverterInvocationV1 {
  const issues: string[] = [];
  if (!record(value)) throw new ContractValidationError(["invocation must be an object"]);
  exactKeys(value, ["contract_version", "invocation_id", "job_id", "attempt_id", "roots", "source", "source_sha256", "options", "deadline_at", "cancellation_file", "result_manifest"], "invocation", issues);
  if (value.contract_version !== CONVERTER_CONTRACT_VERSION) issues.push("contract_version is unsupported");
  validateIdentity(value, issues);
  if (!record(value.roots)) issues.push("roots must be an object");
  else {
    exactKeys(value.roots, ["input_root", "work_root", "output_root"], "roots", issues);
    for (const key of ["input_root", "work_root", "output_root"] as const)
      if (!absoluteNormalized(value.roots[key])) issues.push(`roots.${key} must be an absolute normalized path`);
    const roots = [value.roots.input_root, value.roots.work_root, value.roots.output_root];
    if (new Set(roots).size !== roots.length) issues.push("roots must be distinct");
  }
  if (!record(value.source)) issues.push("source must be an object");
  else if (value.source.kind === "path") {
    exactKeys(value.source, ["kind", "path"], "source", issues);
    if (!safeRelativePath(value.source.path)) issues.push("source.path must be relative to input_root");
  } else if (value.source.kind === "private_object") {
    exactKeys(value.source, ["kind", "object_key"], "source", issues);
    if (!nonEmpty(value.source.object_key) || value.source.object_key.length > 1024 || /[\0\r\n]/.test(value.source.object_key)) issues.push("source.object_key is invalid");
  } else issues.push("source.kind is invalid");
  if (!record(value.options)) issues.push("options must be an object");
  else {
    exactKeys(value.options, ["quality_profile", "preserve_links", "semantic_html", "javascript"], "options", issues);
    if (value.options.quality_profile !== "visual-semantic-v1" || value.options.preserve_links !== true || value.options.semantic_html !== true || value.options.javascript !== "none") issues.push("options must equal the product v1 profile");
  }
  if (!instant(value.deadline_at)) issues.push("deadline_at must be an ISO instant with timezone");
  if (!safeRelativePath(value.cancellation_file)) issues.push("cancellation_file must be relative to work_root");
  if (value.result_manifest !== RESULT_MANIFEST_FILENAME) issues.push("result_manifest is invalid");
  if (issues.length) throw new ContractValidationError(issues);
  return value as unknown as ConverterInvocationV1;
}

export function parseConverterResult(value: unknown): ConverterResultV1 {
  const issues: string[] = [];
  if (!record(value)) throw new ContractValidationError(["result must be an object"]);
  const common = ["contract_version", "invocation_id", "job_id", "attempt_id", "terminal_sequence", "converter_version", "source_sha256", "started_at", "completed_at", "status", "warnings"];
  const statusKeys = value.status === "succeeded" ? ["page_count", "entry_html", "asset_count", "output_bytes", "quality_profile"] : ["failure"];
  exactKeys(value, [...common, ...statusKeys], "result", issues);
  if (value.contract_version !== CONVERTER_CONTRACT_VERSION) issues.push("contract_version is unsupported");
  if (value.terminal_sequence !== TERMINAL_SEQUENCE) issues.push("terminal_sequence must be 1");
  validateIdentity(value, issues);
  if (!nonEmpty(value.converter_version) || !VERSION.test(value.converter_version)) issues.push("converter_version is invalid");
  if (!instant(value.started_at) || !instant(value.completed_at)) issues.push("timestamps must be ISO instants with timezone");
  else if (Date.parse(value.completed_at) < Date.parse(value.started_at)) issues.push("completed_at precedes started_at");
  validateWarnings(value.warnings, issues);
  if (value.status === "succeeded") {
    if (!integerAtLeast(value.page_count, 1)) issues.push("page_count is invalid");
    if (!safeRelativePath(value.entry_html) || !String(value.entry_html).toLowerCase().endsWith(".html")) issues.push("entry_html must be an HTML path relative to output_root");
    if (!integerAtLeast(value.asset_count, 0)) issues.push("asset_count is invalid");
    if (!integerAtLeast(value.output_bytes, 1)) issues.push("output_bytes is invalid");
    if (value.quality_profile !== "visual-semantic-v1") issues.push("quality_profile is invalid");
  } else if (value.status === "failed") {
    if (!record(value.failure)) issues.push("failure must be an object");
    else {
      exactKeys(value.failure, ["code", "message", "retryable"], "failure", issues);
      if (!PUBLIC_FAILURE_CODES.includes(value.failure.code as PublicFailureCode)) issues.push("failure.code is invalid");
      if (!nonEmpty(value.failure.message) || value.failure.message.length > 240) issues.push("failure.message is invalid");
      if (typeof value.failure.retryable !== "boolean") issues.push("failure.retryable must be boolean");
    }
  } else issues.push("status must be succeeded or failed");
  if (issues.length) throw new ContractValidationError(issues);
  return value as unknown as ConverterResultV1;
}

/** Resolve a contract-relative path and prove it remains strictly below its declared root. */
export function resolveWithinRoot(root: string, relativePath: string): string {
  if (!absoluteNormalized(root)) throw new ContractValidationError(["root must be absolute and normalized"]);
  if (!safeRelativePath(relativePath)) throw new ContractValidationError(["path must be normalized, POSIX-style, and relative"]);
  const target = resolve(root, ...relativePath.split("/"));
  if (!target.startsWith(`${root}${sep}`)) throw new ContractValidationError(["path escapes root"]);
  return target;
}

/** Atomic protocol writer. Caller remains responsible for exactly-once attempt ownership. */
export async function writeResultManifestAtomic(finalPath: string, result: ConverterResultV1): Promise<void> {
  parseConverterResult(result);
  const parent = dirname(finalPath);
  const temporary = resolve(parent, ATOMIC_MANIFEST_WRITE_CONTRACT.temp_name);
  const body = `${JSON.stringify(result)}\n`;
  let file: Awaited<ReturnType<typeof open>> | undefined;
  try {
    file = await open(temporary, "wx", 0o600);
    await file.writeFile(body, "utf8");
    await file.sync();
    await file.close();
    file = undefined;
    await link(temporary, finalPath);
    await rm(temporary);
    const directory = await open(parent, "r");
    try { await directory.sync(); } finally { await directory.close(); }
  } catch (error) {
    if (file) await file.close().catch(() => undefined);
    await rm(temporary, { force: true }).catch(() => undefined);
    throw error;
  }
}
