import { existsSync, readFileSync } from "node:fs";
import { mkdir, readFile, realpath } from "node:fs/promises";
import { basename, dirname, join, resolve } from "node:path";
import { atomicWrite, withTargetLock, TargetLockError, type AtomicWriteResult } from "./atomic-write";
import { z } from "zod";
import { configFile } from "./paths";
import { CollectorFatalError } from "./errors";

const AdapterConfigSchema = z.object({
  enabled: z.boolean().default(true),
  intervalMs: z.number().int().positive().optional(),
  /** systray-ai Codex HealthStore snapshot path; factory global sssf.db path alias. */
  snapshotPath: z.string().optional(),
  /** systray-ai Claude HealthStore snapshot path. */
  claudeSnapshotPath: z.string().optional(),
  /** factory: global sssf.db path (preferred over snapshotPath when both are set). */
  dbPath: z.string().optional(),
  /** ghci: "owner/repo" list to poll; golive / gates: absolute repo paths to poll. */
  repos: z.array(z.string()).optional(),
  /** harness / prometheus / botmaster source base URL. */
  baseUrl: z.string().optional(),
  /** cluster: agent-class regex for orphan scan (ANNOYANCE_FATIGUE §7). */
  agentClassRegex: z.string().optional(),
  /** cluster: agent-guard unix socket path. */
  agentGuardSocketPath: z.string().optional(),
  /** cluster: buildbox-watch state file path. */
  buildboxStatePath: z.string().optional(),
  /** cluster: ci-fallback counter directory. */
  ciFallbackDir: z.string().optional(),
  /** cluster: buildslot queue directory. */
  buildslotDir: z.string().optional(),
  orphanMinEtimeMinutes: z.number().int().positive().optional(),
  orphanMinCpuPercent: z.number().positive().optional(),
  /** gates: security-gate prevent/confirmed.json path. */
  confirmedPath: z.string().optional(),
  /** gates: security-gate precision records path. */
  precisionRecordsPath: z.string().optional(),
  /** gates: slopgate baseline path relative to each repo root. */
  slopgateBaselineRel: z.string().optional(),
  /** gates: pending .warnignore sign-off file relative to each repo root. */
  warnignorePendingRel: z.string().optional(),
  /** botmaster / offload: bearer token file path (not the secret itself). */
  tokenPath: z.string().optional(),
  /** github-checks: GitHub repository in owner/name form. */
  repo: z.string().optional(),
  /** offload: build controller base URL (GET /status). */
  controllerUrl: z.string().optional(),
  /** offload: Prometheus query API base URL. */
  metricsUrl: z.string().optional(),
  /** offload: override critical CPU temp threshold (°C) when metrics omit sensor=crit. */
  cpuTempCritOverride: z.number().positive().optional(),
  /** offload: percent of the last 5 min with every task stalled on memory that counts as a stalled box. */
  hostStallPercent: z.number().positive().optional(),
  /** offload: percent of a box's /tmp that counts as full — at 100 its writes are already failing. */
  hostTmpFullPercent: z.number().positive().optional(),
  /** offload: buildbox-parity.sh state file path. */
  parityStatePath: z.string().optional(),
  metricsLimit: z.number().int().positive().optional(),
  downThresholdMs: z.number().int().positive().optional(),
  slowErrorThreshold: z.number().int().positive().optional(),
  /** harness: control-api request timeout (ms). */
  requestTimeoutMs: z.number().int().positive().optional(),
  /** sessions: agent-session ledger directory. */
  ledgerDir: z.string().optional(),
  /** seats: admission limiter slot directory. */
  slotDir: z.string().optional(),
  /** kubernetes: dedicated read-only kubeconfig. */
  kubeconfigPath: z.string().optional(),
  /** kubernetes: stable configured cluster identity. */
  clusterId: z.string().optional(),
  /** kubernetes: namespace scopes; each is reported complete or partial independently. */
  namespaces: z.array(z.string().min(1)).optional(),
  /** kubernetes: loopback Prometheus HTTP API. */
  prometheusUrl: z.string().optional(),
  /** sandbox-toolgap: JSONL of missing-tool records pulled back from sandbox hosts. */
  gapsPath: z.string().optional(),
  /** sandbox-toolgap: file holding the image tag the sandboxes currently run. */
  imageTagPath: z.string().optional(),
});

