import { describe, test } from "node:test";
import assert from "node:assert/strict";

import {
  launchdPlist,
  crontabLine,
  installInstructions,
  uninstallInstructions,
  defaultLabel,
  type ScheduleSpec,
} from "../src/schedule.js";

// A fully-resolved spec, exactly as the CLI builds it at generation time (every value injected —
// the emitters read no clock/fs/env). `env` is canonical: always { NODE_BIN, LINKEDIN_STUDIO_DATA }.
function launchdSpec(): ScheduleSpec {
  return {
    platform: "launchd",
    label: "com.linkedin-studio.trends.daily",
    nodeBin: "/usr/local/bin/node",
    wrapperPath: "/repo/scripts/trends/run-daily.sh",
    args: ["--pillars", "ai,gov", "--fresh-days", "7"],
    hour: 7,
    minute: 30,
    logPath: "/data/trends/cron.log",
    workingDir: "/repo/scripts/trends",
    env: { NODE_BIN: "/usr/local/bin/node", LINKEDIN_STUDIO_DATA: "/data" },
  };
}
const cronSpec = (): ScheduleSpec => ({ ...launchdSpec(), platform: "cron" });

function isBalancedXml(xml: string): boolean {
  const stack: string[] = [];
  const re = /<(\/?)([a-zA-Z][\w.:-]*)[^>]*?(\/?)>/g;
  let m: RegExpExecArray | null;
  while ((m = re.exec(xml)) !== null) {
    if (m[3] === "/") continue;
    if (m[1] === "/") {
      if (stack.pop() !== m[2]) return false;
    } else {
      stack.push(m[2]);
    }
  }
  return stack.length === 0;
}

describe("schedule.ts — pure launchd plist emitter (SC1)", () => {
  test("key-complete: Label, ProgramArguments, StartCalendarInterval(H/M), Std*Path, EnvironmentVariables", () => {
    const p = launchdPlist(launchdSpec());
    assert.ok(p.includes("<key>Label</key>") && p.includes("com.linkedin-studio.trends.daily"), "Label");
    assert.ok(p.includes("<key>ProgramArguments</key>") && p.includes("/bin/bash") && p.includes("run-daily.sh"), "ProgramArguments");
    assert.ok(p.includes("--pillars") && p.includes("ai,gov"), "the args are rendered as <string> entries");
    assert.ok(p.includes("<key>StartCalendarInterval</key>"), "StartCalendarInterval");
    assert.match(p, /<key>Hour<\/key>\s*<integer>7<\/integer>/, "Hour 7");
    assert.match(p, /<key>Minute<\/key>\s*<integer>30<\/integer>/, "Minute 30");
    assert.ok(p.includes("<key>StandardOutPath</key>") && p.includes("<key>StandardErrorPath</key>"), "Std*Path");
    assert.ok(p.includes("/data/trends/cron.log"), "Std*Path point at the resolved cron.log");
    assert.ok(p.includes("<key>EnvironmentVariables</key>") && p.includes("NODE_BIN") && p.includes("LINKEDIN_STUDIO_DATA"), "env rendered");
    assert.ok(p.includes("<key>WorkingDirectory</key>") && p.includes("/repo/scripts/trends"), "WorkingDirectory");
    assert.ok(p.includes("RunAtLoad"), "RunAtLoad declared");
  });

  test("well-formed: balanced tags + plist DOCTYPE wrapper", () => {
    const p = launchdPlist(launchdSpec());
    assert.ok(p.startsWith("<?xml"), "begins with the XML declaration");
    assert.ok(p.includes("<!DOCTYPE plist"), "has the plist DOCTYPE");
    assert.ok(p.trimEnd().endsWith("</plist>"), "closes the plist element");
    assert.ok(isBalancedXml(p), "every element tag is balanced");
  });

  test("pure / deterministic: two calls are byte-identical", () => {
    assert.equal(launchdPlist(launchdSpec()), launchdPlist(launchdSpec()));
  });
});

describe("schedule.ts — pure cron line emitter (SC2, string only)", () => {
  test("the line carries time, env prefix, /bin/bash, wrapper, args, log redirect, label comment", () => {
    const line = crontabLine(cronSpec());
    assert.match(line, /^30 7 \* \* \* /, "fires 07:30 daily");
    assert.ok(line.includes("NODE_BIN=/usr/local/bin/node"), "the env prefix carries NODE_BIN");
    assert.ok(line.includes("LINKEDIN_STUDIO_DATA=/data"), "the env prefix carries the data root");
    assert.ok(line.includes("/bin/bash") && line.includes("run-daily.sh"), "invokes the wrapper via bash");
    assert.ok(line.includes("--pillars ai,gov"), "carries the pillars");
    assert.ok(line.includes(">> /data/trends/cron.log 2>&1"), "redirects stdout+stderr to the log");
    assert.ok(line.trimEnd().endsWith("# com.linkedin-studio.trends.daily"), "ends with the label comment");
    assert.ok(!line.includes("\n"), "a single line");
  });
});

describe("schedule.ts — install / uninstall instruction strings", () => {
  const plistTarget = "/home/u/Library/LaunchAgents/com.linkedin-studio.trends.daily.plist";

  test("install launchd: written-path + launchctl bootstrap recipe", () => {
    const out = installInstructions(launchdSpec(), plistTarget);
    assert.ok(out.includes(plistTarget), "names the written plist path");
    assert.ok(out.includes("launchctl bootstrap"), "the activation recipe");
  });

  test("install cron: the crontab - install recipe carrying the line", () => {
    const out = installInstructions(cronSpec());
    assert.ok(out.includes("crontab -"), "the install recipe");
    assert.ok(out.includes("run-daily.sh"), "carries the line to install");
  });

  test("uninstall launchd: bootout + rm the plist", () => {
    const out = uninstallInstructions(launchdSpec(), plistTarget);
    assert.ok(out.includes("launchctl bootout"), "the deactivation recipe");
    assert.ok(out.includes(plistTarget), "removes the plist file");
  });

  test("uninstall cron: the line-removal recipe", () => {
    const out = uninstallInstructions(cronSpec());
    assert.ok(out.includes("crontab -") && out.includes("com.linkedin-studio.trends.daily"), "removes by the label comment");
  });
});

describe("schedule.ts — defaultLabel (SC1/SC2 the plugin namespace)", () => {
  test("is the reverse-DNS plugin namespace", () => {
    assert.equal(defaultLabel(), "com.linkedin-studio.trends.daily");
  });
});
