/**
 * On-disk materialisation for skills. Atomic-by-rename: stage a skill's
 * full file tree under `<targetDir>.mcpctl-staging-<pid>/`, then swap the
 * old directory aside (rename to `.mcpctl-trash-<pid>`) and move the
 * staging dir into place. A concurrent reader (Claude Code starting up)
 * therefore never sees a partially-written tree.
 */
import { mkdir, rm, rename, writeFile, readdir, stat } from 'node:fs/promises';
import { dirname, join } from 'node:path';

import type { FileState } from './skills-state.js';
import { sha256Of } from './skills-state.js';

export interface SkillBody {
  /** SKILL.md content. */
  content: string;
  /** Auxiliary files keyed by relative path. */
  files?: Record<string, string>;
}

/**
 * Write a skill atomically into `targetDir`. If a previous install exists,
 * it's renamed to `<targetDir>.mcpctl-trash-<pid>` and rmtree'd after the
 * swap succeeds — so the live tree is always consistent.
 *
 * Returns the per-file FileState map for the state file.
 */
export async function installSkillAtomic(targetDir: string, body: SkillBody): Promise<Record<string, FileState>> {
  const parent = dirname(targetDir);
  await mkdir(parent, { recursive: true });

  const stagingDir = `${targetDir}.mcpctl-staging-${String(process.pid)}`;
  // If a stale staging dir exists from a previous crash, scrub it.
  await rm(stagingDir, { recursive: true, force: true });
  await mkdir(stagingDir, { recursive: true });

  const fileStates: Record<string, FileState> = {};
  // Always write SKILL.md first.
  await writeFileAt(stagingDir, 'SKILL.md', body.content, fileStates);
  if (body.files) {
    for (const [rel, content] of Object.entries(body.files)) {
      // Reject paths that try to escape the install dir. Skill files are
      // server-published; the server should already validate, but the
      // client checks too as defence in depth.
      if (rel.includes('..') || rel.startsWith('/')) {
        throw new Error(`Skill file path escapes install dir: ${rel}`);
      }
      await writeFileAt(stagingDir, rel, content, fileStates);
    }
  }

  // Atomic swap: rename existing tree aside, move staging in, rmtree the old.
  const trashDir = `${targetDir}.mcpctl-trash-${String(process.pid)}`;
  let hadExisting = false;
  try {
    await rename(targetDir, trashDir);
    hadExisting = true;
  } catch (err: unknown) {
    if (!isNotFoundError(err)) throw err;
  }
  await rename(stagingDir, targetDir);
  if (hadExisting) {
    await rm(trashDir, { recursive: true, force: true });
  }
  return fileStates;
}

/**
 * Symmetric atomic delete: rename to `.mcpctl-trash-<pid>` first, then
 * rmtree. Skip if the directory doesn't exist.
 */
export async function removeSkillAtomic(targetDir: string): Promise<void> {
  const trashDir = `${targetDir}.mcpctl-trash-${String(process.pid)}`;
  try {
    await rename(targetDir, trashDir);
  } catch (err: unknown) {
    if (isNotFoundError(err)) return;
    throw err;
  }
  await rm(trashDir, { recursive: true, force: true });
}

/** True if `path` is a directory. */
export async function isDirectory(path: string): Promise<boolean> {
  try {
    const s = await stat(path);
    return s.isDirectory();
  } catch (err: unknown) {
    if (isNotFoundError(err)) return false;
    throw err;
  }
}

/** List subdirectories of `parent` that aren't staging/trash artifacts. */
export async function listInstalledSkillNames(parent: string): Promise<string[]> {
  try {
    const entries = await readdir(parent, { withFileTypes: true });
    return entries
      .filter((e) => e.isDirectory())
      .map((e) => e.name)
      .filter((name) => !name.includes('.mcpctl-staging-') && !name.includes('.mcpctl-trash-'));
  } catch (err: unknown) {
    if (isNotFoundError(err)) return [];
    throw err;
  }
}

async function writeFileAt(
  base: string,
  rel: string,
  content: string,
  states: Record<string, FileState>,
): Promise<void> {
  const full = join(base, rel);
  await mkdir(dirname(full), { recursive: true });
  await writeFile(full, content, 'utf-8');
  const buf = Buffer.from(content, 'utf-8');
  states[rel] = { sha256: sha256Of(buf), size: buf.length };
}

function isNotFoundError(err: unknown): boolean {
  return typeof err === 'object' && err !== null && (err as { code?: string }).code === 'ENOENT';
}
