import { existsSync, readFileSync } from "node:fs";
import { z } from "zod";
import type { Item, Panel } from "./schema";
import type { CollectorState } from "./state";
import { configDir, configFile } from "./paths";

export const ALERTER_MINING_OUTPUT_ABSENT = "ALERTER_MINING_OUTPUT_ABSENT";

const DEFAULT_MINING_PATH = () => `${configDir()}/notification-mining.toml`;

const MiningOutputSchema = z.object({
  cooldownMs: z.number().int().positive(),
  quietHoursStart: z.string().regex(/^\d{2}:\d{2}$/),
  quietHoursEnd: z.string().regex(/^\d{2}:\d{2}$/),
});

const AlerterSectionSchema = z.object({
  enabled: z.boolean().default(false),
  miningPath: z.string().optional(),
});

export type MiningOutput = z.infer<typeof MiningOutputSchema>;

export interface ResolvedAlerterConfig {
  enabled: true;
  miningPath: string;
  cooldownMs: number;
  quietHoursStart: string;
  quietHoursEnd: string;
}

export type AlerterResolveResult =
  | { ok: true; config: ResolvedAlerterConfig }
  | { ok: false; code: string; message: string };

export interface DigestInput {
  overnightRuns: Array<{ title: string; status: string }>;
  scoreboardDeltaWorks: number;
  scoreboardRepoCount: number;
  newFailures: Array<{ title: string }>;
  waitingDecisions: Array<{ title: string }>;
}

export interface DigestResponse {
  day: string;
  paragraph: string;
}

export type SpawnFn = (argv: string[]) => Promise<{ stdout: string; stderr: string; rc: number }>;

export interface AlerterDeps {
  state: CollectorState;
  config: ResolvedAlerterConfig;
  notify?: NotifyFn;
  spawn?: SpawnFn;
  now?: () => number;
  timeZone?: string;
}

export interface AlerterHandle {
  stop(): void;
}

export type NotifyFn = (
  summary: string,
  body: string,
  actions?: string[],
  urgency?: "normal" | "critical",
  replacesId?: number,
) => Promise<number>;

interface PlansRun {
  title: string;
  status: string;
  updatedAt: string;
}

interface ScoreboardRepo {
  repo: string;
  trend7d: number;
}

function readAlerterSection(): z.infer<typeof AlerterSectionSchema> {
  const path = configFile();
  if (!existsSync(path)) {
    return AlerterSectionSchema.parse({});
  }
  const raw = readFileSync(path, "utf8");
  const parsed = Bun.TOML.parse(raw) as { alerter?: unknown };
  return AlerterSectionSchema.parse(parsed.alerter ?? {});
}

export function readMiningOutput(path: string): MiningOutput | null {
  if (!existsSync(path)) return null;
  let raw: string;
  try {
    raw = readFileSync(path, "utf8");
  } catch {
    return null;
  }
  let parsed: unknown;
  try {
    parsed = Bun.TOML.parse(raw);
  } catch {
    return null;
  }
  const result = MiningOutputSchema.safeParse(parsed);
  return result.success ? result.data : null;
}

export function miningOutputAbsentMessage(path: string): string {
  return (
    `alerter disabled: notification-mining output missing at ${path}. ` +
    "Run the ANNOYANCE_FATIGUE §7 miner (mine-all.prompt), write cooldownMs + quietHoursStart + quietHoursEnd to that TOML, then set [alerter] enabled = true in config.toml"
  );
}

export function resolveAlerterSettings(
  section: z.infer<typeof AlerterSectionSchema> = readAlerterSection(),
  readMining: (path: string) => MiningOutput | null = readMiningOutput,
): AlerterResolveResult {
  const miningPath = section.miningPath ?? DEFAULT_MINING_PATH();
  const mining = readMining(miningPath);
  if (!mining) {
    return {
      ok: false,
      code: ALERTER_MINING_OUTPUT_ABSENT,
      message: miningOutputAbsentMessage(miningPath),
    };
  }
  if (!section.enabled) {
    return {
      ok: false,
      code: "ALERTER_DISABLED",
      message: "alerter disabled: set [alerter] enabled = true in config.toml after notification-mining output is present",
    };
  }
  return {
    ok: true,
    config: {
      enabled: true,
      miningPath,
      cooldownMs: mining.cooldownMs,
      quietHoursStart: mining.quietHoursStart,
      quietHoursEnd: mining.quietHoursEnd,
    },
  };
}

async function defaultSpawn(argv: string[]): Promise<{ stdout: string; stderr: string; rc: number }> {
  const proc = Bun.spawn(argv, { stdout: "pipe", stderr: "pipe" });
  const [stdout, stderr, rc] = await Promise.all([
    new Response(proc.stdout).text(),
    new Response(proc.stderr).text(),
    proc.exited,
  ]);
  return { stdout, stderr, rc };
}

