#!/usr/bin/env bun
import { spawnSync } from "node:child_process";
import { mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { runtimeGitopsScriptsConfigMap } from "../hwlab/runtime-gitops-scripts-configmap.mjs";
import { hwlabRuntimeLaneSpecForNode } from "../../src/hwlab-node-lanes.ts";

function option(name) {
  const index = process.argv.indexOf(name);
  if (index === -1) return null;
  const value = process.argv[index + 1];
  if (value === undefined || value.length === 0 || value.startsWith("--")) throw new Error(`${name} requires a value`);
  return value;
}

function record(value, path) {
  if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`);
  return value;
}

function required(value, path) {
  if (typeof value !== "string" || value.length === 0 || value.includes("\n")) throw new Error(`${path} must be a non-empty single-line string`);
  return value;
}

function run(command, args, cwd, allowFailure = false) {
  const result = spawnSync(command, args, { cwd, encoding: "utf8", maxBuffer: 4 * 1024 * 1024 });
  if (result.error !== undefined) throw result.error;
  if (result.status !== 0 && !allowFailure) {
    const detail = `${result.stderr || result.stdout || "command failed"}`.trim().slice(-2000);
    throw new Error(`${command} ${args.join(" ")} failed (${result.status}): ${detail}`);
  }
  return result;
}

function safeManifestPath(value, pathLabel) {
  const path = required(value, pathLabel);
  if (path.startsWith("/") || path.split("/").includes("..")) throw new Error(`${pathLabel} must be a safe relative path`);
  return path;
}

function safeReleaseStatePath(value, pathLabel) {
  const path = required(value, pathLabel);
  if (path.startsWith("/") || path.split("/").includes("..")) throw new Error(`${pathLabel} must be a safe relative path`);
  return path;
}

function safeResourceManifestPath(value, pathLabel) {
  const path = required(value, pathLabel);
  if (path.startsWith("/") || path.split("/").includes("..") || !path.endsWith(".yaml")) throw new Error(`${pathLabel} must be a safe relative .yaml path`);
  return path;
}

export function renderGitOpsResources(gitops, sourceRoot) {
  if (gitops.resources === undefined) return [];
  if (!Array.isArray(gitops.resources)) throw new Error("delivery.gitops.resources must be an array");
  return gitops.resources.map((value, index) => {
    const pathLabel = `delivery.gitops.resources[${index}]`;
    const resource = record(value, pathLabel);
    const renderer = required(resource.renderer, `${pathLabel}.renderer`);
    if (renderer !== "hwlab-runtime-gitops-scripts") throw new Error(`${pathLabel}.renderer is unsupported: ${renderer}`);
    const configRef = required(resource.configRef, `${pathLabel}.configRef`);
    const match = /^config\/hwlab-node-lanes\.yaml#lanes\.([^.]+)\.targets\.([^.]+)$/u.exec(configRef);
    if (match === null) throw new Error(`${pathLabel}.configRef must select config/hwlab-node-lanes.yaml#lanes.<lane>.targets.<node>`);
    const lane = match[1];
    const node = match[2];
    const spec = hwlabRuntimeLaneSpecForNode(lane, node);
    const overlay = {
      nodeId: spec.nodeId,
      lane: spec.lane,
      runtimePath: spec.runtimePath,
      observability: spec.observability,
      codeAgentRuntime: spec.codeAgentRuntime,
    };
    const configMapName = `${spec.pipeline}-runtime-gitops-scripts`;
    const manifest = Bun.YAML.stringify(runtimeGitopsScriptsConfigMap({
      overlay,
      scriptsDir: resolve(sourceRoot, "scripts/native/hwlab"),
      featureConfigValidatorPath: resolve(sourceRoot, "scripts/native/cicd/feature-config-schema-warning.mjs"),
      ajvBundlePath: resolve(sourceRoot, "scripts/vendor/ajv-dist/8.17.1/ajv2020.min.js"),
      namespace: required(resource.namespace, `${pathLabel}.namespace`),
      configMapName,
    }));
    return {
      id: required(resource.id, `${pathLabel}.id`),
      path: safeResourceManifestPath(resource.manifestPath, `${pathLabel}.manifestPath`),
      content: `${manifest.trimEnd()}\n`,
    };
  });
}

