import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { DeployWatcher, type DeployStatusFile } from "./deploy-watcher";
import { ControllerStore } from "./store";

let dir = "";
let store: ControllerStore;

const SHA_A = "a".repeat(40);
const SHA_B = "b".repeat(40);
const SHA_C = "c".repeat(40);

beforeEach(() => {
  dir = mkdtempSync(join(tmpdir(), "deploy-watcher-"));
  store = new ControllerStore(join(dir, "state.sqlite"), { now: () => 1_000_000 });
});

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

function deployDir(servedSha: string | null) {
  const d = join(dir, "deploy");
  mkdirSync(join(d, ".git"), { recursive: true });
  if (servedSha) writeFileSync(join(d, ".git", "harness-deployed-sha"), `${servedSha}\n`);
  return d;
}

function status(partial: Partial<DeployStatusFile>): DeployStatusFile {
  return {
    schema: 1,
    state: "running",
    step: "preparing",
    detail: "",
    sha: "",
    pid: 1,
    started_at: 1,
    updated_at: 1,
    ...partial,
  };
}

describe("DeployWatcher.tick", () => {
  test("does nothing when the deploy clone is missing", async () => {
    let enqueued = false;
    const watcher = new DeployWatcher(store, {
      deployDir: join(dir, "no-such-clone"),
      repoRoot: dir,
      fetchMain: () => { throw new Error("must not be called"); },
      enqueueDeploy: () => { enqueued = true; },
      readDeployStatus: () => null,
    });
    await watcher.tick();
    expect(enqueued).toBe(false);
  });

  test("does nothing when served sha already matches origin/main", async () => {
    const d = deployDir(SHA_A);
    let enqueued = false;
    const watcher = new DeployWatcher(store, {
      deployDir: d,
      repoRoot: dir,
      fetchMain: () => true,
      gitRev: () => SHA_A,
      enqueueDeploy: () => { enqueued = true; },
      readDeployStatus: () => null,
    });
    await watcher.tick();
    expect(enqueued).toBe(false);
    expect(store.getDeployWatcherState()).toBeNull();
  });

  test("enqueues (never runs the deploy itself) when served sha falls behind origin/main", async () => {
    const d = deployDir(SHA_A);
    let enqueuedRepoRoot = "";
    const watcher = new DeployWatcher(store, {
      deployDir: d,
      repoRoot: dir,
      fetchMain: () => true,
      gitRev: () => SHA_B,
      enqueueDeploy: (repoRoot) => { enqueuedRepoRoot = repoRoot; },
      readDeployStatus: () => null,
    });
    await watcher.tick();
    expect(enqueuedRepoRoot).toBe(dir);
    expect(store.getDeployWatcherState()).toMatchObject({
      targetSha: SHA_B,
      attempts: 0,
      lastStatus: "deploy-requested",
      lastOk: true,
      failureClass: "none",
    });
  });

  test("persists the initial request so repeated ticks do not flood the queue", async () => {
    const d = deployDir(SHA_A);
    let enqueueCalls = 0;
    const watcher = new DeployWatcher(store, {
      deployDir: d,
      repoRoot: dir,
      fetchMain: () => true,
      gitRev: () => SHA_B,
      enqueueDeploy: () => { enqueueCalls += 1; },
      readDeployStatus: () => null,
      now: () => 1_000,
    });
    await watcher.tick();
    await watcher.tick();
    await new DeployWatcher(store, {
      deployDir: d,
      repoRoot: dir,
      fetchMain: () => true,
      gitRev: () => SHA_B,
      enqueueDeploy: () => { enqueueCalls += 1; },
      readDeployStatus: () => null,
      now: () => 2_000,
    }).tick();
    expect(enqueueCalls).toBe(1);
  });

  test("never fetches a second time or re-implements the lock/queue — only enqueues", async () => {
    const d = deployDir(SHA_A);
    let fetchCalls = 0;
    let enqueueCalls = 0;
    const watcher = new DeployWatcher(store, {
      deployDir: d,
      repoRoot: dir,
      fetchMain: () => { fetchCalls += 1; return true; },
      gitRev: () => SHA_B,
      enqueueDeploy: () => { enqueueCalls += 1; },
      readDeployStatus: () => null,
    });
    await watcher.tick();
    expect(fetchCalls).toBe(1);
    expect(enqueueCalls).toBe(1);
  });

  test("does not enqueue again while a deploy is in flight (state running)", async () => {
    const d = deployDir(SHA_A);
    let enqueued = false;
    const watcher = new DeployWatcher(store, {
      deployDir: d,
      repoRoot: dir,
      fetchMain: () => true,
      gitRev: () => SHA_B,
      enqueueDeploy: () => { enqueued = true; },
      readDeployStatus: () => status({ state: "running", step: "building-web", sha: SHA_A.slice(0, 7) }),
    });
    await watcher.tick();
    expect(enqueued).toBe(false);
    expect(store.getDeployWatcherState()).toBeNull();
  });

  test("records a successful terminal run for the target sha and does not re-enqueue that tick", async () => {
    const d = deployDir(SHA_A); // stamp not yet caught up on this tick
    let enqueued = false;
    const watcher = new DeployWatcher(store, {
      deployDir: d,
      repoRoot: dir,
      fetchMain: () => true,
      gitRev: () => SHA_B,
      enqueueDeploy: () => { enqueued = true; },
      readDeployStatus: () => status({ state: "finished", step: "deployed", sha: SHA_B.slice(0, 7), detail: "deployed", updated_at: 42 }),
    });
    await watcher.tick();
    expect(enqueued).toBe(false);
    expect(store.getDeployWatcherState()).toEqual({
      targetSha: SHA_B,
      attempts: 1,
      lastStatus: "deployed",
      lastDetail: "deployed",
      lastAt: new Date(42_000).toISOString(),
      lastOk: true,
      failureClass: "none",
      nextRetryAt: null,
    });
  });

  test("records a terminal run even when its second-aligned timestamp matches the request", async () => {
    const d = deployDir(SHA_A);
    store.recordDeployWatcherResult({
      targetSha: SHA_B,
      attempts: 0,
      lastStatus: "deploy-requested",
      lastDetail: "waiting for the standing deployer",
      lastAt: new Date(42_000).toISOString(),
      lastOk: true,
      failureClass: "none",
      nextRetryAt: null,
    });
    const watcher = new DeployWatcher(store, {
      deployDir: d,
      repoRoot: dir,
      fetchMain: () => true,
      gitRev: () => SHA_B,
      enqueueDeploy: () => { throw new Error("must not enqueue"); },
      readDeployStatus: () => status({
        schema: 2,
        state: "failed",
        step: "fetch-failed",
        detail: "network unavailable",
        target_sha: SHA_B,
        failure_class: "transient",
        updated_at: 42,
      }),
      now: () => 42_000,
    });
    await watcher.tick();
    expect(store.getDeployWatcherState()).toMatchObject({
      attempts: 1,
      lastStatus: "fetch-failed",
      lastOk: false,
      failureClass: "transient",
    });
  });

  test("a coalesced finished run records deployed-coalesced and is never a failure", async () => {
    const d = deployDir(SHA_A);
    let enqueueCalls = 0;
    const watcher = new DeployWatcher(store, {
      deployDir: d,
      repoRoot: dir,
      fetchMain: () => true,
      gitRev: () => SHA_B,
      enqueueDeploy: () => { enqueueCalls += 1; },
      readDeployStatus: () => status({ state: "finished", step: "coalesced", sha: SHA_B.slice(0, 7), detail: "another deploy already installed this change", updated_at: 3 }),
    });
    await watcher.tick();
    expect(store.getDeployWatcherState()).toMatchObject({ lastStatus: "deployed-coalesced", lastOk: true, attempts: 1 });
    expect(enqueueCalls).toBe(0); // "finished" this tick — the stamp catches up next tick, nothing to enqueue
  });

  test("a deploy-lock-timeout run is recorded as ok, not a failure, and does not consume a retry attempt", async () => {
    const d = deployDir(SHA_A);
    const watcher = new DeployWatcher(store, {
      deployDir: d,
      repoRoot: dir,
      fetchMain: () => true,
      gitRev: () => SHA_B,
      enqueueDeploy: () => {},
      readDeployStatus: () => status({ state: "failed", step: "deploy-lock-timeout", sha: SHA_B.slice(0, 7), detail: "could not acquire the deploy lock", updated_at: 4 }),
      maxAttemptsPerSha: 1,
    });
    await watcher.tick();
    expect(store.getDeployWatcherState()).toMatchObject({ lastStatus: "deploy-lock-timeout", lastOk: true, attempts: 1 });
  });

  test("a docs-only finished run records deployed-docs-only", async () => {
    const d = deployDir(SHA_A);
    const watcher = new DeployWatcher(store, {
      deployDir: d,
      repoRoot: dir,
      fetchMain: () => true,
      gitRev: () => SHA_B,
      enqueueDeploy: () => {},
      readDeployStatus: () => status({ state: "finished", step: "docs-only", sha: SHA_B.slice(0, 7), detail: "documentation-only landing: nothing to install", updated_at: 7 }),
    });
    await watcher.tick();
    expect(store.getDeployWatcherState()).toMatchObject({ lastStatus: "deployed-docs-only", lastOk: true });
  });

  test("never attributes a schema-2 terminal record to a different full target", async () => {
    const d = deployDir(SHA_A);
    let target = "";
    const watcher = new DeployWatcher(store, {
      deployDir: d,
      repoRoot: dir,
      fetchMain: () => true,
      gitRev: () => SHA_B,
      enqueueDeploy: (_repoRoot, targetSha) => { target = targetSha; },
      readDeployStatus: () => status({
        schema: 2,
        state: "failed",
        step: "fetch-failed",
        sha: SHA_B.slice(0, 7),
        target_sha: SHA_C,
        failure_class: "transient",
        updated_at: 9,
      }),
    });
    await watcher.tick();
    expect(target).toBe(SHA_B);
    expect(store.getDeployWatcherState()).toMatchObject({ targetSha: SHA_B, lastStatus: "deploy-requested" });
  });

  test("legacy failures default permanent and do not retry", async () => {
    const d = deployDir(SHA_A);
    let enqueueCalls = 0;
    const watcher = new DeployWatcher(store, {
      deployDir: d,
      repoRoot: dir,
      fetchMain: () => true,
      gitRev: () => SHA_B,
      enqueueDeploy: () => { enqueueCalls += 1; },
      readDeployStatus: () => status({ state: "failed", step: "deploy-clone-dirty", sha: SHA_B.slice(0, 7), detail: "dirty", updated_at: 5 }),
      now: () => 0,
    });
    await watcher.tick();
    await watcher.tick();
    expect(store.getDeployWatcherState()).toMatchObject({
      attempts: 1,
      lastOk: false,
      failureClass: "permanent",
      nextRetryAt: null,
    });
    expect(enqueueCalls).toBe(0);
  });

  test("legacy stored failures with the migrated none default read back as permanent", () => {
    store.recordDeployWatcherResult({
      targetSha: SHA_B,
      attempts: 1,
      lastStatus: "deploy-clone-dirty",
      lastDetail: "dirty",
      lastAt: new Date(1_000).toISOString(),
      lastOk: false,
      failureClass: "none",
      nextRetryAt: null,
    });
    expect(store.getDeployWatcherState()).toMatchObject({
      lastOk: false,
      failureClass: "permanent",
      nextRetryAt: null,
    });
  });

  test("schema-2 transient failures retry only when due and advance the persisted window", async () => {
    const d = deployDir(SHA_A);
    let now = 1_000_000;
    let enqueueCalls = 0;
    let enqueuedTarget = "";
    let retryAtDuringEnqueue: string | null | undefined;
    const failed = status({
      schema: 2,
      state: "failed",
      step: "fetch-failed",
      sha: SHA_A.slice(0, 7),
      target_sha: SHA_B,
      failure_class: "transient",
      detail: "network unavailable",
      updated_at: 9,
    });
    const options = {
      deployDir: d,
      repoRoot: dir,
      fetchMain: () => true,
      gitRev: () => SHA_B,
      enqueueDeploy: (_repoRoot: string, targetSha: string) => {
        enqueueCalls += 1;
        enqueuedTarget = targetSha;
        retryAtDuringEnqueue = store.getDeployWatcherState()?.nextRetryAt;
      },
      readDeployStatus: () => failed,
      now: () => now,
    };
    const watcher = new DeployWatcher(store, options);

    await watcher.tick();
    expect(enqueueCalls).toBe(0);
    expect(store.getDeployWatcherState()).toMatchObject({
      targetSha: SHA_B,
      attempts: 1,
      failureClass: "transient",
      nextRetryAt: new Date(1_060_000).toISOString(),
    });

    now = 1_059_999;
    await watcher.tick();
    expect(enqueueCalls).toBe(0);
    now = 1_060_000;
    await watcher.tick();
    expect(enqueueCalls).toBe(1);
    expect(enqueuedTarget).toBe(SHA_B);
    expect(retryAtDuringEnqueue).toBe(new Date(1_360_000).toISOString());
    expect(store.getDeployWatcherState()?.nextRetryAt).toBe(new Date(1_360_000).toISOString());

    now = 1_100_000;
    const reconstructed = new DeployWatcher(store, options);
    await reconstructed.tick();
    expect(enqueueCalls).toBe(1);
  });

  test("distinct transient failures use bounded increasing delays and exhaust at the cap", async () => {
    const d = deployDir(SHA_A);
    let now = 0;
    let updatedAt = 1;
    let enqueueCalls = 0;
    const watcher = new DeployWatcher(store, {
      deployDir: d,
      repoRoot: dir,
      fetchMain: () => true,
      gitRev: () => SHA_B,
      enqueueDeploy: () => { enqueueCalls += 1; },
      readDeployStatus: () => status({
        schema: 2,
        state: "failed",
        step: "fetch-failed",
        target_sha: SHA_B,
        failure_class: "transient",
        detail: "network unavailable",
        updated_at: updatedAt,
      }),
      now: () => now,
      maxAttemptsPerSha: 3,
    });

    await watcher.tick();
    expect(store.getDeployWatcherState()?.nextRetryAt).toBe(new Date(60_000).toISOString());
    now = 60_000;
    await watcher.tick();
    expect(enqueueCalls).toBe(1);
    expect(store.getDeployWatcherState()?.nextRetryAt).toBe(new Date(360_000).toISOString());

    updatedAt = 2;
    await watcher.tick();
    expect(store.getDeployWatcherState()).toMatchObject({
      attempts: 2,
      nextRetryAt: new Date(360_000).toISOString(),
    });
    now = 360_000;
    await watcher.tick();
    expect(enqueueCalls).toBe(2);
    expect(store.getDeployWatcherState()?.nextRetryAt).toBe(new Date(1_260_000).toISOString());

    updatedAt = 3;
    await watcher.tick();
    expect(store.getDeployWatcherState()).toMatchObject({ attempts: 3, nextRetryAt: null });
    now = 2_000_000;
    await watcher.tick();
    expect(enqueueCalls).toBe(2);
  });

  test("a new target resets stopped state and forwards the exact full sha", async () => {
    const d = deployDir(SHA_A);
    store.recordDeployWatcherResult({
      targetSha: SHA_B,
      attempts: 1,
      lastStatus: "deploy-clone-dirty",
      lastDetail: "dirty",
      lastAt: new Date(1_000).toISOString(),
      lastOk: false,
      failureClass: "permanent",
      nextRetryAt: null,
    });
    let enqueuedTarget = "";
    const watcher = new DeployWatcher(store, {
      deployDir: d,
      repoRoot: dir,
      fetchMain: () => true,
      gitRev: () => SHA_C,
      enqueueDeploy: (_repoRoot, targetSha) => { enqueuedTarget = targetSha; },
      readDeployStatus: () => null,
    });
    await watcher.tick();
    expect(enqueuedTarget).toBe(SHA_C);
  });

  test("a later successful run supersedes permanent stopped state", async () => {
    const d = deployDir(SHA_A);
    store.recordDeployWatcherResult({
      targetSha: SHA_B,
      attempts: 1,
      lastStatus: "deploy-clone-dirty",
      lastDetail: "dirty",
      lastAt: new Date(1_000).toISOString(),
      lastOk: false,
      failureClass: "permanent",
      nextRetryAt: null,
    });
    const watcher = new DeployWatcher(store, {
      deployDir: d,
      repoRoot: dir,
      fetchMain: () => true,
      gitRev: () => SHA_B,
      enqueueDeploy: () => { throw new Error("must not enqueue"); },
      readDeployStatus: () => status({
        schema: 2,
        state: "finished",
        step: "deployed",
        target_sha: SHA_B,
        failure_class: "none",
        detail: "deployed",
        updated_at: 2,
      }),
    });
    await watcher.tick();
    expect(store.getDeployWatcherState()).toMatchObject({
      targetSha: SHA_B,
      attempts: 1,
      lastOk: true,
      failureClass: "none",
      nextRetryAt: null,
    });
  });

  test("is re-entrancy-safe: a tick already running skips a concurrent tick", async () => {
    const d = deployDir(SHA_A);
    let inFlight = 0;
    let maxConcurrent = 0;
    let enqueueCalls = 0;
    const watcher = new DeployWatcher(store, {
      deployDir: d,
      repoRoot: dir,
      fetchMain: () => {
        inFlight += 1;
        maxConcurrent = Math.max(maxConcurrent, inFlight);
        return true;
      },
      gitRev: () => { inFlight -= 1; return SHA_B; },
      enqueueDeploy: () => { enqueueCalls += 1; },
      readDeployStatus: () => null,
    });
    await Promise.all([watcher.tick(), watcher.tick()]);
    expect(maxConcurrent).toBe(1);
    expect(enqueueCalls).toBe(1);
  });

  test("reports a failed fetch without enqueueing and without recording a false sha comparison", async () => {
    const d = deployDir(SHA_A);
    let enqueued = false;
    const logs: string[] = [];
    const watcher = new DeployWatcher(store, {
      deployDir: d,
      repoRoot: dir,
      fetchMain: () => false,
      gitRev: () => { throw new Error("must not resolve a sha after a failed fetch"); },
      enqueueDeploy: () => { enqueued = true; },
      readDeployStatus: () => null,
      log: (line) => logs.push(line),
    });
    await watcher.tick();
    expect(enqueued).toBe(false);
    expect(store.getDeployWatcherState()).toBeNull();
    expect(logs.some((l) => l.includes("fetch of origin/main failed"))).toBe(true);
  });
});
