import { createHash, randomUUID } from "node:crypto";
import {
  cp,
  lstat,
  mkdir,
  readFile,
  readdir,
  rename,
  rm,
  writeFile,
} from "node:fs/promises";
import { join, relative, resolve } from "node:path";
import type { RoleSkillEntry } from "./models.js";

const MAX_SKILL_FILES = 512;
const MAX_SKILL_BYTES = 16 * 1024 * 1024;
const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
const DIGEST_PATTERN = /^[a-f0-9]{64}$/;
const RUNTIME_PLUGIN_NAME = "cph-runtime";

export interface ImportedSkillContent {
  readonly name: string;
  readonly description: string | undefined;
  readonly contentDigest: string;
}

export interface RunSkillPlugin {
  readonly root: string;
  readonly skillIds: readonly string[];
  cleanup(): Promise<void>;
}

export async function importSkillDirectory(input: {
  readonly sourceDir: string;
  readonly storeRoot: string;
}): Promise<ImportedSkillContent> {
  return commitSkillContent({
    storeRoot: input.storeRoot,
    prepare: async (dir) => {
      await cp(input.sourceDir, dir, { recursive: true, force: false, errorOnExist: true });
      return inspectSkillDirectory(dir);
    },
  });
}

/**
 * Import a skill from an in-memory file list (web upload path). Each file
 * path is relative to the skill root and must be a simple relative path
 * (no absolute, no `..`). A `SKILL.md` manifest is required.
 */
export async function importSkillFromFiles(input: {
  readonly files: readonly SkillFileInput[];
  readonly storeRoot: string;
}): Promise<ImportedSkillContent> {
  const seen = new Set<string>();
  for (const file of input.files) {
    validateSkillFilePath(file.path);
    if (seen.has(file.path)) throw new Error(`duplicate skill file path: ${file.path}`);
    seen.add(file.path);
  }
  return commitSkillContent({
    storeRoot: input.storeRoot,
    prepare: async (dir) => {
      for (const file of input.files) {
        const dest = join(dir, file.path);
        const parent = join(dest, "..");
        await mkdir(parent, { recursive: true, mode: 0o750 });
        await writeFile(dest, file.bytes, { mode: 0o640 });
      }
      return inspectSkillDirectory(dir);
    },
  });
}

/**
 * Read all files from a stored skill version (by content digest). Returns
 * relative paths and UTF-8 decoded content for the web editor.
 */
export async function readSkillFiles(input: {
  readonly storeRoot: string;
  readonly contentDigest: string;
}): Promise<readonly SkillFileOutput[]> {
  if (!DIGEST_PATTERN.test(input.contentDigest)) {
    throw new Error(`invalid content digest: ${input.contentDigest}`);
  }
  const dir = join(input.storeRoot, "versions", input.contentDigest);
  const files: Array<{ readonly path: string; readonly bytes: Buffer }> = [];
  await walk(resolve(dir), resolve(dir), files);
  return files.sort((a, b) => a.path.localeCompare(b.path)).map((f) => ({
    path: f.path,
    content: f.bytes.toString("utf8"),
  }));
}

export interface SkillFileInput {
  readonly path: string;
  readonly bytes: Buffer;
}

export interface SkillFileOutput {
  readonly path: string;
  readonly content: string;
}

/**
 * Shared commit logic: populate a temp dir, inspect it, deduplicate against
 * an existing `versions/<digest>/` destination, and atomically rename.
 *
 * `prepare(dir)` writes the skill content into `dir` and returns the inspected
 * result (name, description, contentDigest). The caller owns the write;
 * commitSkillContent owns the move.
 */
