import { lstatSync, readdirSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import { z } from "zod";
import type { Adapter, FetchLike } from "../adapter";
import type { AdapterResult, Panel } from "../schema";

const DEFAULT_INTERVAL_MS = 30_000;
const DEFAULT_DEPLOY_DIR = join(homedir(), ".local", "share", "overdeck", "deploy");
const DEFAULT_QUEUE_DIR = `${DEFAULT_DEPLOY_DIR}-queue`;
const DEFAULT_LOCK_PATH = `${DEFAULT_DEPLOY_DIR}.lock`;
const DEFAULT_CONTROLLER_URL = "http://127.0.0.1:8787";
const SHA_RE = /^[0-9a-f]{40}$/;

const DeployStateSchema = z.enum(["idle", "queued", "running", "stalled", "unknown"]);
export type DeployState = z.infer<typeof DeployStateSchema>;

const QueueRequestSchema = z.object({
  id: z.string().min(1),
  observedAt: z.string().datetime(),
});
export type QueueRequest = z.infer<typeof QueueRequestSchema>;

const DeployStatusSchema = z.object({
  state: DeployStateSchema,
  complete: z.boolean(),
  observedAt: z.string().datetime(),
  queue: z.object({
    depth: z.number().int().nonnegative(),
    oldestAgeMs: z.number().int().nonnegative().nullable(),
    requests: z.array(QueueRequestSchema),
  }).nullable(),
  operation: z.string().min(1).nullable(),
  holder: z.string().min(1).nullable(),
  latestEvent: z.object({
    at: z.string().datetime(),
    type: z.string().min(1),
    detail: z.string().min(1).nullable(),
  }).nullable(),
  latestProgressAt: z.string().datetime().nullable(),
  reason: z.string().min(1).nullable(),
  servedSha: z.string().regex(SHA_RE).nullable(),
  mainSha: z.string().regex(SHA_RE).nullable(),
  commitsBehind: z.number().int().nonnegative().nullable(),
  mainCommitAt: z.string().datetime({ offset: true }).nullable(),
  watcher: z.object({
    targetSha: z.string(),
    attempts: z.number().int().nonnegative(),
    lastStatus: z.string(),
    lastDetail: z.string(),
    lastAt: z.string(),
    lastOk: z.boolean(),
    failureClass: z.enum(["none", "transient", "permanent"]).optional(),
    nextRetryAt: z.string().nullable().optional(),
  }).nullable(),
});
export type DeployStatus = z.infer<typeof DeployStatusSchema>;

interface FileStat {
  mtimeMs: number;
  dev: number;
  ino: number;
  isFile(): boolean;
}

const REQUEST_FILE_NAME = /^req-\d+-\d+$/;

const DEFAULT_PROGRESS_PATH = join(
  process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"),
  "overdeck",
  "deploy-status.json",
);

/** The record `packaging/deploy-local.sh` publishes at each step of a deploy. */
const ProgressRecordBaseSchema = z.object({
  state: z.enum(["running", "finished", "failed"]),
  step: z.string().min(1),
  detail: z.string(),
  sha: z.string(),
  pid: z.number().int().positive(),
  started_at: z.number().int().nonnegative(),
  updated_at: z.number().int().nonnegative(),
});
const ProgressRecordSchema = z.discriminatedUnion("schema", [
  ProgressRecordBaseSchema.extend({ schema: z.literal(1) }),
  ProgressRecordBaseSchema.extend({
    schema: z.literal(2),
    target_sha: z.string().regex(SHA_RE),
    failure_class: z.enum(["none", "transient", "permanent"]),
  }),
]);
type ProgressRecord = z.infer<typeof ProgressRecordSchema>;

export interface DeployStatusAdapterOptions {
  id?: string;
  interval?: number;
  queueDir?: string;
  lockPath?: string;
  deployDir?: string;
  progressPath?: string;
  now?: () => number;
  readdirImpl?: (path: string) => string[];
  readFileImpl?: (path: string) => string;
  lstatImpl?: (path: string) => FileStat;
  /** Runs a read-only `git -C <deployDir> <args>`, trimmed stdout on rc 0, null otherwise. */
  gitRevImpl?: (args: string[]) => string | null;
  /** Controller's own `/status`, source of the S3 deploy watcher's retry-stop state (S5). */
  controllerUrl?: string;
  controllerToken?: string;
  fetchImpl?: FetchLike;
}

type Source<T> =
  | { available: true; value: T }
  | { available: false; reason: string };

type HeldLock = { pid: number };

function sourceReason(label: string, error: unknown): string {
  const detail = error instanceof Error ? error.message : String(error);
  return `${label} unavailable: ${detail}`;
}

function iso(ms: number): string {
  return new Date(ms).toISOString();
}

function parseQueue(
  queueDir: string,
  nowMs: number,
  readdir: (path: string) => string[],
  lstat: (path: string) => FileStat,
): Source<DeployStatus["queue"]> {
  try {
    const names = readdir(queueDir);
    const malformed = names.find((name) => name.startsWith("req-") && !REQUEST_FILE_NAME.test(name));
    if (malformed) throw new Error(`malformed deploy request entry: ${malformed}`);

    const requests = names
      .filter((name) => REQUEST_FILE_NAME.test(name))
      .sort()
      .map((id) => {
        const file = lstat(join(queueDir, id));
        if (!file.isFile()) throw new Error(`invalid deploy request entry: ${id}`);
        if (!Number.isFinite(file.mtimeMs)) throw new Error(`invalid mtime for ${id}`);
        return { id, observedAt: iso(file.mtimeMs), mtimeMs: file.mtimeMs };
      });
    const oldestAgeMs = requests.length === 0
      ? null
      // mtimeMs is fractional (sub-millisecond filesystem precision); the schema is integer.
      : Math.floor(Math.max(0, nowMs - Math.min(...requests.map((request) => request.mtimeMs))));
    return {
      available: true,
      value: {
        depth: requests.length,
        oldestAgeMs,
        requests: requests.map(({ id, observedAt }) => ({ id, observedAt })),
      },
    };
  } catch (error) {
    return { available: false, reason: sourceReason("deploy queue", error) };
  }
}

function lockDevice(dev: number): string {
  if (!Number.isSafeInteger(dev) || dev < 0) throw new Error("invalid lock device");
  const major = (Math.floor(dev / 2 ** 8) % 2 ** 12) + (Math.floor(dev / 2 ** 32) * 2 ** 12);
  const minor = (dev % 2 ** 8) + ((Math.floor(dev / 2 ** 12) % 2 ** 24) * 2 ** 8);
  return `${major.toString(16).padStart(2, "0")}:${minor.toString(16).padStart(2, "0")}`;
}

function parseHeldLock(locks: string, lockStat: FileStat): HeldLock | null {
  if (!Number.isSafeInteger(lockStat.ino) || lockStat.ino < 0) throw new Error("invalid lock inode");
  const identity = `${lockDevice(lockStat.dev)}:${lockStat.ino}`;
  for (const line of locks.split("\n")) {
    if (!line.includes("FLOCK")) continue;
    // "144: -> FLOCK …" is a process queued behind the holder, and READ is a shared lock.
    // Both are ordinary kernel records, not corruption — neither one holds our write lock.
    const match =
      /^\d+:\s+(->\s+)?FLOCK\s+\S+\s+(READ|WRITE)\s+(\d+)\s+([0-9a-f]+:[0-9a-f]+:\d+)\s+\S+\s+\S+$/i.exec(
        line,
      );
    if (!match) throw new Error(`malformed kernel lock record: ${line}`);
    const [, waiter, mode, pid, recordIdentity] = match;
    if (!pid || !recordIdentity) throw new Error(`malformed kernel lock record: ${line}`);
    if (waiter || mode?.toUpperCase() !== "WRITE") continue;
    if (recordIdentity.toLowerCase() !== identity.toLowerCase()) continue;
    return { pid: Number(pid) };
  }
  return null;
}

function readLock(
  lockPath: string,
  lstat: (path: string) => FileStat,
  readFile: (path: string) => string,
): Source<HeldLock | null> {
  let lock: FileStat;
  try {
    lock = lstat(lockPath);
  } catch (error) {
    if (isMissingPath(error)) return { available: true, value: null };
    return { available: false, reason: sourceReason("deploy lock", error) };
  }

  try {
    if (!lock.isFile()) throw new Error("invalid deploy lock entry");
    return { available: true, value: parseHeldLock(readFile("/proc/locks"), lock) };
  } catch (error) {
    return { available: false, reason: sourceReason("deploy lock", error) };
  }
}

function readProgress(
  path: string,
  readFile: (path: string) => string,
): Source<ProgressRecord | null> {
  let raw: string;
  try {
    raw = readFile(path);
  } catch (error) {
    if (isMissingPath(error)) return { available: true, value: null };
    return { available: false, reason: sourceReason("deploy progress", error) };
  }
  try {
    return { available: true, value: ProgressRecordSchema.parse(JSON.parse(raw)) };
  } catch (error) {
    return { available: false, reason: sourceReason("deploy progress", error) };
  }
}

function isMissingPath(error: unknown): error is NodeJS.ErrnoException {
  return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
}

function reasonFor(
  queue: Source<DeployStatus["queue"]>,
  lock: Source<HeldLock | null>,
  progress: Source<ProgressRecord | null>,
  lifecycleIncomplete: boolean,
): string | null {
  const reasons = [
    queue.available ? null : queue.reason,
    lock.available ? null : lock.reason,
    progress.available ? null : progress.reason,
    lifecycleIncomplete ? "active deploy has no authoritative operation identity or lifecycle record" : null,
  ].filter((reason): reason is string => reason !== null);
  return reasons.length === 0 ? null : reasons.join("; ");
}

/**
 * The served-vs-main identity, read entirely from the deploy clone's own git metadata:
 * the commit stamped by the last successful `deploy-local.sh` run, and whatever
 * `refs/remotes/origin/main` last resolved to in that clone. Neither triggers a fetch —
 * this adapter stays read-only; the watcher (S3) owns fetch cadence and freshness.
 */
function readDeployIdentity(
  deployDir: string,
  gitRev: (args: string[]) => string | null,
  readFile: (path: string) => string,
): { servedSha: string | null; mainSha: string | null; commitsBehind: number | null; mainCommitAt: string | null } {
  let servedSha: string | null = null;
  try {
    const stamped = readFile(join(deployDir, ".git", "harness-deployed-sha")).trim();
    if (SHA_RE.test(stamped)) servedSha = stamped;
  } catch {
    servedSha = null;
  }

  const mainSha = gitRev(["-C", deployDir, "rev-parse", "--verify", "refs/remotes/origin/main"]);

  let commitsBehind: number | null = null;
  let mainCommitAt: string | null = null;
  if (servedSha && mainSha) {
    if (servedSha === mainSha) {
      commitsBehind = 0;
    } else {
      const count = gitRev(["-C", deployDir, "rev-list", "--count", `${servedSha}..${mainSha}`]);
      commitsBehind = count !== null && /^\d+$/.test(count) ? Number(count) : null;
      // The commit TIME main's tip landed, not the count — a 1-commit gap from 20
      // seconds ago and one from 4 hours ago both read "1 commit behind" without this;
      // the owner's failure mode ("backed up for a day unnoticed") is a duration, not a count.
      if (commitsBehind !== null && commitsBehind > 0) {
        const committedAt = gitRev(["-C", deployDir, "log", "-1", "--format=%cI", mainSha]);
        mainCommitAt = committedAt !== null && Number.isFinite(Date.parse(committedAt))
          ? new Date(committedAt).toISOString()
          : null;
      }
    }
  }

  return { servedSha, mainSha, commitsBehind, mainCommitAt };
}

function defaultGitRev(args: string[]): string | null {
  const result = spawnSync("git", args, { 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;
}

const DeployWatcherStatusSchema = z.object({
  targetSha: z.string(),
  attempts: z.number().int().nonnegative(),
  lastStatus: z.string(),
  lastDetail: z.string(),
  lastAt: z.string(),
  lastOk: z.boolean(),
  failureClass: z.enum(["none", "transient", "permanent"]).optional(),
  nextRetryAt: z.string().nullable().optional(),
}).nullable();

/**
 * Fetches the S3 deploy watcher's retry-stop state off the controller's own `/status`
 * (`deployWatcher` field, `controller/src/status.ts`) — the watcher's SQLite state lives
 * in the controller process, not the filesystem, so it can't be read the way servedSha/
 * mainSha above are. Fails soft to null: a controller that's down, disabled, or on an
 * older build without this field must never turn an otherwise-valid deploy-status panel
 * into "unknown" — the servedSha/mainSha/commitsBehind fields stay authoritative
 * regardless of whether this fetch succeeds.
 */
async function fetchDeployWatcherStatus(
  fetchImpl: FetchLike,
  controllerUrl: string,
  controllerToken: string,
): Promise<z.infer<typeof DeployWatcherStatusSchema>> {
  if (!controllerToken) return null;
  try {
    const res = await fetchImpl(`${controllerUrl}/status`, {
      headers: { authorization: `Bearer ${controllerToken}` },
    });
    if (!res.ok) return null;
    const body: unknown = await res.json();
    const parsed = DeployWatcherStatusSchema.safeParse(
      typeof body === "object" && body !== null ? (body as Record<string, unknown>).deployWatcher : undefined,
    );
    return parsed.success ? parsed.data : null;
  } catch {
    return null;
  }
}

/** Read-only projection of deployed queue requests and the kernel-held deploy lock. */
export function createDeployStatusAdapter(opts: DeployStatusAdapterOptions = {}): Adapter {
  const id = opts.id ?? "deploy-status";
  const interval = opts.interval ?? DEFAULT_INTERVAL_MS;
  const queueDir = opts.queueDir ?? DEFAULT_QUEUE_DIR;
  const lockPath = opts.lockPath ?? DEFAULT_LOCK_PATH;
  const deployDir = opts.deployDir ?? DEFAULT_DEPLOY_DIR;
  const progressPath = opts.progressPath ?? DEFAULT_PROGRESS_PATH;
  const now = opts.now ?? Date.now;
  const readdir = opts.readdirImpl ?? readdirSync;
  const readFile = opts.readFileImpl ?? ((path: string) => readFileSync(path, "utf8"));
  const lstat = opts.lstatImpl ?? lstatSync;
  const gitRev = opts.gitRevImpl ?? defaultGitRev;
  const controllerUrl = opts.controllerUrl ?? DEFAULT_CONTROLLER_URL;
  const controllerToken = opts.controllerToken ?? "";
  const fetchImpl = opts.fetchImpl ?? fetch;

  async function poll(): Promise<AdapterResult> {
    const nowMs = now();
    const queue = parseQueue(queueDir, nowMs, readdir, lstat);
    const lock = readLock(lockPath, lstat, readFile);
    const progress = readProgress(progressPath, readFile);
    const activeHolder = lock.available ? lock.value : null;
    const running = progress.available && progress.value?.state === "running"
      ? progress.value
      : null;
    // A deploy holding the lock without a progress record is a deploy nothing can
    // describe — the incomplete lifecycle the owner sees as a silent hang.
    const lifecycleIncomplete = activeHolder !== null && running === null;
    const complete = queue.available && lock.available && progress.available && !lifecycleIncomplete;
    const reason = reasonFor(queue, lock, progress, lifecycleIncomplete);
    const latest = progress.available ? progress.value : null;
    const identity = readDeployIdentity(deployDir, gitRev, readFile);
    const watcher = await fetchDeployWatcherStatus(fetchImpl, controllerUrl, controllerToken);

    let state: DeployState = "unknown";
    if (queue.available && lock.available && progress.available) {
      if (running) {
        // A run whose lock is gone published progress and then died: stalled, never idle.
        state = activeHolder ? "running" : "stalled";
      } else if (activeHolder) {
        state = "unknown";
      } else if (queue.value && queue.value.depth > 0) {
        state = "queued";
      } else {
        state = "idle";
      }
    }

    const data = DeployStatusSchema.parse({
      state,
      complete,
      observedAt: iso(nowMs),
      queue: queue.available ? queue.value : null,
      operation: running ? (running.detail.trim() || running.step) : null,
      holder: activeHolder ? `pid:${activeHolder.pid}` : null,
      latestEvent: latest
        ? { at: iso(latest.updated_at * 1000), type: latest.step, detail: latest.detail.trim() || null }
        : null,
      latestProgressAt: latest ? iso(latest.updated_at * 1000) : null,
      reason,
      servedSha: identity.servedSha,
      mainSha: identity.mainSha,
      commitsBehind: identity.commitsBehind,
      mainCommitAt: identity.mainCommitAt,
      watcher,
    });
    const panels: Panel[] = [{ id, ts: data.observedAt, data }];
    return { items: [], panels };
  }

  return { id, interval, poll };
}
