import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
import * as childProcess from "node:child_process";
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { loadConfig, saveConfig } from "../../src/core/config/config";
import { syncFromRef } from "../../src/sources/providers/sync-from-ref";
import { validateTarEntries } from "../../src/sources/providers/tar-utils";

/**
 * Test helper: provider sync that returns the legacy `stashRoot` field used
 * by these tests. The production `akmAdd` flow uses the same `syncFromRef`
 * call directly.
 */
async function installRegistryRef(ref: string, options?: { cacheRootDir?: string; writable?: boolean }) {
  const synced = await syncFromRef(ref, options);
  return {
    ...synced,
    stashRoot: synced.contentDir,
    installedAt: synced.syncedAt,
  };
}

import { akmShowUnified as akmShow } from "../../src/commands/read/show";
import { akmAdd, registerWikiSource } from "../../src/commands/sources/source-add";
import { parseRegistryRef } from "../../src/registry/resolve";
import { listPages, listWikis, showWiki } from "../../src/wiki/wiki";

function makeTempDir(prefix: string): string {
  return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}

function createEmptyStashDir(prefix: string): string {
  const stashDir = makeTempDir(prefix);
  for (const sub of ["skills", "commands", "agents", "knowledge", "scripts"]) {
    fs.mkdirSync(path.join(stashDir, sub), { recursive: true });
  }
  saveConfig({ semanticSearchMode: "off" });
  return stashDir;
}

function writeFile(filePath: string, content: string): void {
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
  fs.writeFileSync(filePath, content);
}

function runGit(args: string[], cwd: string): string {
  const result = spawnSync("git", args, { cwd, encoding: "utf8" });
  if (result.status !== 0) {
    throw new Error(result.stderr.trim() || `git ${args.join(" ")} failed`);
  }
  return result.stdout.trim();
}

function runRealSpawnSync(
  command: string,
  args: string[],
  options?: Parameters<typeof childProcess.spawnSync>[2],
): ReturnType<typeof childProcess.spawnSync> {
  const result = Bun.spawnSync([command, ...args], {
    cwd: typeof options?.cwd === "string" ? options.cwd : options?.cwd?.toString(),
    env: options?.env ? Object.fromEntries(Object.entries(options.env).map(([k, v]) => [k, String(v)])) : undefined,
    stdout: "pipe",
    stderr: "pipe",
  });
  const resultRecord = result as unknown as { success: boolean; error?: unknown };
  return {
    pid: 0,
    output: [null, result.stdout, result.stderr],
    stdout: Buffer.from(result.stdout).toString(options?.encoding === "buffer" ? undefined : "utf8"),
    stderr: Buffer.from(result.stderr).toString(options?.encoding === "buffer" ? undefined : "utf8"),
    status: result.exitCode,
    signal: result.signalCode,
    error: resultRecord.success ? undefined : (resultRecord.error as Error | undefined),
  } as ReturnType<typeof childProcess.spawnSync>;
}

// XDG_* snapshot+restore is provided by tests/_preload.ts. This block only
// owns the per-test tmp-dir lifecycle.
let testConfigDir = "";
let testDataDir = "";
let testStateDir = "";

beforeEach(() => {
  testConfigDir = makeTempDir("akm-registry-config-");
  testDataDir = makeTempDir("akm-registry-data-");
  testStateDir = makeTempDir("akm-registry-state-");
  process.env.XDG_CONFIG_HOME = testConfigDir;
  process.env.XDG_DATA_HOME = testDataDir;
  process.env.XDG_STATE_HOME = testStateDir;
});

afterEach(() => {
  if (testConfigDir) {
    fs.rmSync(testConfigDir, { recursive: true, force: true });
    testConfigDir = "";
  }
  if (testDataDir) {
    fs.rmSync(testDataDir, { recursive: true, force: true });
    testDataDir = "";
  }
  if (testStateDir) {
    fs.rmSync(testStateDir, { recursive: true, force: true });
    testStateDir = "";
  }
});