async function commitSkillContent(input: {
  readonly storeRoot: string;
  readonly prepare: (dir: string) => Promise<ImportedSkillContent>;
}): Promise<ImportedSkillContent> {
  const versionsRoot = join(input.storeRoot, "versions");
  await mkdir(versionsRoot, { recursive: true, mode: 0o750 });
  const temporary = join(versionsRoot, `.tmp-${randomUUID()}`);

  let source: ImportedSkillContent;
  try {
    source = await input.prepare(temporary);
  } catch (error) {
    await rm(temporary, { recursive: true, force: true });
    throw error;
  }

  const destination = join(versionsRoot, source.contentDigest);

  // If the destination already exists with identical content, reuse it.
  try {
    const existing = await inspectSkillDirectory(destination);
    if (existing.contentDigest !== source.contentDigest || existing.name !== source.name) {
      throw new Error(`stored skill content digest mismatch: ${source.name}`);
    }
    await rm(temporary, { recursive: true, force: true });
    return source;
  } catch (error) {
    if (!isMissingPath(error)) throw error;
  }

  try {
    await rename(temporary, destination);
  } catch (error) {
    await rm(temporary, { recursive: true, force: true });
    if (isDestinationExists(error)) {
      const existing = await inspectSkillDirectory(destination);
      if (existing.contentDigest === source.contentDigest && existing.name === source.name) {
        return source;
      }
    }
    throw error;
  }
  return source;
}

function validateSkillFilePath(path: string): void {
  if (path === "") throw new Error("skill file path is empty");
  if (path.startsWith("/")) throw new Error(`skill file path must be relative: ${path}`);
  if (path.includes("..")) throw new Error(`skill file path must not escape: ${path}`);
  if (path.startsWith("\\")) throw new Error(`skill file path must be relative: ${path}`);
}

export async function prepareRunSkillPlugin(input: {
  readonly storeRoot: string;
  readonly runId: string;
  readonly skills: readonly RoleSkillEntry[];
}): Promise<RunSkillPlugin | null> {
  if (input.skills.length === 0) return null;
  const names = new Set<string>();
  for (const skill of input.skills) {
    requireSkillName(skill.name);
    if (!DIGEST_PATTERN.test(skill.contentDigest)) {
      throw new Error(`skill ${skill.name} has invalid content digest`);
    }
    if (names.has(skill.name)) throw new Error(`duplicate role skill: ${skill.name}`);
    names.add(skill.name);
  }

  const runtimeRoot = join(input.storeRoot, "runtime");
  await mkdir(runtimeRoot, { recursive: true, mode: 0o750 });
  const pluginRoot = join(runtimeRoot, `run-${randomUUID()}`);
  try {
    await mkdir(join(pluginRoot, ".claude-plugin"), { recursive: true, mode: 0o750 });
    await mkdir(join(pluginRoot, "skills"), { recursive: true, mode: 0o750 });
    await writeFile(
      join(pluginRoot, ".claude-plugin", "plugin.json"),
      `${JSON.stringify({
        name: RUNTIME_PLUGIN_NAME,
        description: `Runtime skill snapshot for ${input.runId}`,
        version: "1",
      }, null, 2)}\n`,
      { mode: 0o640 },
    );

    for (const skill of input.skills) {
      const sourceDir = join(input.storeRoot, "versions", skill.contentDigest);
      const stored = await inspectSkillDirectory(sourceDir);
      if (stored.contentDigest !== skill.contentDigest) {
        throw new Error(`skill ${skill.name} content digest mismatch`);
      }
      if (stored.name !== skill.name) {
        throw new Error(`skill name mismatch: expected ${skill.name}, got ${stored.name}`);
      }
      await cp(sourceDir, join(pluginRoot, "skills", skill.name), {
        recursive: true,
        force: false,
        errorOnExist: true,
      });
    }
  } catch (error) {
    await rm(pluginRoot, { recursive: true, force: true });
    throw error;
  }

  return {
    root: pluginRoot,
    skillIds: input.skills.map((skill) => `${RUNTIME_PLUGIN_NAME}:${skill.name}`),
    async cleanup() {
      await rm(pluginRoot, { recursive: true, force: true });
    },
  };
}