const CSS_NAMED_COLORS = new Set([
  "aliceblue", "antiquewhite", "aqua", "aquamarine", "azure", "beige", "bisque", "black", "blanchedalmond", "blue", "blueviolet", "brown", "burlywood", "cadetblue", "chartreuse", "chocolate", "coral", "cornflowerblue", "cornsilk", "crimson", "cyan", "darkblue", "darkcyan", "darkgoldenrod", "darkgray", "darkgreen", "darkgrey", "darkkhaki", "darkmagenta", "darkolivegreen", "darkorange", "darkorchid", "darkred", "darksalmon", "darkseagreen", "darkslateblue", "darkslategray", "darkslategrey", "darkturquoise", "darkviolet", "deeppink", "deepskyblue", "dimgray", "dimgrey", "dodgerblue", "firebrick", "floralwhite", "forestgreen", "fuchsia", "gainsboro", "ghostwhite", "gold", "goldenrod", "gray", "green", "greenyellow", "grey", "honeydew", "hotpink", "indianred", "indigo", "ivory", "khaki", "lavender", "lavenderblush", "lawngreen", "lemonchiffon", "lightblue", "lightcoral", "lightcyan", "lightgoldenrodyellow", "lightgray", "lightgreen", "lightgrey", "lightpink", "lightsalmon", "lightseagreen", "lightskyblue", "lightslategray", "lightslategrey", "lightsteelblue", "lightyellow", "lime", "limegreen", "linen", "magenta", "maroon", "mediumaquamarine", "mediumblue", "mediumorchid", "mediumpurple", "mediumseagreen", "mediumslateblue", "mediumspringgreen", "mediumturquoise", "mediumvioletred", "midnightblue", "mintcream", "mistyrose", "moccasin", "navajowhite", "navy", "oldlace", "olive", "olivedrab", "orange", "orangered", "orchid", "palegoldenrod", "palegreen", "paleturquoise", "palevioletred", "papayawhip", "peachpuff", "peru", "pink", "plum", "powderblue", "purple", "rebeccapurple", "red", "rosybrown", "royalblue", "saddlebrown", "salmon", "sandybrown", "seagreen", "seashell", "sienna", "silver", "skyblue", "slateblue", "slategray", "slategrey", "snow", "springgreen", "steelblue", "tan", "teal", "thistle", "tomato", "transparent", "turquoise", "violet", "wheat", "white", "whitesmoke", "yellow", "yellowgreen",
]);

export const ProjectColorSchema = z.string().refine(
  (value) => /^#[\da-f]{3}(?:[\da-f]{3})?$/i.test(value) || CSS_NAMED_COLORS.has(value.toLowerCase()),
  "expected a plain CSS color token",
);
export const ProjectColorsSchema = z.record(ProjectColorSchema);

const ConfigSchema = z.object({
  bindHost: z.string().default("127.0.0.1"),
  port: z.number().int().positive().default(4980),
  bind_port: z.number().int().positive().optional(),
  tailnetBind: z.boolean().default(false),
  adapters: z.record(AdapterConfigSchema).default({}),
  projectColors: ProjectColorsSchema.default({}),
}).transform((config) => ({ ...config, bind_port: config.bind_port ?? config.port }));
export type CollectorConfig = z.infer<typeof ConfigSchema>;

const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "localhost"]);

/** The address the collector binds. Tailnet binding is opt-in and widens reach. */
export function resolveBindHost(config: CollectorConfig): string {
  return config.tailnetBind ? config.bindHost : "127.0.0.1";
}

export function isLoopbackHost(host: string): boolean {
  return LOOPBACK_HOSTS.has(host) || host.startsWith("127.");
}

export function defaultConfig(): CollectorConfig {
  return ConfigSchema.parse({});
}

export function loadConfig(path = configFile()): CollectorConfig {
  if (!existsSync(path)) {
    return defaultConfig();
  }

  let raw: string;
  try {
    raw = readFileSync(path, "utf8");
  } catch (err) {
    throw new CollectorFatalError(
      "CONFIG_UNREADABLE",
      `cannot read config at ${path}: ${(err as Error).message}`,
    );
  }

  let parsedToml: unknown;
  try {
    parsedToml = Bun.TOML.parse(raw);
  } catch (err) {
    throw new CollectorFatalError(
      "CONFIG_INVALID_TOML",
      `config at ${path} is not valid TOML: ${(err as Error).message}`,
    );
  }

  const result = ConfigSchema.safeParse(parsedToml);
  if (!result.success) {
    throw new CollectorFatalError(
      "CONFIG_INVALID_SHAPE",
      `config at ${path} failed validation: ${result.error.message}`,
    );
  }
  return result.data;
}

