import { spawnSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import type { ControllerStore, DeployFailureClass, DeployWatcherStateRecord } from "./store";

/**
 * Second duty on the controller's existing 60s reconcile loop
 * (`reconcileDeliveryLifecycle` in index.ts). Compares the served commit (the deploy
 * clone's `harness-deployed-sha` stamp) against `origin/main`. This is a TRIGGER only —
 * `overdeck-deploy.service` (drained by `overdeck-deploy.path` off the deploy queue) is
 * the single standing consumer that actually runs `packaging/deploy-local.sh`'s
 * lock/build/install; the watcher never runs it itself. When the served commit lags,
 * the watcher ENQUEUES a request (invoking `deploy-local.sh` without the consumer flag,
 * which itself just drops a request file and exits) and reads the outcome later from the
 * shared `deploy-status.json` the consumer's run writes. It exists so no agent has to
 * request a deploy itself: land, then this loop notices and enqueues within one tick.
 */

export interface DeployWatcherOptions {
  /** Absolute path to the deploy clone (`OVERDECK_DEPLOY_DIR`, e.g. ~/.local/share/overdeck/deploy). */
  deployDir: string;
  /** Absolute path to the repo whose packaging/deploy-local.sh enqueues the deploy request. */
  repoRoot: string;
  now?: () => number;
  /** Runs `git -C <deployDir> <args>`, trimmed stdout on rc 0, null otherwise. Never fetches. */
  gitRev?: (args: string[]) => string | null;
  /** Fetches origin/main into the deploy clone. Only network call this class makes. */
  fetchMain?: (deployDir: string) => boolean;
  /**
   * Enqueues a deploy request by invoking `deploy-local.sh` WITHOUT the consumer flag —
   * the script itself just drops a coalescing request file and returns in milliseconds.
   * Injectable for tests.
   */
  enqueueDeploy?: (repoRoot: string, targetSha: string) => boolean | void;
  /** Reads the shared deploy-status.json the standing consumer's run writes. */
  readDeployStatus?: () => DeployStatusFile | null;
  /** Distinct transient failed runs before automatic retries stop. */
  maxAttemptsPerSha?: number;
  /** Delay before each transient retry; the final entry caps later delays. */
  retryBackoffMs?: number[];
  log?: (line: string) => void;
}

/** Shape of `deploy_state()`'s JSON in packaging/deploy-local.sh. `sha` is a SHORT sha and,
 * mid-run (`state === "running"`), still names the PREVIOUS deploy — only trust `sha`
 * once `state` is terminal (`finished`/`failed`). */
export interface DeployStatusFile {
  schema: number;
  state: "running" | "finished" | "failed";
  step: string;
  detail: string;
  sha: string;
  pid: number;
  started_at: number;
  updated_at: number;
  /** Schema 2: immutable full commit selected before admission. */
  target_sha?: string;
  /** Schema 2: terminal failures are explicit; absent legacy failures classify conservatively. */
  failure_class?: DeployFailureClass;
}

const SHA_RE = /^[0-9a-f]{40}$/;
const DEFAULT_MAX_ATTEMPTS = 3;
const DEFAULT_RETRY_BACKOFF_MS = [60_000, 300_000, 900_000] as const;

// deploy-lock-timeout is a "failed"-state deploy_state() call (packaging/deploy-local.sh's
// fail()), but it means another deploy already covers this sha — never a failure. Kept in
// sync with apps/web/src/lib/ci-delivery-data.ts's WATCHER_NON_FAILURE_STATUSES.
const NON_FAILURE_STEPS = new Set(["deploy-lock-timeout"]);

function defaultGitRev(deployDir: string) {
  return (args: string[]): string | null => {
    const result = spawnSync("git", args, {
      cwd: deployDir,
      encoding: "utf8",
      stdio: ["ignore", "pipe", "ignore"],
    });
    if (result.status !== 0 || typeof result.stdout !== "string") return null;
    const trimmed = result.stdout.trim();
    return trimmed.length > 0 ? trimmed : null;
  };
}

function defaultFetchMain(deployDir: string): boolean {
  const result = spawnSync("git", ["-C", deployDir, "fetch", "--quiet", "origin", "main"], {
    stdio: "ignore",
  });
  return result.status === 0;
}

// No consumer env/flag set here, so deploy-local.sh classifies this call as a hand
// caller: it drops a request file into the deploy queue and exits within milliseconds.
// overdeck-deploy.path notices the non-empty queue and starts overdeck-deploy.service
// (which runs with OVERDECK_DEPLOY_CONSUMER=1) — the single standing consumer that does
// the actual lock/build/install. This call is therefore short-lived and safe to run
// synchronously on the controller's own reconcile tick; it is never SIGKILLed by a
// controller restart the way a long-lived deploy child used to be.
function defaultEnqueueDeploy(repoRoot: string, targetSha: string): boolean {
  const result = spawnSync("bash", [join(repoRoot, "packaging", "deploy-local.sh")], {
    cwd: repoRoot,
    env: { ...process.env, OVERDECK_DEPLOY_TARGET_SHA: targetSha },
    stdio: "ignore",
  });
  return result.status === 0;
}

function defaultDeployStatusPath(): string {
  const stateHome = process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state");
  return process.env.OVERDECK_DEPLOY_STATE_FILE ?? join(stateHome, "overdeck", "deploy-status.json");
}

function defaultReadDeployStatus(): DeployStatusFile | null {
  try {
    const raw = readFileSync(defaultDeployStatusPath(), "utf8");
    const parsed = JSON.parse(raw) as Partial<DeployStatusFile>;
    if (
      typeof parsed.state === "string"
      && typeof parsed.sha === "string"
      && typeof parsed.step === "string"
      && typeof parsed.detail === "string"
      && typeof parsed.updated_at === "number"
    ) {
      return parsed as DeployStatusFile;
    }
    return null;
  } catch {
    return null;
  }
}

export class DeployWatcher {
  private readonly deployDir: string;
  private readonly repoRoot: string;
  private readonly gitRev: (args: string[]) => string | null;
  private readonly fetchMain: (deployDir: string) => boolean;
  private readonly enqueueDeploy: (repoRoot: string, targetSha: string) => boolean | void;
  private readonly readDeployStatus: () => DeployStatusFile | null;
  private readonly maxAttemptsPerSha: number;
  private readonly retryBackoffMs: readonly number[];
  private readonly now: () => number;
  private readonly log: (line: string) => void;
  private running = false;

  constructor(private readonly store: ControllerStore, options: DeployWatcherOptions) {
    this.deployDir = options.deployDir;
    this.repoRoot = options.repoRoot;
    this.gitRev = options.gitRev ?? defaultGitRev(this.deployDir);
    this.fetchMain = options.fetchMain ?? defaultFetchMain;
    this.enqueueDeploy = options.enqueueDeploy ?? defaultEnqueueDeploy;
    this.readDeployStatus = options.readDeployStatus ?? defaultReadDeployStatus;
    this.maxAttemptsPerSha = Math.max(1, options.maxAttemptsPerSha ?? DEFAULT_MAX_ATTEMPTS);
    const configuredBackoff = options.retryBackoffMs?.filter((delay) => Number.isFinite(delay) && delay >= 0) ?? [];
    this.retryBackoffMs = configuredBackoff.length ? configuredBackoff : DEFAULT_RETRY_BACKOFF_MS;
    this.now = options.now ?? Date.now;
    this.log = options.log ?? ((line) => process.stderr.write(`${line}\n`));
  }

  private servedSha(): string | null {
    try {
      const stamped = readFileSync(join(this.deployDir, ".git", "harness-deployed-sha"), "utf8").trim();
      return SHA_RE.test(stamped) ? stamped : null;
    } catch {
      return null;
    }
  }

  /** Runs one pass. Re-entrancy-safe like `conductConfiguredRoots` — a tick mid-run is a no-op. */
  async tick(): Promise<void> {
    if (this.running) return;
    this.running = true;
    try {
      // Yield once so a concurrent caller's `tick()` (e.g. two admission-loop passes
      // firing close together) observes `running === true` and skips, rather than both
      // racing through fully-synchronous injected stubs before either sets the flag.
      await Promise.resolve();
      if (!existsSync(this.deployDir)) return;
      if (!this.fetchMain(this.deployDir)) {
        this.log(`deploy-watcher: fetch of origin/main failed in ${this.deployDir}`);
        return;
      }
      const mainSha = this.gitRev(["-C", this.deployDir, "rev-parse", "--verify", "refs/remotes/origin/main"]);
      if (!mainSha || !SHA_RE.test(mainSha)) {
        this.log("deploy-watcher: could not resolve a full origin/main sha — skipping this tick");
        return;
      }

      const prior = this.store.getDeployWatcherState();
      const status = this.readDeployStatus();
      const statusTarget = status?.target_sha && SHA_RE.test(status.target_sha)
        ? status.target_sha
        : null;
      const terminal = status !== null && (status.state === "finished" || status.state === "failed");
      const legacyTargetMatch = terminal && status !== null
        && status.schema === 1
        && statusTarget === null
        && status.sha.length > 0
        && mainSha.startsWith(status.sha);
      const terminalForTarget = terminal && (statusTarget === mainSha || legacyTargetMatch);

      // Record a terminal outcome exactly once. Schema-2 records bind it to a full immutable
      // target; the short-sha comparison exists only for in-flight schema-1 rollout records.
      // Process this before retry-stop checks so a successful manual deploy supersedes a
      // previously exhausted/permanent watcher record for the same target.
      let latest = prior;
      if (terminalForTarget && status) {
        const recordedAt = new Date(status.updated_at * 1000).toISOString();
        const normalizedStatus = status.state === "finished"
          ? (status.step === "docs-only" ? "deployed-docs-only"
            : status.step === "coalesced" ? "deployed-coalesced"
            : "deployed")
          : status.step;
        const alreadyRecorded = prior !== null
          && prior.targetSha === mainSha
          && prior.lastStatus === normalizedStatus
          && prior.lastDetail === status.detail
          && prior.lastAt === recordedAt;
        if (!alreadyRecorded) {
          const finished = status.state === "finished";
          const ok = finished || NON_FAILURE_STEPS.has(status.step);
          const failureClass: DeployFailureClass = ok
            ? "none"
            : status.schema >= 2 && (status.failure_class === "transient" || status.failure_class === "permanent")
              ? status.failure_class
              : "permanent";
          const attempts = prior && prior.targetSha === mainSha
            ? (ok ? Math.max(1, prior.attempts) : prior.attempts + 1)
            : 1;
          const lastStatus = normalizedStatus;
          const retryDelay = this.retryBackoffMs[Math.min(attempts - 1, this.retryBackoffMs.length - 1)]!;
          const nextRetryAt = failureClass === "transient" && attempts < this.maxAttemptsPerSha
            ? new Date(this.now() + retryDelay).toISOString()
            : null;
          latest = {
            targetSha: mainSha,
            attempts,
            lastStatus,
            lastDetail: status.detail,
            lastAt: recordedAt,
            lastOk: ok,
            failureClass,
            nextRetryAt,
          };
          this.store.recordDeployWatcherResult(latest);
          if (!ok) {
            const retry = nextRetryAt ? `; retry scheduled ${nextRetryAt}` : "; automatic retry stopped";
            this.log(`deploy-watcher: deploy to ${mainSha} failed (${status.step}: ${status.detail}); ${failureClass} attempt ${attempts}/${this.maxAttemptsPerSha}${retry}`);
          }
        }
      }

      const servedSha = this.servedSha();
      if (servedSha === mainSha) return;
      if (status?.state === "running") return;
      if (terminalForTarget && (status?.state === "finished" || NON_FAILURE_STEPS.has(status?.step ?? ""))) return;

      if (latest && latest.targetSha === mainSha && !latest.lastOk) {
        if (latest.failureClass === "permanent" || latest.attempts >= this.maxAttemptsPerSha) return;
        if (latest.failureClass === "transient") {
          const retryAt = latest.nextRetryAt ? Date.parse(latest.nextRetryAt) : Number.POSITIVE_INFINITY;
          if (this.now() < retryAt) return;
          const retryDelay = this.retryBackoffMs[Math.min(latest.attempts, this.retryBackoffMs.length - 1)]!;
          const deferred: DeployWatcherStateRecord = {
            ...latest,
            nextRetryAt: new Date(this.now() + retryDelay).toISOString(),
          };
          // Persist the next eligibility boundary before touching the queue. A crash after
          // enqueue must not make the next controller tick enqueue the same retry again.
          this.store.recordDeployWatcherResult(deferred);
          const enqueued = this.enqueueDeploy(this.repoRoot, mainSha);
          if (enqueued === false) {
            this.store.recordDeployWatcherResult(latest);
            this.log(`deploy-watcher: could not enqueue retry for ${mainSha}`);
          }
          return;
        }
      }

      if (latest && latest.targetSha === mainSha && latest.lastStatus === "deploy-requested") return;

      const enqueued = this.enqueueDeploy(this.repoRoot, mainSha);
      if (enqueued === false) {
        this.log(`deploy-watcher: could not enqueue deploy for ${mainSha}`);
        return;
      }
      const requestedAt = new Date(this.now()).toISOString();
      this.store.recordDeployWatcherResult({
        targetSha: mainSha,
        attempts: 0,
        lastStatus: "deploy-requested",
        lastDetail: "waiting for the standing deployer",
        lastAt: requestedAt,
        lastOk: true,
        failureClass: "none",
        nextRetryAt: null,
      });
    } finally {
      this.running = false;
    }
  }
}
