import { afterAll, describe, expect, spyOn, test } from "bun:test";
import {
  mkdirSync,
  mkdtempSync,
  readdirSync,
  readFileSync,
  writeFileSync,
  existsSync,
  rmSync,
} from "node:fs";
import { join } from "node:path";
import { loadAuthzCatalog, runAuthzCommand } from "../src/authz.ts";

// Declarations under app/authz/ import "@wrnexus/authz" with a bare specifier,
// which Bun resolves via the root tsconfig.json `paths` map by walking up from
// the imported file's directory. os.tmpdir() lives outside the repo tree (often
// on a different drive on Windows), so that walk never reaches the root
// tsconfig.json and the dynamic import fails with "Cannot find module
// '@wrnexus/authz'". Scaffolding under this test file's own directory keeps the
// walk-up inside the repo, exactly like a real app (which has its own
// node_modules/tsconfig with @wrnexus/authz installed).
const scratchRoot = join(import.meta.dir, ".tmp-authz-cli");
const createdRoots: string[] = [];

function scaffold(): string {
  mkdirSync(scratchRoot, { recursive: true });
  const root = mkdtempSync(join(scratchRoot, "run-"));
  createdRoots.push(root);
  mkdirSync(join(root, "app", "authz"), { recursive: true });
  mkdirSync(join(root, "app", "pages"), { recursive: true });
  writeFileSync(
    join(root, "app", "authz", "blog.ts"),
    `import { defineAuthz } from "@wrnexus/authz";
export default defineAuthz({
  permissions: { "post:read": { title: "View posts" }, "post:write": {} },
  roles: { editor: ["post:*"] },
});
`,
    "utf8",
  );
  return root;
}

afterAll(() => {
  rmSync(scratchRoot, { recursive: true, force: true });
});

/**
 * authz.ts's `fail()` helper (usage errors, bad --dialect) mirrors db.ts's
 * convention: console.error + process.exit(1), never throw — so index.ts's
 * generic `main().catch(err) { console.error(err); process.exit(1); }` (which
 * prints the raw Error, stack and all) never sees an expected validation
 * failure. That means a *direct* call to runAuthzCommand() would normally kill
 * the whole test worker via a real process.exit(); intercept both console.error
 * and process.exit so the failure path stays testable in-process.
 */
async function expectCleanFailure(run: () => Promise<void>): Promise<string> {
  const errors: string[] = [];
  const originalError = console.error;
  console.error = (...args: unknown[]) => void errors.push(args.join(" "));
  const exitSpy = spyOn(process, "exit").mockImplementation(((code?: number) => {
    throw new Error(`__process_exit_${code}__`);
  }) as never);
  try {
    await expect(run()).rejects.toThrow(/^__process_exit_1__$/);
  } finally {
    console.error = originalError;
    exitSpy.mockRestore();
  }
  return errors.join("\n");
}