function releaseValue(releaseDir, name) {
  return required(readFileSync(resolve(releaseDir, name), "utf8").trim(), `${releaseDir}/${name}`);
}

function valueAt(root, reference) {
  return reference.split(".").reduce((value, key) => record(value, reference)[key], root);
}

function deliveryServices(config, delivery) {
  if (!Array.isArray(delivery.services) || delivery.services.length === 0) throw new Error("delivery.services must be a non-empty array");
  const ids = new Set();
  return delivery.services.map((value, index) => {
    const path = `delivery.services[${index}]`;
    const item = record(value, path);
    const id = required(item.id, `${path}.id`);
    if (!/^[a-z0-9](?:[-a-z0-9]{0,62}[a-z0-9])?$/u.test(id) || ids.has(id)) throw new Error(`${path}.id must be unique DNS label`);
    ids.add(id);
    const serviceRef = required(item.serviceRef, `${path}.serviceRef`);
    record(valueAt(config, serviceRef), serviceRef);
    const image = record(item.image, `${path}.image`);
    const gitops = record(item.gitops, `${path}.gitops`);
    return {
      id,
      serviceRef,
      imageRepository: required(image.repository, `${path}.image.repository`),
      manifestPath: safeManifestPath(gitops.manifestPath, `${path}.gitops.manifestPath`),
      releaseStatePath: safeReleaseStatePath(gitops.releaseStatePath, `${path}.gitops.releaseStatePath`),
    };
  });
}

function removeFile(path) {
  try {
    unlinkSync(path);
  } catch (error) {
    if (error?.code !== "ENOENT") throw error;
  }
}

function readReleaseState(path, serviceRef) {
  let value;
  try {
    value = record(JSON.parse(readFileSync(path, "utf8")), path);
  } catch (error) {
    if (error?.code === "ENOENT") return null;
    throw error;
  }
  if (value.kind !== "UniDeskHostReleaseState") throw new Error(`${path}.kind must be UniDeskHostReleaseState`);
  if (value.serviceRef !== serviceRef) throw new Error(`${path}.serviceRef must match ${serviceRef}`);
  const sourceCommit = required(value.sourceCommit, `${path}.sourceCommit`);
  const digest = required(value.digest, `${path}.digest`);
  const digestRef = required(value.digestRef, `${path}.digestRef`);
  if (!/^[0-9a-f]{40}$/u.test(sourceCommit)) throw new Error(`${path}.sourceCommit must be a full Git commit SHA`);
  if (!/^sha256:[0-9a-f]{64}$/u.test(digest)) throw new Error(`${path}.digest must be sha256:<64 hex>`);
  return { sourceCommit, digest, digestRef };
}