export interface PersistedProjectColors {
  projects: Record<string, string>;
  durability: Extract<AtomicWriteResult, { state: "committed" }>['durability'];
}

export class ProjectColorsPersistenceError extends Error {
  constructor(readonly code: "conflict-exhausted" | "lock-busy", message: string) { super(message); }
}

const MAX_SOURCE_CONFLICT_RETRIES = 5;

async function canonicalConfigPath(path: string): Promise<string> {
  const directory = dirname(resolve(path));
  await mkdir(directory, { recursive: true });
  return join(await realpath(directory), basename(path));
}

async function readSource(path: string): Promise<string | null> {
  try { return await readFile(path, "utf8"); }
  catch (error) {
    if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
    throw error;
  }
}

function tomlString(value: string): string {
  // JSON's string syntax is also TOML basic-string syntax for these values.
  return JSON.stringify(value);
}

function inlineProjectColors(projects: Record<string, string>): string {
  return `{ ${Object.entries(projects).map(([key, value]) => `${tomlString(key)} = ${tomlString(value)}`).join(", ")} }`;
}

function lineEnding(line: string): string { return line.endsWith("\r\n") ? "\r\n" : line.endsWith("\n") ? "\n" : ""; }
function withoutEnding(line: string): string { return line.slice(0, line.length - lineEnding(line).length); }
function hashOutsideString(value: string): number {
  let quote: "basic" | "literal" | null = null;
  let escaped = false;
  for (let index = 0; index < value.length; index++) {
    const char = value[index]!;
    if (quote === "basic") {
      if (escaped) escaped = false;
      else if (char === "\\") escaped = true;
      else if (char === '"') quote = null;
    } else if (quote === "literal") {
      if (char === "'") quote = null;
    } else if (char === '"') quote = "basic";
    else if (char === "'") quote = "literal";
    else if (char === "#") return index;
  }
  return -1;
}

function projectColorAssignment(line: string): { indent: string; key: string; valueStart: number; valueEnd: number; tail: string; newline: string } | null {
  const newline = lineEnding(line);
  const body = withoutEnding(line);
  const prefix = /^(\s*)("(?:\\.|[^"\\])*"|'[^']*'|[A-Za-z0-9_-]+)(\s*=\s*)/.exec(body);
  if (!prefix) return null;
  let key: unknown;
  try { key = (Bun.TOML.parse(`key = ${prefix[2]}`) as Record<string, unknown>).key; } catch { return null; }
  if (typeof key !== "string") return null;
  const valueStart = prefix[0].length;
  const quote = body[valueStart];
  if (quote !== '"' && quote !== "'") return null;
  let escaped = false;
  let valueEnd = -1;
  for (let index = valueStart + 1; index < body.length; index++) {
    if (quote === '"' && escaped) { escaped = false; continue; }
    if (quote === '"' && body[index] === "\\") { escaped = true; continue; }
    if (body[index] === quote) { valueEnd = index + 1; break; }
  }
  if (valueEnd < 0) return null;
  return { indent: prefix[1]!, key, valueStart, valueEnd, tail: body.slice(valueEnd), newline };
}