function initGitRepo(repoDir: string): void {
  // Pin the initial branch name so the test doesn't depend on the host's
  // `init.defaultBranch` setting (which may be `master` on older hosts and
  // `main` on newer ones). We push `HEAD:main` below; the bare remote's
  // HEAD symbolic-ref only lines up if the worktree branch is also `main`.
  runGit(["init", "--initial-branch=main"], repoDir);
  runGit(["config", "user.name", "AKM Tests"], repoDir);
  runGit(["config", "user.email", "akm@example.test"], repoDir);
  runGit(["config", "commit.gpgsign", "false"], repoDir);
  runGit(["add", "."], repoDir);
  runGit(["commit", "-m", "initial"], repoDir);
}

function withEnv<T>(overrides: Partial<NodeJS.ProcessEnv>, run: () => Promise<T>): Promise<T>;
function withEnv<T>(overrides: Partial<NodeJS.ProcessEnv>, run: () => T): T;
function withEnv<T>(overrides: Partial<NodeJS.ProcessEnv>, run: () => T | Promise<T>): T | Promise<T> {
  const previous = new Map<string, string | undefined>();
  for (const [key, value] of Object.entries(overrides)) {
    previous.set(key, process.env[key]);
    if (value === undefined) {
      delete process.env[key];
    } else {
      process.env[key] = value;
    }
  }

  const restore = () => {
    for (const [key, value] of previous) {
      if (value === undefined) {
        delete process.env[key];
      } else {
        process.env[key] = value;
      }
    }
  };

  try {
    const result = run();
    if (result && typeof (result as Promise<T>).then === "function") {
      return (result as Promise<T>).finally(restore);
    }
    restore();
    return result;
  } catch (error) {
    restore();
    throw error;
  }
}

function createTarGz(sourceDir: string, archivePath: string): void {
  const result = spawnSync("tar", ["czf", archivePath, "-C", path.dirname(sourceDir), path.basename(sourceDir)], {
    encoding: "utf8",
  });
  if (result.status !== 0) {
    throw new Error(result.stderr.trim() || `tar failed for ${archivePath}`);
  }
}

async function withMockedNpmPackage<T>(packageName: string, archivePath: string, run: () => Promise<T>): Promise<T> {
  const tarballBytes = fs.readFileSync(archivePath);
  const tarballSha1 = createHash("sha1").update(tarballBytes).digest("hex");
  const encodedPackageName = encodeURIComponent(packageName);
  const registryUrl = `https://registry.npmjs.org/${encodedPackageName}`;
  const tarballUrl = `https://example.test/${encodedPackageName}.tgz`;
  const originalFetch = globalThis.fetch;
  globalThis.fetch = (async (input: RequestInfo | URL) => {
    const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
    if (url === registryUrl) {
      return new Response(
        JSON.stringify({
          "dist-tags": { latest: "1.0.0" },
          versions: {
            "1.0.0": {
              dist: { tarball: tarballUrl, shasum: tarballSha1 },
            },
          },
        }),
        { status: 200 },
      );
    }
    if (url === tarballUrl) {
      return new Response(tarballBytes, { status: 200 });
    }
    return new Response("not found", { status: 404 });
  }) as typeof fetch;

  // The mock serves tarballs from example.test, so trust that host for the
  // duration of the run via the operator-configurable npm registry override.
  const originalRegistry = process.env.AKM_NPM_REGISTRY;
  process.env.AKM_NPM_REGISTRY = "https://example.test";
  try {
    return await run();
  } finally {
    globalThis.fetch = originalFetch;
    if (originalRegistry === undefined) {
      delete process.env.AKM_NPM_REGISTRY;
    } else {
      process.env.AKM_NPM_REGISTRY = originalRegistry;
    }
  }
}

