import { createHash, randomBytes } from "node:crypto";
import {
  mkdir,
  open,
  readFile,
  rename,
  writeFile,
} from "node:fs/promises";
import { dirname, join } from "node:path";

export type ArtifactKind =
  | "dom-snapshot"
  | "screenshot-original"
  | "trace"
  | "partial-evidence";

export type ArtifactRef = {
  relativePath: string;
  mediaType: string;
  contentHash: string;
  redactionState: "none" | "partial" | "full";
  dimensions?: { width: number; height: number };
  sourceRunId: string;
};

export type ArtifactPayload = {
  bytes: Buffer;
  mediaType: string;
  dimensions?: { width: number; height: number };
};

function assertNonEmptyString(value: string, field: string): void {
  if (value.trim().length === 0) {
    throw new Error(`${field} must be a non-empty string`);
  }
}

function assertSafeRelativePath(value: string, field: string): void {
  assertNonEmptyString(value, field);
  if (value.startsWith("/") || value.includes("\\")) {
    throw new Error(`${field} must be a safe relative path`);
  }
  const segments = value.split("/");
  if (segments.some((segment) => segment === ".." || segment.length === 0)) {
    throw new Error(`${field} must be a safe relative path`);
  }
}

function tempPathFor(targetPath: string): string {
  const suffix = `${String(process.pid)}-${randomBytes(8).toString("hex")}.tmp`;
  return `${targetPath}.${suffix}`;
}

async function fsyncFile(filePath: string): Promise<void> {
  const handle = await open(filePath, "r");
  try {
    await handle.sync();
  } finally {
    await handle.close();
  }
}

async function fsyncDirectory(dirPath: string): Promise<void> {
  try {
    const handle = await open(dirPath, "r");
    try {
      await handle.sync();
    } finally {
      await handle.close();
    }
  } catch {
    // Directory fsync is best-effort on platforms that do not support it.
  }
}

export function sha256Buffer(content: Buffer): string {
  return createHash("sha256").update(content).digest("hex");
}

export function contentAddressedFileName(fileName: string, contentHash: string): string {
  assertNonEmptyString(fileName, "fileName");
  assertNonEmptyString(contentHash, "contentHash");
  const digest = contentHash.slice(0, 12);
  const dot = fileName.lastIndexOf(".");
  return dot <= 0
    ? `${fileName}-${digest}`
    : `${fileName.slice(0, dot)}-${digest}${fileName.slice(dot)}`;
}

function artifactFileName(kind: ArtifactKind): string {
  switch (kind) {
    case "dom-snapshot":
      return "dom-snapshot.html";
    case "screenshot-original":
      return "screenshot-original.png";
    case "trace":
      return "trace.zip";
    case "partial-evidence":
      return "partial-evidence.json";
    default: {
      const exhaustive: never = kind;
      throw new Error(`Unsupported artifact kind: ${String(exhaustive)}`);
    }
  }
}

export function artifactRelativePath(
  cellId: string,
  kind: ArtifactKind,
  contentHash: string,
): string {
  assertNonEmptyString(cellId, "cellId");
  return `cells/${cellId}/${contentAddressedFileName(artifactFileName(kind), contentHash)}`;
}

export function cellArtifactDir(runDir: string, cellId: string): string {
  assertNonEmptyString(runDir, "runDir");
  assertNonEmptyString(cellId, "cellId");
  return join(runDir, "cells", cellId);
}

export async function atomicWriteBytes(
  targetPath: string,
  content: Buffer,
): Promise<void> {
  const directory = dirname(targetPath);
  await mkdir(directory, { recursive: true });

  const tempPath = tempPathFor(targetPath);
  await writeFile(tempPath, content);
  await fsyncFile(tempPath);
  await rename(tempPath, targetPath);
  await fsyncDirectory(directory);
}

export async function atomicWriteBytesIfAbsent(
  targetPath: string,
  content: Buffer,
): Promise<"written" | "exists"> {
  try {
    await readFile(targetPath);
    return "exists";
  } catch (error) {
    const errno = (error as NodeJS.ErrnoException).code;
    if (errno !== "ENOENT") {
      throw error;
    }
  }

  await atomicWriteBytes(targetPath, content);
  return "written";
}

export function buildArtifactRef(input: {
  relativePath: string;
  mediaType: string;
  content: Buffer;
  sourceRunId: string;
  dimensions?: { width: number; height: number };
  redactionState?: ArtifactRef["redactionState"];
}): ArtifactRef {
  const ref: ArtifactRef = {
    relativePath: input.relativePath,
    mediaType: input.mediaType,
    contentHash: sha256Buffer(input.content),
    redactionState: input.redactionState ?? "none",
    sourceRunId: input.sourceRunId,
  };
  if (input.dimensions !== undefined) {
    ref.dimensions = input.dimensions;
  }
  return ref;
}

export async function persistArtifact(input: {
  runDir: string;
  cellId: string;
  kind: ArtifactKind;
  payload: ArtifactPayload;
  sourceRunId: string;
  redactionState?: ArtifactRef["redactionState"];
}): Promise<ArtifactRef> {
  const relativePath = artifactRelativePath(
    input.cellId,
    input.kind,
    sha256Buffer(input.payload.bytes),
  );
  return persistArtifactAtPath({
    runDir: input.runDir,
    relativePath,
    payload: input.payload,
    sourceRunId: input.sourceRunId,
    ...(input.redactionState === undefined ? {} : { redactionState: input.redactionState }),
  });
}

export async function persistArtifactAtPath(input: {
  runDir: string;
  relativePath: string;
  payload: ArtifactPayload;
  sourceRunId: string;
  redactionState?: ArtifactRef["redactionState"];
}): Promise<ArtifactRef> {
  assertNonEmptyString(input.runDir, "runDir");
  assertSafeRelativePath(input.relativePath, "artifact relativePath");
  assertNonEmptyString(input.sourceRunId, "sourceRunId");

  const relativePath = input.relativePath;
  await atomicWriteBytesIfAbsent(join(input.runDir, relativePath), input.payload.bytes);

  return buildArtifactRef({
    relativePath,
    mediaType: input.payload.mediaType,
    content: input.payload.bytes,
    sourceRunId: input.sourceRunId,
    ...(input.redactionState === undefined
      ? {}
      : { redactionState: input.redactionState }),
    ...(input.payload.dimensions === undefined
      ? {}
      : { dimensions: input.payload.dimensions }),
  });
}