/** Changes only projectColors values, retaining comments and layout in existing tables. */
function patchProjectColors(source: string, projects: Record<string, string>): string {
  const lines = source.split(/(?<=\n)/);
  for (let index = 0; index < lines.length; index++) {
    const body = withoutEnding(lines[index]!);
    const root = /^(\s*)("(?:\\.|[^"\\])*"|'[^']*'|[A-Za-z0-9_-]+)(\s*=\s*)(.*)$/.exec(body);
    if (!root) continue;
    let rootKey: unknown;
    try { rootKey = (Bun.TOML.parse(`key = ${root[2]}`) as Record<string, unknown>).key; } catch { continue; }
    if (rootKey !== "projectColors") continue;
    const valueAndSuffix = root[4]!;
    const hash = hashOutsideString(valueAndSuffix);
    const beforeComment = valueAndSuffix.slice(0, hash < 0 ? valueAndSuffix.length : hash).match(/\s*$/)?.[0] ?? "";
    const suffix = `${beforeComment}${hash < 0 ? "" : valueAndSuffix.slice(hash)}`;
    return [...lines.slice(0, index), `${root[1]}${root[2]}${root[3]}${inlineProjectColors(projects)}${suffix}${lineEnding(lines[index]!)}`, ...lines.slice(index + 1)].join("");
  }

  const tableHeader = /^\s*\[projectColors\]\s*(?:#.*)?$/;
  for (let index = 0; index < lines.length; index++) {
    if (!tableHeader.test(withoutEnding(lines[index]!))) continue;
    let end = index + 1;
    while (end < lines.length && !/^\s*\[\[?[^\]]+\]\]?/.test(withoutEnding(lines[end]!))) end++;
    const remaining = new Set(Object.keys(projects));
    for (let entry = index + 1; entry < end; entry++) {
      const assignment = projectColorAssignment(lines[entry]!);
      if (!assignment || !remaining.has(assignment.key)) continue;
      remaining.delete(assignment.key);
      lines[entry] = `${withoutEnding(lines[entry]!).slice(0, assignment.valueStart)}${tomlString(projects[assignment.key]!)}${assignment.tail}${assignment.newline}`;
    }
    // Remove omitted assignments without consuming their vertical slot or comments.
    for (let entry = index + 1; entry < end; entry++) {
      const assignment = projectColorAssignment(lines[entry]!);
      if (!assignment || Object.hasOwn(projects, assignment.key)) continue;
      const comment = hashOutsideString(assignment.tail);
      lines[entry] = `${assignment.indent}${comment < 0 ? "" : assignment.tail.slice(comment)}${assignment.newline}`;
    }
    if (remaining.size) {
      const newline = lineEnding(lines[index]!) || lines.slice(index + 1, end).map(lineEnding).find(Boolean) || "\n";
      if (!lineEnding(lines[index]!)) lines[index] = `${lines[index]}${newline}`;
      const additions = [...remaining].map((key) => `${tomlString(key)} = ${tomlString(projects[key]!)}${newline}`);
      lines.splice(end, 0, ...additions);
    }
    return lines.join("");
  }

  const insertion = `projectColors = ${inlineProjectColors(projects)}\n`;
  const firstTable = lines.findIndex((line) => /^\s*\[\[?[^\]]+\]\]?/.test(withoutEnding(line)));
  if (firstTable >= 0) return [...lines.slice(0, firstTable), insertion, ...lines.slice(firstTable)].join("");
  return source.length === 0 || source.endsWith("\n") ? `${source}${insertion}` : `${source}\n${insertion}`;
}

function validatePatchedSource(source: string, projects: Record<string, string>): void {
  const parsed: unknown = Bun.TOML.parse(source);
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new TypeError("config root must be an object");
  const persisted = ProjectColorsSchema.parse((parsed as Record<string, unknown>).projectColors);
  const expected = Object.entries(projects);
  if (Object.keys(persisted).length !== expected.length || expected.some(([key, value]) => persisted[key] !== value)) {
    throw new TypeError("projectColors patch did not persist requested values");
  }
}
const pathQueues = new Map<string, Promise<void>>();

async function serialized<T>(path: string, operation: () => Promise<T>): Promise<T> {
  const previous = pathQueues.get(path) ?? Promise.resolve();
  const result = previous.catch(() => undefined).then(operation);
  const settled = result.then(() => undefined, () => undefined);
  pathQueues.set(path, settled);
  void settled.finally(() => { if (pathQueues.get(path) === settled) pathQueues.delete(path); });
  return result;
}

/** Atomically replace only projectColors while retaining all other on-disk TOML bytes. */
export async function persistProjectColors(path: string, input: unknown): Promise<PersistedProjectColors> {
  const projects = ProjectColorsSchema.parse(input);
  return serialized(resolve(path), async () => {
    const target = await canonicalConfigPath(path);
    try {
      return await withTargetLock(target, async () => {
        for (let attempt = 0; attempt < MAX_SOURCE_CONFLICT_RETRIES; attempt++) {
          const source = await readSource(target);
          // Parse before writing so invalid existing TOML is never replaced.
          if (source !== null) Bun.TOML.parse(source);
          const patched = patchProjectColors(source ?? "", projects);
          validatePatchedSource(patched, projects);
          const result = await atomicWrite(target, patched, { expectedSource: source, temporaryPrefix: "config" });
          if (result.state === "conflict") continue;
          return { projects, durability: result.durability };
        }
        throw new ProjectColorsPersistenceError("conflict-exhausted", "project colors source remained concurrently modified");
      });
    } catch (error) {
      if (error instanceof TargetLockError && error.code === "busy") {
        throw new ProjectColorsPersistenceError("lock-busy", error.message);
      }
      throw error;
    }
  });
}