describe("local directory installs", () => {
  test("akmAdd adds a local directory as a stash source", async () => {
    const stashDir = createEmptyStashDir("akm-git-stash-");
    const cacheHome = makeTempDir("akm-git-cache-");
    const repoDir = makeTempDir("akm-git-repo-");
    const stashDir2 = path.join(repoDir, "stashes", "sample");
    writeFile(path.join(stashDir2, "scripts", "hello.sh"), "#!/usr/bin/env bash\necho hello\n");
    writeFile(path.join(repoDir, "README.md"), "# Example repo\n");
    initGitRepo(repoDir);

    try {
      const result = await withEnv({ AKM_STASH_DIR: stashDir, XDG_CACHE_HOME: cacheHome }, () =>
        akmAdd({ ref: stashDir2 }),
      );

      // Local adds now create stash sources, not installed entries
      expect(result.sourceAdded).toBeDefined();
      expect(result.sourceAdded?.type).toBe("filesystem");
      expect(result.sourceAdded?.stashRoot).toBe(stashDir2);
      expect(result.installed).toBeUndefined();
      expect(fs.existsSync(path.join(result.sourceAdded?.stashRoot as string, "scripts", "hello.sh"))).toBe(true);

      const config = loadConfig();
      const stashPaths = (config.sources ?? []).map((s) => s.path);
      expect(stashPaths).toContain(result.sourceAdded?.stashRoot);

      const shown = await withEnv({ AKM_STASH_DIR: stashDir, XDG_CACHE_HOME: cacheHome }, () =>
        akmShow({ ref: "script:hello.sh" }),
      );
      expect(shown.type).toBe("script");
      expect(shown.path).toContain(result.sourceAdded?.stashRoot as string);
    } finally {
      fs.rmSync(stashDir, { recursive: true, force: true });
      fs.rmSync(cacheHome, { recursive: true, force: true });
      fs.rmSync(repoDir, { recursive: true, force: true });
    }
  });

  test("akmAdd references local directory directly (no include config)", async () => {
    const stashDir = createEmptyStashDir("akm-nogit-stash-");
    const cacheHome = makeTempDir("akm-nogit-cache-");
    const stashDir2 = makeTempDir("akm-nogit-stash-");
    writeFile(path.join(stashDir2, "scripts", "hello.sh"), "#!/usr/bin/env bash\necho hello\n");

    try {
      const result = await withEnv({ AKM_STASH_DIR: stashDir, XDG_CACHE_HOME: cacheHome }, () =>
        akmAdd({ ref: stashDir2 }),
      );

      expect(result.sourceAdded).toBeDefined();
      expect(result.sourceAdded?.type).toBe("filesystem");
      // stashRoot points directly at the source, no cache directory
      expect(result.sourceAdded?.stashRoot).toBe(stashDir2);
      expect(fs.existsSync(path.join(result.sourceAdded?.stashRoot as string, "scripts", "hello.sh"))).toBe(true);
    } finally {
      fs.rmSync(stashDir, { recursive: true, force: true });
      fs.rmSync(cacheHome, { recursive: true, force: true });
      fs.rmSync(stashDir2, { recursive: true, force: true });
    }
  });

  test("akmAdd discovers stash dirs nested inside a subdirectory", async () => {
    const stashDir = createEmptyStashDir("akm-nested-stash-");
    const cacheHome = makeTempDir("akm-nested-cache-");
    const projectDir = makeTempDir("akm-nested-project-");
    // Assets are nested: project/my-stash/scripts/hello.sh
    writeFile(path.join(projectDir, "my-stash", "scripts", "hello.sh"), "#!/usr/bin/env bash\necho hello\n");
    writeFile(path.join(projectDir, "my-stash", "skills", "review", "SKILL.md"), "---\nname: review\n---\n# Review\n");
    writeFile(path.join(projectDir, "README.md"), "# My project\n");

    try {
      const result = await withEnv({ AKM_STASH_DIR: stashDir, XDG_CACHE_HOME: cacheHome }, () =>
        akmAdd({ ref: projectDir }),
      );

      expect(result.sourceAdded).toBeDefined();
      // stashRoot should point to the nested my-stash dir, not the project root
      expect(result.sourceAdded?.stashRoot).toBe(path.join(projectDir, "my-stash"));
      expect(fs.existsSync(path.join(result.sourceAdded?.stashRoot as string, "scripts", "hello.sh"))).toBe(true);
      expect(fs.existsSync(path.join(result.sourceAdded?.stashRoot as string, "skills", "review", "SKILL.md"))).toBe(
        true,
      );
    } finally {
      fs.rmSync(stashDir, { recursive: true, force: true });
      fs.rmSync(cacheHome, { recursive: true, force: true });
      fs.rmSync(projectDir, { recursive: true, force: true });
    }
  });

  test("akmAdd indexes type-dir source directly when basename matches type", async () => {
    const stashDir = createEmptyStashDir("akm-typedir-stash-");
    const cacheHome = makeTempDir("akm-typedir-cache-");
    // Create a directory named "knowledge" with nested files
    const parentDir = makeTempDir("akm-typedir-src-");
    const srcDir = path.join(parentDir, "knowledge");
    writeFile(path.join(srcDir, "guide.md"), "# Guide\n");
    writeFile(path.join(srcDir, "policies", "general.md"), "# General\n");
    writeFile(path.join(srcDir, "policies", "security", "main.md"), "# Security\n");

    try {
      const result = await withEnv({ AKM_STASH_DIR: stashDir, XDG_CACHE_HOME: cacheHome }, () =>
        akmAdd({ ref: srcDir }),
      );

      expect(result.sourceAdded).toBeDefined();
      // stashRoot is the source dir itself — indexer detects basename "knowledge" matches a type dir
      expect(result.sourceAdded?.stashRoot).toBe(srcDir);
      expect(result.index.totalEntries).toBeGreaterThanOrEqual(3);
    } finally {
      fs.rmSync(stashDir, { recursive: true, force: true });
      fs.rmSync(cacheHome, { recursive: true, force: true });
      fs.rmSync(parentDir, { recursive: true, force: true });
    }
  });

  test("akmAdd with --type wiki registers an external wiki source coherently", async () => {
    const stashDir = createEmptyStashDir("akm-wiki-stash-");
    const cacheHome = makeTempDir("akm-wiki-cache-");
    const wikiDir = makeTempDir("akm-wiki-source-");
    writeFile(path.join(wikiDir, "schema.md"), "---\ndescription: External docs\n---\n# Schema\n");
    writeFile(path.join(wikiDir, "overview.md"), "---\ndescription: Overview page\n---\n# Overview\n");
    writeFile(path.join(wikiDir, "raw", "paper.md"), "# Paper\n");

    try {
      const result = await withEnv({ AKM_STASH_DIR: stashDir, XDG_CACHE_HOME: cacheHome }, () =>
        akmAdd({ ref: wikiDir, name: "ics-docs", overrideType: "wiki" }),
      );

      expect(result.sourceAdded?.type).toBe("filesystem");
      expect(result.sourceAdded?.stashRoot).toBe(wikiDir);

      const config = loadConfig();
      const entry = (config.sources ?? []).find((stash) => stash.path === wikiDir);
      expect(entry?.wikiName).toBe("ics-docs");

      const wikis = listWikis(stashDir);
      expect(wikis.map((wiki) => wiki.name)).toContain("ics-docs");

      const shownWiki = showWiki(stashDir, "ics-docs");
      expect(shownWiki.path).toBe(wikiDir);

      const pages = listPages(stashDir, "ics-docs");
      expect(pages.map((page) => page.ref)).toEqual(["wiki:ics-docs/overview", "wiki:ics-docs/raw/paper"]);

      const shownPage = await withEnv({ AKM_STASH_DIR: stashDir, XDG_CACHE_HOME: cacheHome }, () =>
        akmShow({ ref: "wiki:ics-docs/overview" }),
      );
      expect(shownPage.type).toBe("wiki");
      expect(shownPage.path).toBe(path.join(wikiDir, "overview.md"));
    } finally {
      fs.rmSync(stashDir, { recursive: true, force: true });
      fs.rmSync(cacheHome, { recursive: true, force: true });
      fs.rmSync(wikiDir, { recursive: true, force: true });
    }
  });

  test("registerWikiSource rejects a name that conflicts with an existing stash-owned wiki", async () => {
    const stashDir = createEmptyStashDir("akm-wiki-conflict-stash-");
    const cacheHome = makeTempDir("akm-wiki-conflict-cache-");
    const wikiSourceDir = makeTempDir("akm-wiki-conflict-source-");
    writeFile(path.join(stashDir, "wikis", "ics-docs", "schema.md"), "---\ndescription: Stash wiki\n---\n# Schema\n");

    try {
      await expect(
        withEnv({ AKM_STASH_DIR: stashDir, XDG_CACHE_HOME: cacheHome }, () =>
          registerWikiSource({ ref: wikiSourceDir, name: "ics-docs" }),
        ),
      ).rejects.toThrow("Wiki already exists: ics-docs.");
    } finally {
      fs.rmSync(stashDir, { recursive: true, force: true });
      fs.rmSync(cacheHome, { recursive: true, force: true });
      fs.rmSync(wikiSourceDir, { recursive: true, force: true });
    }
  });

  test("parseRegistryRef resolves bare name to local when directory exists", () => {
    const tempDir = makeTempDir("akm-parse-registry-");
    const previousCwd = process.cwd();
    fs.mkdirSync(path.join(tempDir, "local-stash"));

    try {
      process.chdir(tempDir);
      const parsed = parseRegistryRef("local-stash");
      expect(parsed.source).toBe("local");
      if (parsed.source === "local") {
        expect(parsed.sourcePath).toBe(path.resolve("local-stash"));
      }
    } finally {
      process.chdir(previousCwd);
      fs.rmSync(tempDir, { recursive: true, force: true });
    }
  });

  test("parseRegistryRef falls through to npm when bare name is not a local directory", () => {
    const parsed = parseRegistryRef("nonexistent-stash");
    expect(parsed.source).toBe("npm");
    expect(parsed.id).toBe("npm:nonexistent-stash");
  });

  test("parseRegistryRef resolves '.' as the current directory", () => {
    const tempDir = makeTempDir("akm-parse-dot-");
    const previousCwd = process.cwd();

    try {
      process.chdir(tempDir);
      const parsed = parseRegistryRef(".");
      expect(parsed.source).toBe("local");
      if (parsed.source === "local") {
        expect(parsed.sourcePath).toBe(path.resolve("."));
      }
    } finally {
      process.chdir(previousCwd);
      fs.rmSync(tempDir, { recursive: true, force: true });
    }
  });

  test("parseRegistryRef rejects missing explicit local paths", () => {
    const tempDir = makeTempDir("akm-missing-local-path-");
    const previousCwd = process.cwd();

    try {
      process.chdir(tempDir);
      expect(() => parseRegistryRef("./missing-stash")).toThrow("Local path not found:");
    } finally {
      process.chdir(previousCwd);
      fs.rmSync(tempDir, { recursive: true, force: true });
    }
  });

  test("parseRegistryRef parses git+https:// prefix as git source", () => {
    const parsed = parseRegistryRef("git+https://gitlab.com/org/stash.git");
    expect(parsed.source).toBe("git");
    expect(parsed.id).toBe("git:https://gitlab.com/org/stash");
    if (parsed.source === "git") {
      expect(parsed.url).toBe("https://gitlab.com/org/stash.git");
      expect(parsed.requestedRef).toBeUndefined();
    }
  });

  test("parseRegistryRef parses git+https:// with ref suffix", () => {
    const parsed = parseRegistryRef("git+https://gitlab.com/org/stash#v2.0");
    expect(parsed.source).toBe("git");
    if (parsed.source === "git") {
      expect(parsed.url).toBe("https://gitlab.com/org/stash");
      expect(parsed.requestedRef).toBe("v2.0");
    }
  });

  test("parseRegistryRef parses git+ssh:// as git source", () => {
    const parsed = parseRegistryRef("git+ssh://git@gitlab.com/org/stash.git");
    expect(parsed.source).toBe("git");
    if (parsed.source === "git") {
      expect(parsed.url).toBe("ssh://git@gitlab.com/org/stash.git");
    }
  });

  test("parseRegistryRef routes non-GitHub https URLs to git source", () => {
    const parsed = parseRegistryRef("https://gitlab.com/org/stash.git");
    expect(parsed.source).toBe("git");
  });

  test("parseRegistryRef still routes GitHub https URLs to github source", () => {
    const parsed = parseRegistryRef("https://github.com/owner/repo");
    expect(parsed.source).toBe("github");
  });

  test("parseRegistryRef parses file: prefix as local source", () => {
    const tempDir = makeTempDir("akm-file-uri-");
    try {
      const parsed = parseRegistryRef(`file:${tempDir}`);
      expect(parsed.source).toBe("local");
      if (parsed.source === "local") {
        expect(parsed.sourcePath).toBe(path.resolve(tempDir));
      }
    } finally {
      fs.rmSync(tempDir, { recursive: true, force: true });
    }
  });

  test("parseRegistryRef parses file:/// absolute URI as local source", () => {
    const tempDir = makeTempDir("akm-file-abs-uri-");
    try {
      const parsed = parseRegistryRef(`file://${tempDir}`);
      expect(parsed.source).toBe("local");
      if (parsed.source === "local") {
        expect(parsed.sourcePath).toBe(path.resolve(tempDir));
      }
    } finally {
      fs.rmSync(tempDir, { recursive: true, force: true });
    }
  });

  test("parseRegistryRef rejects registry search IDs like skills-sh:...", () => {
    expect(() => parseRegistryRef("skills-sh:anthropics/skills/frontend-design")).toThrow(
      "looks like a registry search result ID",
    );
  });

  test("parseRegistryRef rejects static-index registry IDs", () => {
    expect(() => parseRegistryRef("static-index:npm:some-stash")).toThrow("looks like a registry search result ID");
  });

  test("parseRegistryRef still allows npm: prefix", () => {
    const parsed = parseRegistryRef("npm:some-stash");
    expect(parsed.source).toBe("npm");
  });

  test("parseRegistryRef still allows github: prefix", () => {
    const parsed = parseRegistryRef("github:owner/repo");
    expect(parsed.source).toBe("github");
  });

  test("installRegistryRef installs github refs through git transport", async () => {
    const cacheHome = makeTempDir("akm-github-cache-");
    const repoRoot = makeTempDir("akm-github-src-");
    const remoteRoot = makeTempDir("akm-github-remote-");
    const remoteRepo = path.join(remoteRoot, "repo.git");
    const worktree = path.join(repoRoot, "worktree");
    fs.mkdirSync(worktree, { recursive: true });
    writeFile(path.join(worktree, "scripts", "hello.sh"), "#!/usr/bin/env bash\necho hello\n");
    initGitRepo(worktree);
    // Pin the bare repo's default branch so its HEAD symbolic-ref matches
    // the branch we push to. Without this, the bare repo's HEAD may point
    // at `master` (host default) and `git clone` checks out an empty tree.
    runGit(["init", "--bare", "--initial-branch=main", remoteRepo], remoteRoot);
    runGit(["remote", "add", "origin", remoteRepo], worktree);
    runGit(["push", "origin", "HEAD:main"], worktree);

    const originalFetch = globalThis.fetch;
    let gitLsRemoteCalls = 0;
    let gitCloneCalls = 0;

    globalThis.fetch = (async () => new Response("not found", { status: 404 })) as unknown as typeof fetch;
    const spawnSyncSpy = spyOn(childProcess, "spawnSync").mockImplementation(((
      command: string,
      args: readonly string[] | undefined,
      options: Parameters<typeof childProcess.spawnSync>[2],
    ) => {
      if (command === "git" && Array.isArray(args) && args[0] === "ls-remote") {
        gitLsRemoteCalls += 1;
        const nextArgs = [...args];
        nextArgs[1] = remoteRepo;
        return runRealSpawnSync(command, nextArgs, options);
      }
      if (command === "git" && Array.isArray(args) && args[0] === "clone") {
        gitCloneCalls += 1;
        const nextArgs = [...args];
        const urlIndex = nextArgs.indexOf("https://github.com/owner/repo.git");
        if (urlIndex >= 0) nextArgs[urlIndex] = remoteRepo;
        return runRealSpawnSync(command, nextArgs, options);
      }
      return runRealSpawnSync(command, args as string[], options);
    }) as unknown as typeof childProcess.spawnSync);

    try {
      const result = await withEnv({ XDG_CACHE_HOME: cacheHome }, () => installRegistryRef("github:owner/repo"));
      expect(result.source).toBe("github");
      expect(result.ref).toBe("github:owner/repo");
      expect(result.artifactUrl).toBe("https://github.com/owner/repo.git");
      expect(fs.existsSync(path.join(result.stashRoot, "scripts", "hello.sh"))).toBe(true);
      expect(gitLsRemoteCalls).toBeGreaterThan(0);
      expect(gitCloneCalls).toBeGreaterThan(0);
    } finally {
      globalThis.fetch = originalFetch;
      spawnSyncSpy.mockRestore();
      fs.rmSync(cacheHome, { recursive: true, force: true });
      fs.rmSync(repoRoot, { recursive: true, force: true });
      fs.rmSync(remoteRoot, { recursive: true, force: true });
    }
  });

  test("applies include from nearest package.json for nested stash roots", async () => {
    const cacheHome = makeTempDir("akm-nested-include-cache-");
    const packageDir = makeTempDir("akm-nested-include-package-");
    const archivePath = path.join(makeTempDir("akm-nested-archive-"), "stash.tgz");
    const tarRoot = path.join(packageDir, "stash");
    fs.mkdirSync(path.join(tarRoot, "scripts"), { recursive: true });
    fs.mkdirSync(path.join(tarRoot, "docs"), { recursive: true });
    writeFile(
      path.join(tarRoot, "package.json"),
      JSON.stringify(
        {
          name: "nested-stash",
          akm: {
            include: ["scripts"],
          },
        },
        null,
        2,
      ),
    );
    writeFile(path.join(tarRoot, "scripts", "kept.sh"), "#!/usr/bin/env bash\necho kept\n");
    writeFile(path.join(tarRoot, "docs", "ignored.md"), "# ignored\n");
    createTarGz(tarRoot, archivePath);

    try {
      const result = await withMockedNpmPackage("nested-stash", archivePath, () =>
        withEnv({ XDG_CACHE_HOME: cacheHome }, () => installRegistryRef("nested-stash")),
      );
      expect(fs.existsSync(path.join(result.stashRoot, "scripts", "kept.sh"))).toBe(true);
      expect(fs.existsSync(path.join(result.stashRoot, "docs"))).toBe(false);
    } finally {
      fs.rmSync(cacheHome, { recursive: true, force: true });
      fs.rmSync(packageDir, { recursive: true, force: true });
      fs.rmSync(path.dirname(archivePath), { recursive: true, force: true });
    }
  });
});