describe("wrnexus authz", () => {
  test("loadAuthzCatalog merges every declaration", async () => {
    const catalog = await loadAuthzCatalog(join(scaffold(), "app"));
    expect([...catalog.permissions.keys()].sort()).toEqual(["post:read", "post:write"]);
    expect([...catalog.roles.keys()]).toEqual(["editor"]);
  });

  test("generate writes the permission types file", async () => {
    const root = scaffold();
    await runAuthzCommand(root, "generate", []);
    const generated = readFileSync(join(root, "app", "authz", "permissions.gen.ts"), "utf8");
    expect(generated).toContain('export type Permission = "post:read" | "post:write";');
  });

  test("init writes a migration containing both tables", async () => {
    const root = scaffold();
    mkdirSync(join(root, "app", "db", "migrations"), { recursive: true });
    await runAuthzCommand(root, "init", []);
    const dir = join(root, "app", "db", "migrations");
    const file = readdirSync(dir).find((name: string) => name.includes("authz"));
    expect(file).toBeDefined();
    const sql = readFileSync(join(dir, file!), "utf8");
    expect(sql).toContain("_wrn_authz_assignment");
    expect(sql).toContain("_wrn_authz_grant");
    expect(sql).toContain("-- +down");
  });

  test("list prints every permission and role", async () => {
    const root = scaffold();
    const lines: string[] = [];
    const original = console.log;
    console.log = (...args: unknown[]) => void lines.push(args.join(" "));
    try {
      await runAuthzCommand(root, "list", []);
    } finally {
      console.log = original;
    }
    const output = lines.join("\n");
    expect(output).toContain("post:read");
    expect(output).toContain("editor");
  });

  test("an unknown subcommand prints usage and exits 1, not a thrown error", async () => {
    const errorOutput = await expectCleanFailure(() => runAuthzCommand(scaffold(), "bogus", []));
    expect(errorOutput).toMatch(/usage/i);
  });

  test("a missing subcommand prints usage and exits 1", async () => {
    const errorOutput = await expectCleanFailure(() => runAuthzCommand(scaffold(), undefined, []));
    expect(errorOutput).toMatch(/usage/i);
  });

  test("CLI subprocess: unknown subcommand prints usage without a stack trace", async () => {
    const cliEntry = join(import.meta.dir, "..", "src", "index.ts");
    const root = scaffold();
    const proc = Bun.spawn({
      cmd: ["bun", cliEntry, "authz", "bogus"],
      cwd: root,
      env: { ...process.env, WRNEXUS_NO_UPDATE_CHECK: "1" },
      stdout: "pipe",
      stderr: "pipe",
    });
    const [stderr] = await Promise.all([
      new Response(proc.stderr).text(),
      new Response(proc.stdout).text(),
    ]);
    const exitCode = await proc.exited;
    expect(exitCode).not.toBe(0);
    expect(stderr).toMatch(/usage/i);
    // A raw Error/stack trace looks like "at <fn> (file.ts:12:34)"; the clean
    // console.error(usage) + process.exit(1) path never produces that shape.
    expect(stderr).not.toMatch(/at .*\.ts:\d+/);
  }, 15000);

  test("list does not crash on an app with no app/authz directory", async () => {
    mkdirSync(scratchRoot, { recursive: true });
    const root = mkdtempSync(join(scratchRoot, "empty-"));
    createdRoots.push(root);
    mkdirSync(join(root, "app", "pages"), { recursive: true });
    const lines: string[] = [];
    const original = console.log;
    console.log = (...args: unknown[]) => void lines.push(args.join(" "));
    try {
      await runAuthzCommand(root, "list", []);
    } finally {
      console.log = original;
    }
    expect(lines.join("\n")).toContain("Permissions (0)");
  });

  test("loadAuthzCatalog warns and skips a declaration file with no default export", async () => {
    const root = scaffold();
    writeFileSync(join(root, "app", "authz", "empty.ts"), `export const notDefault = 1;\n`, "utf8");
    const warnings: unknown[][] = [];
    const originalWarn = console.warn;
    console.warn = (...args: unknown[]) => void warnings.push(args);
    try {
      const catalog = await loadAuthzCatalog(join(root, "app"));
      expect([...catalog.permissions.keys()].sort()).toEqual(["post:read", "post:write"]);
    } finally {
      console.warn = originalWarn;
    }
    expect(warnings.some((args) => String(args.join(" ")).includes("no default export"))).toBe(
      true,
    );
  });

  test("generate is idempotent when run twice", async () => {
    const root = scaffold();
    await runAuthzCommand(root, "generate", []);
    const first = readFileSync(join(root, "app", "authz", "permissions.gen.ts"), "utf8");
    await runAuthzCommand(root, "generate", []);
    const second = readFileSync(join(root, "app", "authz", "permissions.gen.ts"), "utf8");
    expect(second).toBe(first);
  });

  test("init --dialect=postgres emits postgres DDL", async () => {
    const root = scaffold();
    mkdirSync(join(root, "app", "db", "migrations"), { recursive: true });
    await runAuthzCommand(root, "init", ["--dialect=postgres"]);
    const dir = join(root, "app", "db", "migrations");
    const file = readdirSync(dir).find((name: string) => name.includes("authz"));
    const sql = readFileSync(join(dir, file!), "utf8");
    expect(sql).toContain("SERIAL PRIMARY KEY");
  });

  test("init --dialect=mysql emits mysql DDL", async () => {
    const root = scaffold();
    mkdirSync(join(root, "app", "db", "migrations"), { recursive: true });
    await runAuthzCommand(root, "init", ["--dialect=mysql"]);
    const dir = join(root, "app", "db", "migrations");
    const file = readdirSync(dir).find((name: string) => name.includes("authz"));
    const sql = readFileSync(join(dir, file!), "utf8");
    expect(sql).toContain("AUTO_INCREMENT PRIMARY KEY");
  });

  test("init with an unrecognised --dialect= does not silently fall back to sqlite", async () => {
    const root = scaffold();
    mkdirSync(join(root, "app", "db", "migrations"), { recursive: true });
    const errorOutput = await expectCleanFailure(() =>
      runAuthzCommand(root, "init", ["--dialect=oracle"]),
    );
    expect(errorOutput).toMatch(/dialect/i);
  });

  test("init writes a migration that the migration runner can parse", async () => {
    const { loadMigrations } = await import("@wrnexus/db");
    const root = scaffold();
    const dir = join(root, "app", "db", "migrations");
    mkdirSync(dir, { recursive: true });
    await runAuthzCommand(root, "init", []);
    const migrations = loadMigrations(dir);
    expect(migrations.length).toBe(1);
    const migration = migrations[0]!;
    expect(migration.up).toContain("_wrn_authz_assignment");
    expect(migration.up).toContain("_wrn_authz_grant");
    expect(migration.down).toContain("DROP TABLE IF EXISTS _wrn_authz_grant");
    expect(migration.down).toContain("DROP TABLE IF EXISTS _wrn_authz_assignment");
  });

  test("init numbers the next migration correctly past a 5-digit prefix", async () => {
    // nextMigrationNumber originally sliced the first 4 characters of the
    // filename, which would have parsed "10000_big.sql" as "1000" and reused
    // that number instead of advancing past it. It must match db/migrate.ts's
    // leading-digit regex instead.
    const root = scaffold();
    const dir = join(root, "app", "db", "migrations");
    mkdirSync(dir, { recursive: true });
    writeFileSync(join(dir, "0001_users.sql"), "-- +up\n\n-- +down\n", "utf8");
    writeFileSync(join(dir, "10000_big.sql"), "-- +up\n\n-- +down\n", "utf8");
    await runAuthzCommand(root, "init", []);
    const file = readdirSync(dir).find((name) => name.includes("authz"));
    expect(file).toBe("10001_authz_tables.sql");
  });

  test("generate does not clobber a real declaration file", async () => {
    const root = scaffold();
    await runAuthzCommand(root, "generate", []);
    expect(existsSync(join(root, "app", "authz", "blog.ts"))).toBe(true);
    const original = readFileSync(join(root, "app", "authz", "blog.ts"), "utf8");
    expect(original).toContain("defineAuthz");
  });
});
