import { chmodSync, copyFileSync, existsSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
import { spawnSync, type SpawnSyncReturns } from "node:child_process";
import { Buffer } from "node:buffer";
import type { UniDeskConfig } from "./config";
import { runSshCommandCapture, type SshCaptureResult } from "./ssh";

export interface Pk01CaddyBlockExposure {
  enabled: boolean;
  dns: { hostname: string };
  frpc: { remotePort: number };
  pk01: {
    route: string;
    caddyConfigPath: string;
    caddyServiceName: string;
    responseHeaderTimeoutSeconds: number;
  };
}

export interface Pk01CaddyManagedBlockMarkers {
  start: string;
  end: string;
}

export interface LocalCaddyManagedSiteOptions {
  serviceId: string;
  markerName: string;
  configPath: string;
  serviceName: string;
  siteBlock: string;
  legacySiteDomain?: string;
}

export function caddyManagedBlockMarkers(markerName: string): Pk01CaddyManagedBlockMarkers {
  return {
    start: `# BEGIN unidesk managed ${markerName}`,
    end: `# END unidesk managed ${markerName}`,
  };
}

export function renderCaddyManagedBlock(markerName: string, siteBlock: string): string {
  const markers = caddyManagedBlockMarkers(markerName);
  return `${markers.start}
${siteBlock.trim()}
${markers.end}
`;
}

export function renderSimpleReverseProxyCaddySiteBlock(params: {
  hostname: string;
  upstream: string;
  responseHeaderTimeoutSeconds: number;
}): string {
  return `${params.hostname} {
  reverse_proxy ${params.upstream} {
    transport http {
      response_header_timeout ${params.responseHeaderTimeoutSeconds}s
    }
  }
}`;
}

export async function applyPk01CaddyManagedBlock(
  config: UniDeskConfig,
  serviceId: string,
  exposure: Pk01CaddyBlockExposure,
  markers: Pk01CaddyManagedBlockMarkers,
): Promise<Record<string, unknown>> {
  if (!exposure.enabled) return { ok: true, action: "not-enabled" };
  const block = renderCaddyManagedBlock(
    markerNameFromMarkers(markers),
    renderSimpleReverseProxyCaddySiteBlock({
      hostname: exposure.dns.hostname,
      upstream: `127.0.0.1:${exposure.frpc.remotePort}`,
      responseHeaderTimeoutSeconds: exposure.pk01.responseHeaderTimeoutSeconds,
    }),
  );
  const blockB64 = Buffer.from(block, "utf8").toString("base64");
  const script = `
set -u
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
block="$tmp/${serviceId}.caddy"
next="$tmp/Caddyfile.next"
printf '%s' '${blockB64}' | base64 -d >"$block"
sudo python3 - ${shQuote(exposure.pk01.caddyConfigPath)} "$block" "$next" >"$tmp/update.out" 2>"$tmp/update.err" <<'PY'
${pk01CaddyApplyManagedBlockPython()}
PY
update_rc=$?
if [ "$update_rc" -eq 0 ]; then
  sudo caddy fmt --overwrite "$next" >"$tmp/fmt.out" 2>"$tmp/fmt.err"
  fmt_rc=$?
else
  : >"$tmp/fmt.out"; : >"$tmp/fmt.err"; fmt_rc=1
fi
if [ "$fmt_rc" -eq 0 ]; then
  sudo caddy validate --config "$next" >"$tmp/validate.out" 2>"$tmp/validate.err"
  validate_rc=$?
else
  : >"$tmp/validate.out"; : >"$tmp/validate.err"; validate_rc=1
fi
if [ "$validate_rc" -eq 0 ]; then
  sudo install -m 0644 "$next" ${shQuote(exposure.pk01.caddyConfigPath)} >"$tmp/install.out" 2>"$tmp/install.err"
  install_rc=$?
else
  : >"$tmp/install.out"; : >"$tmp/install.err"; install_rc=1
fi
if [ "$install_rc" -eq 0 ]; then
  sudo systemctl reload ${shQuote(exposure.pk01.caddyServiceName)} >"$tmp/reload.out" 2>"$tmp/reload.err" || sudo systemctl restart ${shQuote(exposure.pk01.caddyServiceName)} >>"$tmp/reload.out" 2>>"$tmp/reload.err"
  reload_rc=$?
else
  : >"$tmp/reload.out"; : >"$tmp/reload.err"; reload_rc=1
fi
python3 - "$update_rc" "$fmt_rc" "$validate_rc" "$install_rc" "$reload_rc" "$tmp/update.out" "$tmp/update.err" "$tmp/fmt.out" "$tmp/fmt.err" "$tmp/validate.out" "$tmp/validate.err" "$tmp/install.out" "$tmp/install.err" "$tmp/reload.out" "$tmp/reload.err" <<'PY'
import json, sys
rcs = [int(value) for value in sys.argv[1:6]]
def text(path):
    try:
        return open(path, encoding="utf-8", errors="replace").read()[-3000:]
    except FileNotFoundError:
        return ""
payload = {
    "ok": all(rc == 0 for rc in rcs),
    "serviceId": "${serviceId}",
    "hostname": "${exposure.dns.hostname}",
    "remotePort": ${exposure.frpc.remotePort},
    "caddyConfigPath": "${exposure.pk01.caddyConfigPath}",
    "service": "${exposure.pk01.caddyServiceName}",
    "managedBlock": {"start": "${markers.start}", "end": "${markers.end}"},
    "steps": {
        "update": {"exitCode": rcs[0], "stdout": text(sys.argv[6]), "stderr": text(sys.argv[7])},
        "fmt": {"exitCode": rcs[1], "stdout": text(sys.argv[8]), "stderr": text(sys.argv[9])},
        "validate": {"exitCode": rcs[2], "stdout": text(sys.argv[10]), "stderr": text(sys.argv[11])},
        "install": {"exitCode": rcs[3], "stdout": text(sys.argv[12]), "stderr": text(sys.argv[13])},
        "reload": {"exitCode": rcs[4], "stdout": text(sys.argv[14]), "stderr": text(sys.argv[15])},
    },
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
sys.exit(0 if payload["ok"] else 1)
PY
`;
  const result = await runSshCommandCapture(config, exposure.pk01.route, ["sh"], script);
  const parsed = parseJsonOutput(result.stdout);
  return parsed ?? { ok: false, capture: compactCapture(result, { full: true }) };
}

export function mergeDesiredCaddyfilePreservingManagedBlocksScript(params: {
  desiredPath: string;
  currentPath: string;
  mergedPath: string;
  outputPath: string;
  errorPath: string;
  legacySiteDomains?: string[];
}): string {
  const domains = params.legacySiteDomains ?? [];
  return `sudo python3 - ${shQuote(params.desiredPath)} ${shQuote(params.currentPath)} ${shQuote(params.mergedPath)} ${domains.map(shQuote).join(" ")} >${shQuote(params.outputPath)} 2>${shQuote(params.errorPath)} <<'PY'
${pk01CaddyMergeManagedBlocksPython()}
PY`;
}

export function applyLocalCaddyManagedSite(options: LocalCaddyManagedSiteOptions): Record<string, unknown> {
  const path = options.configPath;
  if (!existsSync(path)) return { ok: false, error: "caddy-config-missing", path, valuesPrinted: false };
  const before = readFileSync(path, "utf8");
  const managedBlock = renderCaddyManagedBlock(options.markerName, options.siteBlock);
  const migrated = options.legacySiteDomain === undefined ? before : removeLegacySiteBlocksOutsideManagedBlocks(before, options.legacySiteDomain);
  const next = upsertManagedBlock(migrated, managedBlock);
  const alreadyConfigured = next === before;
  let backupPath: string | null = null;
  let action = "kept-existing";
  const tmpPath = `${path}.unidesk-${safeFilePart(options.serviceId)}.tmp`;
  let validate: SpawnSyncReturns<string>;
  let reload: SpawnSyncReturns<string> | null = null;
  if (!alreadyConfigured) {
    writeFileSync(tmpPath, next, "utf8");
    chmodSync(tmpPath, statSync(path).mode & 0o777);
    validate = spawnSync("caddy", ["validate", "--config", tmpPath, "--adapter", "caddyfile"], { encoding: "utf8" });
    if (validate.status === 0) {
      const stamp = new Date().toISOString().replace(/[-:]/gu, "").replace(/\..*$/u, "Z");
      backupPath = `${path}.bak-${options.serviceId}-https-${stamp}`;
      copyFileSync(path, backupPath);
      writeFileSync(path, next, "utf8");
      chmodSync(path, statSync(backupPath).mode & 0o777);
      action = before.includes(caddyManagedBlockMarkers(options.markerName).start) ? "updated-managed-block" : "added-managed-block";
      reload = spawnSync("systemctl", ["reload", options.serviceName], { encoding: "utf8" });
    } else {
      action = "validate-failed-no-mutation";
    }
    try {
      unlinkSync(tmpPath);
    } catch {
      // best-effort cleanup only
    }
  } else {
    validate = spawnSync("caddy", ["validate", "--config", path, "--adapter", "caddyfile"], { encoding: "utf8" });
  }
  const active = spawnSync("systemctl", ["is-active", options.serviceName], { encoding: "utf8" });
  const ok = validate.status === 0 && (reload === null || reload.status === 0) && active.status === 0 && String(active.stdout).trim() === "active";
  return {
    ok,
    action,
    path,
    backupPath,
    serviceId: options.serviceId,
    markerName: options.markerName,
    serviceName: options.serviceName,
    alreadyConfigured,
    validate: spawnSummary(validate, 1000, 2000),
    reload: reload === null ? null : spawnSummary(reload, 1000, 1000),
    active: spawnSummary(active, 1000, 1000),
    valuesPrinted: false,
  };
}

export function upsertManagedBlock(text: string, block: string): string {
  const marker = parseManagedBlockMarker(block);
  if (marker === null) throw new Error("managed Caddy block is missing UniDesk BEGIN/END markers");
  const pattern = managedBlockPattern();
  let matched = false;
  const replaced = text.replace(pattern, (matchedText: string, name: string) => {
    if (name !== marker) return matchedText;
    matched = true;
    return `${block.trim()}\n\n`;
  }).replace(/\s+$/u, "\n");
  if (matched) return replaced;
  return `${text.trimEnd()}\n\n${block.trim()}\n`;
}

export function caddySiteBlock(text: string, domain: string): string | null {
  const startMatch = new RegExp(`(^|\\n)${escapeRegExp(domain)}\\s*\\{`, "u").exec(text);
  if (startMatch === null) return null;
  const start = startMatch.index + (startMatch[1] === "\n" ? 1 : 0);
  const open = text.indexOf("{", start);
  if (open < 0) return null;
  let depth = 0;
  for (let index = open; index < text.length; index += 1) {
    const ch = text[index];
    if (ch === "{") depth += 1;
    if (ch === "}") {
      depth -= 1;
      if (depth === 0) return text.slice(start, index + 1);
    }
  }
  return null;
}

export function removeLegacySiteBlocksOutsideManagedBlocks(text: string, domain: string): string {
  const pattern = managedBlockPattern();
  let cursor = 0;
  let next = "";
  for (const match of text.matchAll(pattern)) {
    const index = match.index ?? 0;
    next += removeCaddySiteBlocks(text.slice(cursor, index), domain);
    next += match[0];
    cursor = index + match[0].length;
  }
  next += removeCaddySiteBlocks(text.slice(cursor), domain);
  return next;
}

export function shQuote(value: string): string {
  return `'${value.replaceAll("'", "'\"'\"'")}'`;
}

export function parseJsonOutput(stdout: string): Record<string, unknown> | null {
  const trimmed = stdout.trim();
  if (!trimmed) return null;
  const candidates = [trimmed, trimmed.slice(trimmed.indexOf("{"))].filter((item) => item.startsWith("{"));
  for (const candidate of candidates) {
    try {
      const parsed = JSON.parse(candidate) as unknown;
      if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) return parsed as Record<string, unknown>;
    } catch {
      continue;
    }
  }
  return null;
}