// ── Security: validateTarEntries adversarial cases ───────────────────────────

describe("validateTarEntries", () => {
  test("accepts normal relative entries", () => {
    const output = ["stash-v1.0.0/README.md", "stash-v1.0.0/agents/deploy.md", "stash-v1.0.0/scripts/run.sh"].join(
      "\n",
    );
    expect(() => validateTarEntries(output)).not.toThrow();
  });

  test("rejects entry with absolute path", () => {
    const output = "stash-v1.0.0/README.md\n/etc/passwd";
    expect(() => validateTarEntries(output)).toThrow(/absolute path/);
  });

  test("rejects entry with ../ traversal at root level", () => {
    const output = "stash-v1.0.0/README.md\n../../evil";
    expect(() => validateTarEntries(output)).toThrow(/path traversal/);
  });

  test("rejects entry that escapes after strip-components (a/../../../evil)", () => {
    // After normalization, stash-v1.0.0/../../../evil becomes ../../evil which
    // starts with ".." — caught by the path traversal check before strip.
    const output = "stash-v1.0.0/../../../evil";
    expect(() => validateTarEntries(output)).toThrow(/path traversal|unsafe entry/);
  });

  test("rejects entry that escapes after strip-components (clean first part)", () => {
    // "a/b/../../../../evil" normalizes to "../../evil" which starts with ".."
    // and is caught by the path traversal check (same as other traversal cases).
    const output = "a/b/../../../../evil";
    expect(() => validateTarEntries(output)).toThrow(/path traversal|unsafe entry/);
  });

  test("rejects entry with null byte in name", () => {
    const output = "stash-v1.0.0/README\0.md";
    expect(() => validateTarEntries(output)).toThrow(/invalid entry/);
  });

  test("accepts entries with dots in filenames", () => {
    const output = ["stash-v1.0.0/.env.example", "stash-v1.0.0/v2.1.0/notes.md"].join("\n");
    expect(() => validateTarEntries(output)).not.toThrow();
  });

  test("accepts empty output without throwing", () => {
    expect(() => validateTarEntries("")).not.toThrow();
    expect(() => validateTarEntries("\n\n")).not.toThrow();
  });
});
