import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import {
  existsSync,
  chmodSync,
  lstatSync,
  mkdtempSync,
  mkdirSync,
  readFileSync,
  readdirSync,
  rmSync,
  writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { startServer } from "./server";
import { buildControllerStatus } from "./status";
import { ControllerStore } from "./store";
import {
  RemoteJobLimitError,
  SimulatedPromotionCrash,
  TransportInterruptedError,
  TransportReattachError,
  WorkspaceManager,
  digestFile,
  digestTree,
} from "./workspace";

describe("WorkspaceManager", () => {
  let root: string;
  let dbPath: string;
  let store: ControllerStore;
  let now: number;
  let generations: Map<string, string>;

  beforeEach(() => {
    root = mkdtempSync(join(tmpdir(), "controller-workspace-"));
    dbPath = join(root, "state.sqlite");
    now = 1_700_000_000_000;
    generations = new Map();
    store = new ControllerStore(dbPath, { now: () => now });
  });

  afterEach(() => {
    store.close();
    makeWritable(root);
    rmSync(root, { recursive: true, force: true });
  });

  function makeWritable(path: string): void {
    if (!existsSync(path)) return;
    const stat = lstatSync(path);
    if (stat.isSymbolicLink() || !stat.isDirectory()) return;
    chmodSync(path, 0o755);
    for (const entry of readdirSync(path)) makeWritable(join(path, entry));
  }

  function checkout(name: string, source: string): string {
    const path = join(root, name);
    mkdirSync(path, { recursive: true });
    writeFileSync(join(path, "source.txt"), source);
    generations.set(path, "generation-1");
    return path;
  }

  function manager(options: { crashAfterBackup?: boolean; crashAfterSwap?: boolean } = {}): WorkspaceManager {
    return new WorkspaceManager(store, {
      rootDir: join(root, "remote"),
      now: () => now,
      readCheckoutGeneration: (path) => generations.get(path) ?? "missing",
      crashAfterBackup: options.crashAfterBackup,
      crashAfterSwap: options.crashAfterSwap,
    });
  }

  function createJob(
    workspaces: WorkspaceManager,
    id: string,
    checkoutPath: string,
    maxRemoteJobs = 8,
  ) {
    return workspaces.create({
      id,
      repo: "same/repo",
      host: "builder-1",
      checkoutPath,
      publicationPath: join(checkoutPath, "dist"),
      snapshot: digestTree(checkoutPath),
      checkoutGeneration: generations.get(checkoutPath)!,
      maxRemoteJobs,
    });
  }

  function writeArtifact(
    workspace: ReturnType<WorkspaceManager["create"]>,
    contents: string,
  ) {
    writeFileSync(join(workspace.outputPath, "bundle.js"), contents);
    return {
      files: [
        {
          source: "bundle.js",
          target: "bundle.js",
          digest: digestFile(join(workspace.outputPath, "bundle.js")),
        },
      ],
    };
  }

  test("isolates concurrent dirty worktrees of one repo", async () => {
    const firstCheckout = checkout("dirty-a", "alpha-dirty");
    const secondCheckout = checkout("dirty-b", "beta-dirty");
    const workspaces = manager();

    const [first, second] = await Promise.all([
      Promise.resolve().then(() => createJob(workspaces, "job-a", firstCheckout)),
      Promise.resolve().then(() => createJob(workspaces, "job-b", secondCheckout)),
    ]);
    expect(first.workspacePath).not.toBe(second.workspacePath);
    expect(first.cachePath).toBe(second.cachePath);
    expect(first.overlayPath).not.toBe(second.overlayPath);
    expect(first.outputPath).not.toBe(second.outputPath);
    expect(readFileSync(join(first.snapshotPath, "source.txt"), "utf8")).toBe("alpha-dirty");
    expect(readFileSync(join(second.snapshotPath, "source.txt"), "utf8")).toBe("beta-dirty");

    workspaces.stage("job-a", writeArtifact(first, "artifact-a"));
    workspaces.stage("job-b", writeArtifact(second, "artifact-b"));
    expect(workspaces.promote("job-a")).toEqual({ state: "promoted" });
    expect(workspaces.promote("job-b")).toEqual({ state: "promoted" });
    expect(readFileSync(join(firstCheckout, "dist", "bundle.js"), "utf8")).toBe("artifact-a");
    expect(readFileSync(join(secondCheckout, "dist", "bundle.js"), "utf8")).toBe("artifact-b");
  });

  test("refuses stale publication, preserves staging and emits act event", () => {
    const checkoutPath = checkout("stale", "source-before");
    const workspaces = manager();
    const workspace = createJob(workspaces, "job-stale", checkoutPath);
    const sourceBefore = readFileSync(join(checkoutPath, "source.txt"));
    workspaces.stage("job-stale", writeArtifact(workspace, "stale-artifact"));

    writeFileSync(join(checkoutPath, "source.txt"), "source-edited-mid-build");
    generations.set(checkoutPath, "generation-2");
    const result = workspaces.promote("job-stale");

    expect(result.state).toBe("blocked");
    expect(existsSync(join(workspace.stagingPath, "bundle.js"))).toBe(true);
    expect(existsSync(join(checkoutPath, "dist"))).toBe(false);
    expect(sourceBefore.equals(Buffer.from("source-before"))).toBe(true);
    expect(readFileSync(join(checkoutPath, "source.txt"), "utf8")).toBe("source-edited-mid-build");
    const event = store.readEvents().at(-1)?.event;
    expect(event?.reason).toBe("artifact-publication-blocked");
    expect(event?.stage).toBe("blocked");
    expect(buildControllerStatus(store).jobs[0]?.publication).toEqual({
      state: "blocked",
      reason: "snapshot-and-generation-changed",
    });
  });

  test("requires both snapshot digest and checkout generation to remain unchanged", () => {
    const checkoutPath = checkout("generation", "same-source");
    const workspaces = manager();
    const workspace = createJob(workspaces, "job-generation", checkoutPath);
    workspaces.stage("job-generation", writeArtifact(workspace, "artifact"));

    generations.set(checkoutPath, "generation-2");
    expect(workspaces.promote("job-generation")).toEqual({
      state: "blocked",
      reason: "checkout-generation-changed",
    });
    expect(existsSync(join(workspace.stagingPath, "bundle.js"))).toBe(true);
  });

  test("default generation check ignores controller staging paths", () => {
    const checkoutPath = checkout("default-generation", "source");
    const snapshot = digestTree(checkoutPath);
    const workspaces = new WorkspaceManager(store, { rootDir: join(root, "remote") });
    const workspace = workspaces.create({
      id: "job-default-generation",
      repo: "same/repo",
      host: "builder-1",
      checkoutPath,
      publicationPath: join(checkoutPath, "dist"),
      snapshot,
      checkoutGeneration: snapshot,
      maxRemoteJobs: 2,
    });
    workspaces.stage("job-default-generation", writeArtifact(workspace, "artifact"));

    expect(workspaces.promote("job-default-generation")).toEqual({ state: "promoted" });
  });

  test("recovers a crash during promotion to a fully staged state", () => {
    const checkoutPath = checkout("crash", "source");
    mkdirSync(join(checkoutPath, "dist"));
    writeFileSync(join(checkoutPath, "dist", "bundle.js"), "old-artifact");
    const crashing = manager({ crashAfterBackup: true });
    const workspace = createJob(crashing, "job-crash", checkoutPath);
    crashing.stage("job-crash", writeArtifact(workspace, "new-artifact"));

    expect(() => crashing.promote("job-crash")).toThrow(SimulatedPromotionCrash);
    const recovered = manager();
    const record = store.getWorkspace("job-crash");

    expect(record?.publicationState).toBe("staged");
    expect(readFileSync(join(checkoutPath, "dist", "bundle.js"), "utf8")).toBe("old-artifact");
    expect(readFileSync(join(workspace.stagingPath, "bundle.js"), "utf8")).toBe("new-artifact");
    expect(recovered.promote("job-crash")).toEqual({ state: "promoted" });
    expect(readFileSync(join(checkoutPath, "dist", "bundle.js"), "utf8")).toBe("new-artifact");
  });

  test("recovers a crash after the atomic swap to a fully promoted state", () => {
    const checkoutPath = checkout("crash-after-swap", "source");
    mkdirSync(join(checkoutPath, "dist"));
    writeFileSync(join(checkoutPath, "dist", "bundle.js"), "old-artifact");
    const crashing = manager({ crashAfterSwap: true });
    const workspace = createJob(crashing, "job-crash-after-swap", checkoutPath);
    crashing.stage("job-crash-after-swap", writeArtifact(workspace, "new-artifact"));

    expect(() => crashing.promote("job-crash-after-swap")).toThrow(SimulatedPromotionCrash);
    manager();

    expect(store.getWorkspace("job-crash-after-swap")?.publicationState).toBe("promoted");
    expect(existsSync(workspace.stagingPath)).toBe(false);
    expect(existsSync(workspace.backupPath)).toBe(false);
    expect(readFileSync(join(checkoutPath, "dist", "bundle.js"), "utf8")).toBe("new-artifact");
  });

  test("reattaches once after transport interruption", async () => {
    const checkoutPath = checkout("transport", "source");
    const workspaces = manager();
    createJob(workspaces, "job-transport", checkoutPath);
    const calls: string[] = [];

    const result = await workspaces.withTransport("job-transport", async (mode) => {
      calls.push(mode);
      if (mode === "attach") throw new TransportInterruptedError();
      return "resumed";
    });

    expect(result).toBe("resumed");
    expect(calls).toEqual(["attach", "reattach"]);
    expect(store.getWorkspace("job-transport")?.transportReattachCount).toBe(1);
  });

  test("fails loud after the single reattach also disconnects", async () => {
    const checkoutPath = checkout("transport-fail", "source");
    const workspaces = manager();
    createJob(workspaces, "job-transport-fail", checkoutPath);
    let calls = 0;

    await expect(
      workspaces.withTransport("job-transport-fail", async () => {
        calls += 1;
        throw new TransportInterruptedError();
      }),
    ).rejects.toBeInstanceOf(TransportReattachError);
    expect(calls).toBe(2);
    expect(store.getWorkspace("job-transport-fail")?.stage).toBe("failed");

    await expect(
      workspaces.withTransport("job-transport-fail", async () => {
        calls += 1;
        return "must-not-run";
      }),
    ).rejects.toBeInstanceOf(TransportReattachError);
    expect(calls).toBe(2);
  });

  test("garbage-collects completed workspaces after TTL using injected clock", () => {
    const checkoutPath = checkout("gc", "source");
    const workspaces = manager();
    const workspace = createJob(workspaces, "job-gc", checkoutPath);
    workspaces.stage("job-gc", writeArtifact(workspace, "artifact"));
    workspaces.promote("job-gc");

    now += 59_999;
    expect(workspaces.gc(60_000)).toEqual([]);
    expect(existsSync(workspace.workspacePath)).toBe(true);
    now += 1;
    expect(workspaces.gc(60_000)).toEqual(["job-gc"]);
    expect(existsSync(workspace.workspacePath)).toBe(false);
    expect(store.getWorkspace("job-gc")).toBeNull();
  });

  test("atomically reserves the global remote-job limit", async () => {
    const workspaces = manager();
    const attempts = await Promise.all(
      Array.from({ length: 16 }, (_, index) =>
        Promise.resolve().then(() => {
          const checkoutPath = checkout(`reservation-${index}`, `source-${index}`);
          try {
            createJob(workspaces, `job-${index}`, checkoutPath, 3);
            return true;
          } catch (error) {
            expect(error).toBeInstanceOf(RemoteJobLimitError);
            return false;
          }
        }),
      ),
    );

    expect(attempts.filter(Boolean)).toHaveLength(3);
    expect(store.countRemoteJobReservations()).toBe(3);
  });

  test("wires authenticated create, stage and promote routes", async () => {
    const checkoutPath = checkout("route", "route-source");
    const workspaces = manager();
    const server = startServer({
      host: "127.0.0.1",
      port: 0,
      token: "workspace-token",
      store,
      workspace: workspaces,
    });
    const request = (path: string, body: unknown) =>
      fetch(`http://127.0.0.1:${server.port}${path}`, {
        method: "POST",
        headers: {
          authorization: "Bearer workspace-token",
          "content-type": "application/json",
        },
        body: JSON.stringify(body),
      });

    try {
      const createdResponse = await request("/workspace/create", {
        id: "job-route",
        repo: "same/repo",
        host: "builder-1",
        checkoutPath,
        publicationPath: join(checkoutPath, "dist"),
        snapshot: digestTree(checkoutPath),
        checkoutGeneration: "generation-1",
        maxRemoteJobs: 2,
      });
      expect(createdResponse.status).toBe(201);
      const created = (await createdResponse.json()) as { outputPath: string };
      writeFileSync(join(created.outputPath, "bundle.js"), "route-artifact");
      const manifest = {
        files: [{
          source: "bundle.js",
          target: "bundle.js",
          digest: digestFile(join(created.outputPath, "bundle.js")),
        }],
      };

      expect((await request("/workspace/job-route/stage", manifest)).status).toBe(200);
      const promoted = await request("/workspace/job-route/promote", {});
      expect(promoted.status).toBe(200);
      expect(await promoted.json()).toEqual({ state: "promoted" });
      expect(readFileSync(join(checkoutPath, "dist", "bundle.js"), "utf8")).toBe(
        "route-artifact",
      );
    } finally {
      server.stop(true);
    }
  });
});