export function readSkillStoreRoot(env: Readonly<Record<string, string | undefined>> = process.env): string {
  const configured = env["HUB_SKILL_STORE_ROOT"]?.trim();
  if (configured !== undefined && configured !== "") return resolve(configured);
  const stateRoot = env["XDG_STATE_HOME"]?.trim();
  if (stateRoot === undefined || stateRoot === "") {
    throw new Error("HUB_SKILL_STORE_ROOT or XDG_STATE_HOME is required");
  }
  return resolve(stateRoot, "skills");
}

export async function verifyStoredSkill(input: {
  readonly storeRoot: string;
  readonly name: string;
  readonly contentDigest: string;
}): Promise<void> {
  requireSkillName(input.name);
  if (!DIGEST_PATTERN.test(input.contentDigest)) {
    throw new Error(`skill ${input.name} has invalid content digest`);
  }
  const stored = await inspectSkillDirectory(join(input.storeRoot, "versions", input.contentDigest));
  if (stored.name !== input.name || stored.contentDigest !== input.contentDigest) {
    throw new Error(`stored skill verification failed: ${input.name}`);
  }
}

async function inspectSkillDirectory(directory: string): Promise<ImportedSkillContent> {
  const root = resolve(directory);
  const rootStat = await lstat(root);
  if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
    throw new Error(`skill root must be a real directory: ${root}`);
  }
  const files: Array<{ readonly path: string; readonly bytes: Buffer }> = [];
  await walk(root, root, files);
  if (files.length > MAX_SKILL_FILES) throw new Error(`skill has too many files: ${files.length}`);
  const totalBytes = files.reduce((sum, file) => sum + file.bytes.byteLength, 0);
  if (totalBytes > MAX_SKILL_BYTES) throw new Error(`skill is too large: ${totalBytes} bytes`);
  const manifest = files.find((file) => file.path === "SKILL.md");
  if (manifest === undefined) throw new Error(`skill manifest missing: ${join(root, "SKILL.md")}`);
  const frontmatter = manifest.bytes.toString("utf8");
  const name = /^name:\s*['"]?([^'"\r\n]+)['"]?\s*$/m.exec(frontmatter)?.[1]?.trim();
  if (name === undefined) throw new Error("skill manifest name missing");
  requireSkillName(name);
  const description = /^description:\s*['"]?([^'"\r\n]+)['"]?\s*$/m.exec(frontmatter)?.[1]?.trim();

  const hash = createHash("sha256");
  for (const file of files.sort((left, right) => left.path.localeCompare(right.path))) {
    hash.update(`${Buffer.byteLength(file.path)}:`);
    hash.update(file.path);
    hash.update(`${file.bytes.byteLength}:`);
    hash.update(file.bytes);
  }
  return { name, description, contentDigest: hash.digest("hex") };
}

async function walk(
  root: string,
  directory: string,
  files: Array<{ readonly path: string; readonly bytes: Buffer }>,
): Promise<void> {
  const entries = await readdir(directory, { withFileTypes: true });
  for (const entry of entries) {
    const fullPath = join(directory, entry.name);
    if (entry.isSymbolicLink()) throw new Error(`skill symlink is forbidden: ${fullPath}`);
    if (entry.isDirectory()) {
      await walk(root, fullPath, files);
      continue;
    }
    if (!entry.isFile()) throw new Error(`skill contains unsupported filesystem entry: ${fullPath}`);
    const relativePath = relative(root, fullPath);
    files.push({ path: relativePath, bytes: await readFile(fullPath) });
    if (files.length > MAX_SKILL_FILES) throw new Error(`skill has too many files: ${files.length}`);
  }
}

function requireSkillName(name: string): void {
  if (!SKILL_NAME_PATTERN.test(name)) throw new Error(`invalid skill name: ${name}`);
}

function isMissingPath(error: unknown): boolean {
  return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
}

function isDestinationExists(error: unknown): boolean {
  return typeof error === "object" && error !== null && "code" in error &&
    (error.code === "EEXIST" || error.code === "ENOTEMPTY");
}