export function compactCapture(result: SshCaptureResult, options: { full?: boolean } = {}): Record<string, unknown> {
  const full = options.full === true;
  return {
    exitCode: result.exitCode,
    timedOut: result.timedOut,
    stdoutTail: full || result.exitCode !== 0 ? result.stdout.slice(-8000) : "",
    stderrTail: full || result.exitCode !== 0 ? result.stderr.slice(-4000) : "",
  };
}

function pk01CaddyApplyManagedBlockPython(): string {
  return String.raw`import pathlib
import re
import sys

config_path = pathlib.Path(sys.argv[1])
block_path = pathlib.Path(sys.argv[2])
next_path = pathlib.Path(sys.argv[3])
text = config_path.read_text(encoding="utf-8") if config_path.exists() else ""
block = block_path.read_text(encoding="utf-8").strip() + "\n"

marker_match = re.match(r"(?s)^# BEGIN unidesk managed (?P<name>[^\n]+)\n.*\n# END unidesk managed (?P=name)\n?$", block)
if marker_match is None:
    raise SystemExit("block missing matching UniDesk managed markers")
marker_name = marker_match.group("name")
pattern = re.compile(r"(?ms)^# BEGIN unidesk managed (?P<name>[^\n]+)\n(?P<body>.*?)\n# END unidesk managed (?P=name)\n*")
matched = False

def replace(match):
    global matched
    if match.group("name") != marker_name:
        return match.group(0)
    matched = True
    return block.rstrip() + "\n\n"

next_text = pattern.sub(replace, text).rstrip() + "\n"
if not matched:
    next_text = text.rstrip() + "\n\n" + block
next_path.write_text(next_text, encoding="utf-8")
print("update" if matched else "append")`;
}