function main() {
const configPath = resolve(option("--config") ?? "config/unidesk-host-k8s.yaml");
const sourceRoot = resolve(option("--source-root") ?? process.cwd());
const metadataDir = resolve(required(option("--metadata"), "--metadata"));
const releaseDir = resolve(required(option("--release-dir"), "--release-dir"));
const sourceCommit = required(option("--source-commit"), "--source-commit");
const worktree = resolve(option("--worktree") ?? "/workspace/unidesk-host-gitops");
if (!/^[0-9a-f]{40}$/u.test(sourceCommit)) throw new Error("--source-commit must be a full Git commit SHA");

const config = record(Bun.YAML.parse(readFileSync(configPath, "utf8")), configPath);
const delivery = record(config.delivery, "delivery");
if (delivery.enabled !== true && delivery.enabled !== false) throw new Error("delivery.enabled must be boolean");
const enabled = delivery.enabled === true;
const services = deliveryServices(config, delivery);
const gitops = record(delivery.gitops, "delivery.gitops");
const author = record(gitops.author, "delivery.gitops.author");
const readUrl = required(gitops.readUrl, "delivery.gitops.readUrl");
const writeUrl = required(gitops.writeUrl, "delivery.gitops.writeUrl");
const branch = required(gitops.branch, "delivery.gitops.branch");

rmSync(worktree, { recursive: true, force: true });
run("git", ["clone", "--no-checkout", readUrl, worktree], sourceRoot);
const fetched = run("git", ["fetch", "origin", branch], worktree, true).status === 0;
if (fetched) {
  run("git", ["checkout", "-B", branch, `origin/${branch}`], worktree);
} else {
  run("git", ["checkout", "--orphan", branch], worktree);
  run("git", ["rm", "-rf", "."], worktree, true);
}

const serviceResults = [];
for (const service of services) {
  const serviceDir = resolve(releaseDir, "services", service.id);
  const action = releaseValue(serviceDir, "action");
  const reason = releaseValue(serviceDir, "reason");
  const baselineSourceCommit = readFileSync(resolve(serviceDir, "baseline-source-commit"), "utf8").trim() || null;
  if (!new Set(["build", "skip", "disabled"]).has(action)) throw new Error(`${serviceDir}/action must be build, skip, or disabled`);
  if ((enabled && action === "disabled") || (!enabled && action !== "disabled")) throw new Error(`${service.id} release action does not match delivery.enabled`);
  const targetPath = resolve(worktree, service.manifestPath);
  const statePath = resolve(worktree, service.releaseStatePath);
  if (!targetPath.startsWith(`${worktree}/`) || !statePath.startsWith(`${worktree}/`)) throw new Error(`${service.id} resolved path escaped the GitOps worktree`);
  const existingState = readReleaseState(statePath, service.serviceRef);
  let digest = null;
  let digestRef = null;
  let runtimeSourceCommit = null;
  if (action === "build") {
    const metadataPath = resolve(metadataDir, `${service.id}.json`);
    const metadata = record(JSON.parse(readFileSync(metadataPath, "utf8")), metadataPath);
    digest = required(metadata["containerimage.digest"], `${metadataPath}.containerimage.digest`);
    if (!/^sha256:[0-9a-f]{64}$/u.test(digest)) throw new Error(`${service.id} digest must be sha256:<64 hex>`);
    digestRef = `${service.imageRepository}@${digest}`;
    runtimeSourceCommit = sourceCommit;
    const render = run("bun", [
      "scripts/native/deploy/render-unidesk-host-service.mjs",
      "--config", configPath,
      "--service-ref", service.serviceRef,
      "--image", digestRef,
      "--source-commit", sourceCommit,
    ], sourceRoot);
    if (!render.stdout.includes("kind: Deployment") || !render.stdout.includes("kind: Service")) throw new Error(`${service.id} renderer did not emit Service and Deployment objects`);
    mkdirSync(dirname(targetPath), { recursive: true });
    writeFileSync(targetPath, render.stdout, "utf8");
    mkdirSync(dirname(statePath), { recursive: true });
    writeFileSync(statePath, `${JSON.stringify({ version: 1, kind: "UniDeskHostReleaseState", serviceRef: service.serviceRef, sourceCommit, digest, digestRef }, null, 2)}\n`, "utf8");
  } else if (action === "skip") {
    if (existingState === null) throw new Error(`${service.id} skip requires an existing GitOps release state`);
    ({ digest, digestRef, sourceCommit: runtimeSourceCommit } = existingState);
  } else {
    removeFile(targetPath);
    removeFile(statePath);
  }
  serviceResults.push({ id: service.id, serviceRef: service.serviceRef, action, reason, baselineSourceCommit, runtimeSourceCommit, digest, digestRef, manifestPath: service.manifestPath, releaseStatePath: service.releaseStatePath });
}

const action = serviceResults.some((item) => item.action === "build") ? "build" : enabled ? "skip" : "disabled";
const gitopsResources = renderGitOpsResources(gitops, sourceRoot);

for (const resource of gitopsResources) {
  const resourcePath = resolve(worktree, resource.path);
  if (!resourcePath.startsWith(`${worktree}/`)) throw new Error(`resolved resource path escaped the GitOps worktree: ${resource.id}`);
  if (enabled) {
    mkdirSync(dirname(resourcePath), { recursive: true });
    writeFileSync(resourcePath, resource.content, "utf8");
  } else {
    removeFile(resourcePath);
  }
}

run("git", ["config", "user.name", required(author.name, "delivery.gitops.author.name")], worktree);
run("git", ["config", "user.email", required(author.email, "delivery.gitops.author.email")], worktree);
run("git", ["add", "-A"], worktree);
const changedResourceIds = gitopsResources
  .filter((resource) => run("git", ["diff", "--cached", "--quiet", "--", resource.path], worktree, true).status !== 0)
  .map((resource) => resource.id);
const resourceChanged = changedResourceIds.length > 0;
const changed = run("git", ["diff", "--cached", "--quiet"], worktree, true).status !== 0;
if (changed) {
  const commitAction = action === "disabled" ? "remove" : "deploy";
  const changedTargets = [
    ...serviceResults.filter((item) => item.action !== "skip").map((item) => item.id),
    ...changedResourceIds,
  ].join(",");
  run("git", ["commit", "-m", `unidesk-host: ${commitAction} ${changedTargets} ${sourceCommit.slice(0, 12)}`], worktree);
}

run("git", ["remote", "set-url", "origin", writeUrl], worktree);
let pushAttempts = 0;
if (changed) {
  let pushed = false;
  for (let attempt = 1; attempt <= 3; attempt += 1) {
    pushAttempts = attempt;
    const push = run("git", ["push", "origin", `HEAD:refs/heads/${branch}`], worktree, true);
    if (push.status === 0) {
      pushed = true;
      break;
    }
    run("git", ["fetch", "origin", branch], worktree);
    const rebase = run("git", ["rebase", `origin/${branch}`], worktree, true);
    if (rebase.status !== 0) {
      run("git", ["rebase", "--abort"], worktree, true);
      throw new Error(`GitOps rebase failed after push attempt ${attempt}`);
    }
  }
  if (!pushed) throw new Error("GitOps push failed after 3 attempts");
}

const head = run("git", ["rev-parse", "HEAD"], worktree, !enabled && !changed);
const gitopsCommit = head.status === 0 ? head.stdout.trim() : null;
process.stdout.write(`${JSON.stringify({
  ok: true,
  phase: "gitops-publish",
  action,
  status: action === "build" ? "built" : action === "disabled" ? "disabled" : changed ? "published" : "skipped",
  imageStatus: action === "build" ? "built" : action === "skip" ? "skipped" : "disabled",
  resourceStatus: enabled ? (resourceChanged ? "published" : "reused") : resourceChanged ? "removed" : "disabled",
  sourceCommit,
  gitopsCommit,
  affectedServices: serviceResults.filter((item) => item.action === "build").map((item) => item.id),
  buildServices: serviceResults.filter((item) => item.action === "build").map((item) => item.id),
  rolloutServices: serviceResults.filter((item) => item.action === "build").map((item) => item.id),
  reusedServices: serviceResults.filter((item) => item.action === "skip").map((item) => item.id),
  services: serviceResults,
  resources: gitopsResources.map((resource) => ({ id: resource.id, path: resource.path, changed: changedResourceIds.includes(resource.id) })),
  resourceChanged,
  changed,
  pushAttempts,
  valuesPrinted: false,
})}\n`);
}

if (import.meta.main && !process.execArgv.includes("--check")) main();