const GATE_PY = process.env.NOTIF_GATE_PY ?? "/usr/local/lib/notif-gate/notif_gate.py";
const EMITTER = import.meta.path;
const GATE_TIMEOUT_MS = 5000;

/**
 * Default-deny: only an owner-approved source may reach the session bus.
 * Anything other than a clean approval — gate absent, crashed, or slow — denies.
 */
export async function gateAllows(
  summary: string,
  body: string,
  spawn: SpawnFn = defaultSpawn,
): Promise<boolean> {
  const decision = spawn([
    "python3", GATE_PY, "check", EMITTER, summary, body, "dbus-gated",
  ]).then(({ rc }) => rc === 0, () => false);
  const timeout = new Promise<boolean>((resolve) =>
    setTimeout(() => resolve(false), GATE_TIMEOUT_MS).unref?.());
  return Promise.race([decision, timeout]);
}

/** Mirrors system-monitor agent_guard/notifier.py gdbus_notify conventions. */
export async function gdbusNotify(
  summary: string,
  body: string,
  actions: string[] = ["Dismiss"],
  urgency: "normal" | "critical" = "normal",
  replacesId = 0,
  spawn: SpawnFn = defaultSpawn,
): Promise<number> {
  if (!(await gateAllows(summary, body, spawn))) return 0;
  const actionArray: string[] = [];
  for (const action of actions) {
    const key = action.toLowerCase().replace(/ /g, "_");
    actionArray.push(key, action);
  }
  const urgencyHint =
    urgency === "critical" ? "{'urgency': <byte 2>}" : "{'urgency': <byte 1>}";
  const args = [
    "gdbus",
    "call",
    "--session",
    "--dest",
    "org.freedesktop.Notifications",
    "--object-path",
    "/org/freedesktop/Notifications",
    "--method",
    "org.freedesktop.Notifications.Notify",
    "overdeck",
    String(replacesId),
    "dialog-warning",
    summary,
    body,
    JSON.stringify(actionArray).replace(/"/g, "'"),
    urgencyHint,
    "0",
  ];
  const { stdout } = await spawn(args);
  const match = /\(uint32\s+(\d+),?\)/.exec(stdout);
  return match ? Number(match[1]) : 0;
}

function localParts(ms: number, timeZone: string): { hour: number; minute: number; day: string } {
  const fmt = new Intl.DateTimeFormat("en-CA", {
    timeZone,
    year: "numeric",
    month: "2-digit",
    day: "2-digit",
    hour: "2-digit",
    minute: "2-digit",
    hourCycle: "h23",
  });
  const parts = fmt.formatToParts(new Date(ms));
  const pick = (type: Intl.DateTimeFormatPartTypes): string =>
    parts.find((part) => part.type === type)?.value ?? "00";
  return {
    day: `${pick("year")}-${pick("month")}-${pick("day")}`,
    hour: Number(pick("hour")),
    minute: Number(pick("minute")),
  };
}

function parseHm(value: string): number {
  const [h, m] = value.split(":").map((part) => Number(part));
  return h! * 60 + m!;
}

export function isQuietHours(
  nowMs: number,
  quietHoursStart: string,
  quietHoursEnd: string,
  timeZone = "UTC",
): boolean {
  const { hour, minute } = localParts(nowMs, timeZone);
  const nowMinutes = hour * 60 + minute;
  const start = parseHm(quietHoursStart);
  const end = parseHm(quietHoursEnd);
  if (start === end) return false;
  if (start < end) {
    return nowMinutes >= start && nowMinutes < end;
  }
  return nowMinutes >= start || nowMinutes < end;
}

function listPhrase(items: string[]): string {
  if (items.length === 0) return "none";
  if (items.length === 1) return items[0]!;
  if (items.length === 2) return `${items[0]} and ${items[1]}`;
  return `${items.slice(0, -1).join(", ")}, and ${items.at(-1)}`;
}

export function formatDigestParagraph(input: DigestInput): string {
  const runTitles = input.overnightRuns.map((run) => `${run.title} (${run.status})`);
  const runPhrase =
    input.overnightRuns.length === 0
      ? "no harness runs updated overnight"
      : `${input.overnightRuns.length} harness run${input.overnightRuns.length === 1 ? "" : "s"} updated (${listPhrase(runTitles)})`;
  const scorePhrase =
    input.scoreboardRepoCount === 0
      ? "scoreboard unchanged (0 repos tracked)"
      : `scoreboard gained ${input.scoreboardDeltaWorks} works across ${input.scoreboardRepoCount} repo${input.scoreboardRepoCount === 1 ? "" : "s"}`;
  const failureTitles = input.newFailures.map((item) => item.title);
  const failurePhrase =
    input.newFailures.length === 0
      ? "No new CI failures overnight."
      : `${input.newFailures.length} new CI failure${input.newFailures.length === 1 ? "" : "s"}: ${listPhrase(failureTitles)}.`;
  const decisionTitles = input.waitingDecisions.map((item) => item.title);
  const decisionPhrase =
    input.waitingDecisions.length === 0
      ? "No decisions waiting."
      : `${input.waitingDecisions.length} decision${input.waitingDecisions.length === 1 ? "" : "s"} waiting: ${listPhrase(decisionTitles)}.`;
  return `Good morning. Overnight, ${runPhrase}. ${scorePhrase}. ${failurePhrase} ${decisionPhrase}`;
}

function startOfLocalDayMs(nowMs: number, timeZone: string): number {
  const { day } = localParts(nowMs, timeZone);
  for (let candidate = nowMs - 86_400_000 * 2; candidate <= nowMs + 3_600_000; candidate += 60_000) {
    const parts = localParts(candidate, timeZone);
    if (parts.day === day && parts.hour === 0 && parts.minute === 0) {
      return candidate;
    }
  }
  throw new Error(`cannot resolve local midnight for ${day} in ${timeZone}`);
}

function asPlansRuns(panel: Panel | undefined): PlansRun[] {
  if (!panel) return [];
  const data = panel.data as { runs?: PlansRun[] };
  return data.runs ?? [];
}

function asScoreboardRepos(panel: Panel | undefined): ScoreboardRepo[] {
  if (!panel) return [];
  const data = panel.data as { repos?: ScoreboardRepo[] };
  return data.repos ?? [];
}

export function collectDigestInput(
  state: CollectorState,
  nowMs: number,
  timeZone = "UTC",
): DigestInput {
  const midnightMs = startOfLocalDayMs(nowMs, timeZone);
  const panels = state.getPanels();
  const plans = panels.find((panel) => panel.id === "plans");
  const scoreboard = panels.find((panel) => panel.id === "scoreboard");
  const overnightRuns = asPlansRuns(plans).filter((run) => Date.parse(run.updatedAt) >= midnightMs);
  const repos = asScoreboardRepos(scoreboard);
  const items = state.getItems();
  const newFailures = items.filter(
    (item) => item.kind === "ci" && item.severity === "act" && Date.parse(item.ts) >= midnightMs,
  );
  const waitingDecisions = items.filter((item) => item.kind === "decision" || item.kind === "halt");
  return {
    overnightRuns: overnightRuns.map((run) => ({ title: run.title, status: run.status })),
    scoreboardDeltaWorks: repos.reduce((sum, repo) => sum + repo.trend7d, 0),
    scoreboardRepoCount: repos.length,
    newFailures: newFailures.map((item) => ({ title: item.title })),
    waitingDecisions: waitingDecisions.map((item) => ({ title: item.title })),
  };
}

export function buildDigest(
  state: CollectorState,
  now: () => number = Date.now,
  timeZone = "UTC",
): DigestResponse {
  const nowMs = now();
  const { day } = localParts(nowMs, timeZone);
  const input = collectDigestInput(state, nowMs, timeZone);
  return { day, paragraph: formatDigestParagraph(input) };
}

export class Alerter {
  private readonly lastNotified = new Map<string, number>();
  private readonly notificationIds = new Map<string, number>();
  private readonly notify: NotifyFn;
  private readonly now: () => number;
  private readonly timeZone: string;

  constructor(
    private readonly config: ResolvedAlerterConfig,
    deps: { notify: NotifyFn; now?: () => number; timeZone?: string },
  ) {
    this.notify = deps.notify;
    this.now = deps.now ?? Date.now;
    this.timeZone = deps.timeZone ?? "UTC";
  }

  shouldNotify(item: Item, atMs = this.now()): boolean {
    if (item.severity !== "act") return false;
    if (isQuietHours(atMs, this.config.quietHoursStart, this.config.quietHoursEnd, this.timeZone)) {
      return false;
    }
    const last = this.lastNotified.get(item.id);
    if (last !== undefined && atMs - last < this.config.cooldownMs) {
      return false;
    }
    return true;
  }

  async notifyItem(item: Item, atMs = this.now()): Promise<boolean> {
    if (!this.shouldNotify(item, atMs)) return false;
    const replacesId = this.notificationIds.get(item.id) ?? 0;
    const nid = await this.notify(
      `overdeck: ${item.source}`,
      item.detail || item.title,
      ["Dismiss"],
      "critical",
      replacesId,
    );
    this.lastNotified.set(item.id, atMs);
    if (nid) this.notificationIds.set(item.id, nid);
    return true;
  }
}

export function createAlerter(deps: AlerterDeps): AlerterHandle {
  const now = deps.now ?? Date.now;
  const spawn = deps.spawn ?? defaultSpawn;
  const notify =
    deps.notify ??
    ((summary, body, actions, urgency, replacesId) =>
      gdbusNotify(summary, body, actions, urgency, replacesId, spawn));
  const alerter = new Alerter(deps.config, { notify, now, timeZone: deps.timeZone });

  const unsubscribe = deps.state.subscribe((delta) => {
    if (delta.type !== "item") return;
    void alerter.notifyItem(delta.item, now());
  });

  return { stop: unsubscribe };
}