export function pk01CaddyMergeManagedBlocksPython(): string {
  return String.raw`import pathlib
import json
import re
import sys

desired_path = pathlib.Path(sys.argv[1])
current_path = pathlib.Path(sys.argv[2])
merged_path = pathlib.Path(sys.argv[3])
legacy_domains = sys.argv[4:]
desired = desired_path.read_text(encoding="utf-8")
current = current_path.read_text(encoding="utf-8") if current_path.exists() else ""
pattern = re.compile(r"(?ms)^# BEGIN unidesk managed (?P<name>[^\n]+)\n(?P<body>.*?)\n# END unidesk managed (?P=name)\n*")

def caddy_site_block(text, domain):
    match = re.search(r"(^|\n)" + re.escape(domain) + r"\s*\{", text)
    if match is None:
        return None
    start = match.start() + (1 if match.group(1) == "\n" else 0)
    open_index = text.find("{", start)
    if open_index < 0:
        return None
    depth = 0
    for index in range(open_index, len(text)):
        char = text[index]
        if char == "{":
            depth += 1
        elif char == "}":
            depth -= 1
            if depth == 0:
                return text[start:index + 1]
    return None

def remove_site_blocks(text, domain):
    while True:
        block = caddy_site_block(text, domain)
        if block is None:
            return re.sub(r"\n{3,}", "\n\n", text)
        text = text.replace(block, "")

blocks = {}
order = []
for source in (current, desired):
    for match in pattern.finditer(source):
        name = match.group("name")
        if name not in blocks:
            order.append(name)
        blocks[name] = match.group(0).rstrip() + "\n"

base = pattern.sub("", desired)
for domain in legacy_domains:
    base = remove_site_blocks(base, domain)
base = base.rstrip()
managed = "\n\n".join(blocks[name].rstrip() for name in order)
next_text = base
if managed:
    next_text = next_text + "\n\n" + managed
merged_path.write_text(next_text.rstrip() + "\n", encoding="utf-8")
print(json.dumps({"currentManagedBlocks": len(pattern.findall(current)), "desiredManagedBlocks": len(pattern.findall(desired)), "mergedManagedBlocks": len(blocks)}))`;
}

function managedBlockPattern(): RegExp {
  return /^# BEGIN unidesk managed ([^\n]+)\n(?:(?!^# END unidesk managed \1$).*\n)*# END unidesk managed \1\n*/gmu;
}

function parseManagedBlockMarker(block: string): string | null {
  const match = /^# BEGIN unidesk managed ([^\n]+)\n[\s\S]*\n# END unidesk managed \1\s*$/u.exec(block.trim());
  return match?.[1] ?? null;
}

function removeCaddySiteBlocks(text: string, domain: string): string {
  let next = text;
  while (true) {
    const block = caddySiteBlock(next, domain);
    if (block === null) return next;
    next = next.replace(block, "").replace(/\n{3,}/gu, "\n\n");
  }
}

function markerNameFromMarkers(markers: Pk01CaddyManagedBlockMarkers): string {
  const begin = "# BEGIN unidesk managed ";
  const end = "# END unidesk managed ";
  if (!markers.start.startsWith(begin) || !markers.end.startsWith(end)) throw new Error("invalid UniDesk managed Caddy markers");
  const startName = markers.start.slice(begin.length);
  const endName = markers.end.slice(end.length);
  if (startName !== endName) throw new Error("managed Caddy marker start/end mismatch");
  return startName;
}

function safeFilePart(value: string): string {
  return value.replace(/[^A-Za-z0-9_.-]/gu, "_");
}

function spawnSummary(result: SpawnSyncReturns<string>, stdoutLimit: number, stderrLimit: number): Record<string, unknown> {
  return {
    exitCode: result.status,
    stdoutTail: String(result.stdout).slice(-stdoutLimit),
    stderrTail: String(result.stderr).slice(-stderrLimit),
  };
}

function escapeRegExp(value: string): string {
  return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
}
