import { readFile } from "fs/promises"
import path from "path"
import { describe, expect, test } from "bun:test"

// Cross-skill contract parity for ce-babysit-pr's delegation seams. These tokens are protocol
// shared between a producer skill and a consumer skill; a rename or drop on one side alone breaks
// the loop silently (babysit mis-parses a ce-debug status, or references a trajectory field
// pr-snapshot no longer emits). Each assertion below fails under exactly that one-sided drift.
//
// Sensitivity note: presence/exact-set based. It catches renames and drops (the drift that
// actually happens); a field added to BOTH sides in the same change is in sync and intentionally
// does not fail. The emitter-set check additionally catches an emitter-only addition.

async function readRepoFile(relativePath: string): Promise<string> {
  return readFile(path.join(process.cwd(), relativePath), "utf8")
}

const BABYSIT = "skills/ce-babysit-pr/SKILL.md"
const CEDEBUG_PIPELINE = "skills/ce-debug/references/pipeline-mode.md"
const CERESOLVE = "skills/ce-resolve-pr-feedback/SKILL.md"
const CERESOLVE_FULL_MODE = "skills/ce-resolve-pr-feedback/references/full-mode.md"
const PR_SNAPSHOT = "skills/ce-babysit-pr/scripts/pr-snapshot"

// ce-debug's pipeline-mode structured return. babysit branches on this exact set (Step 2 step 5)
// and warns "do not invent infra-retry/stale" — so both the vocabulary and the ban are protocol.
const CEDEBUG_STATUS = ["fixed-and-pushed", "diagnosed-no-fix", "flaky-infra", "needs-human"]

// pr-snapshot's trajectory block (emitted by _update_trajectory). The subset each consumer cites
// by name is protocol for that consumer: rename a field in the emitter and the citation dangles.
const TRAJECTORY_FIELDS = [
  "check_recur_max",
  "recurring_checks",
  "unresolved_threads",
  "unresolved_series",
  "unresolved_trend",
  "new_threads_this_tick",
  "stream_alternations",
  "heads_since_progress",
]
const BABYSIT_TRAJECTORY_REFS = [
  "check_recur_max",
  "recurring_checks",
  "unresolved_trend",
  "new_threads_this_tick",
  "stream_alternations",
  "heads_since_progress",
]
const CERESOLVE_TRAJECTORY_REFS = ["unresolved_trend", "new_threads_this_tick"]

function emittedTrajectoryKeys(script: string): string[] {
  const fn = script.slice(script.indexOf("def _update_trajectory"))
  const retStart = fn.indexOf("return {")
  const block = fn.slice(retStart, fn.indexOf("\n    }", retStart))
  return [...block.matchAll(/"([a-z_]+)":/g)].map((m) => m[1])
}

describe("ce-babysit-pr cross-skill contract parity", () => {
  test("ce-debug pipeline return-status enum agrees between producer and babysit consumer", async () => {
    const [producer, consumer] = await Promise.all([readRepoFile(CEDEBUG_PIPELINE), readRepoFile(BABYSIT)])
    for (const status of CEDEBUG_STATUS) {
      expect(producer, `ce-debug must still emit '${status}'`).toContain(status)
      expect(consumer, `babysit must still branch on '${status}'`).toContain(status)
    }
    // The ban babysit states must remain true of the producer, or the warning is stale.
    expect(producer).not.toContain("infra-retry")
    expect(consumer).toContain("do not invent `infra-retry`")
  })

  test("pr-snapshot emits exactly the canonical trajectory field set", async () => {
    const keys = emittedTrajectoryKeys(await readRepoFile(PR_SNAPSHOT))
    expect(keys.sort()).toEqual([...TRAJECTORY_FIELDS].sort())
  })

  test("the delegated-mutation exclusion boundary is stated at all three ends of the chain", async () => {
    // babysit passes a bounded scope whose exclusions (never rebase/force-push/merge/approve) the
    // delegates must honor. If either child drops the boundary, babysit's contract is one-sided.
    // 'rebase' and 'force-push' are specific enough to canary the exclusion block; 'merge' is not
    // (merge-ready / merge conflict are ordinary prose here).
    const [babysit, cedebug, ceresolve] = await Promise.all([
      readRepoFile(BABYSIT),
      readRepoFile(CEDEBUG_PIPELINE),
      readRepoFile(CERESOLVE),
    ])
    for (const [name, text] of [["babysit", babysit], ["ce-debug", cedebug], ["ce-resolve", ceresolve]] as const) {
      expect(text, `${name} must state the 'rebase' exclusion`).toContain("rebase")
      expect(text, `${name} must state the 'force-push' exclusion`).toContain("force-push")
    }
  })

  test("every trajectory field cited in consumer prose is one pr-snapshot actually emits", async () => {
    const script = await readRepoFile(PR_SNAPSHOT)
    const emitted = new Set(emittedTrajectoryKeys(script))
    const [babysit, ceresolve] = await Promise.all([readRepoFile(BABYSIT), readRepoFile(CERESOLVE)])
    for (const field of BABYSIT_TRAJECTORY_REFS) {
      expect(emitted.has(field), `babysit cites '${field}' but pr-snapshot no longer emits it`).toBe(true)
      expect(babysit).toContain(field)
    }
    for (const field of CERESOLVE_TRAJECTORY_REFS) {
      expect(emitted.has(field), `ce-resolve cites '${field}' but pr-snapshot no longer emits it`).toBe(true)
      expect(ceresolve).toContain(field)
    }
  })

  test("babysit's default mode is a self-sustaining in-session watch backed by pr-snapshot watch", async () => {
    // The self-initiation contract: babysit does NOT do one tick and hand back a resume command by
    // default; it backgrounds the token-free change-detector and stays in-session, woken by a sentinel.
    const [babysit, script] = await Promise.all([readRepoFile(BABYSIT), readRepoFile(PR_SNAPSHOT)])
    expect(babysit, "must describe the self-sustaining in-session watch").toMatch(/self-sustaining[, ]+in-session watch/i)
    expect(babysit, "must invoke the pr-snapshot watch detector").toContain("pr-snapshot watch")
    expect(babysit, "must wait on the BABYSIT_WAKE sentinel").toContain("BABYSIT_WAKE")
    // producer side: the watch subcommand emits the sentinel and can wake on each precedence reason
    expect(script).toContain("def cmd_watch")
    expect(script).toContain("BABYSIT_WAKE")
    for (const reason of ["terminal", "blocked-external", "actionable", "needs-human", "merge-ready"]) {
      expect(script, `watch must be able to wake on '${reason}'`).toContain(reason)
    }
  })

  test("babysit reconciles every passed comment so the loop can settle (never-settle fix)", async () => {
    // Regression guard: marking only the comments ce-resolve explicitly 'handled' left its
    // silently-dropped bot wrappers actionable forever, so counts.comments never reached 0.
    const babysit = await readRepoFile(BABYSIT)
    expect(babysit).toContain("silently drops")
    expect(babysit, "must mark every passed comment, not only the handled ones").toMatch(/mark \*?every\*? comment you passed/i)
    expect(babysit).toContain("never settle")
  })

  test("ce-resolve routes a whole-PR URL to full mode, a comment-fragment URL to targeted", async () => {
    // babysit hands ce-resolve the fork->upstream PR URL; a bare /pull/N must run full mode against
    // the parsed host/repo, while a comment-fragment URL stays targeted.
    const ceresolve = await readRepoFile(CERESOLVE)
    expect(ceresolve).toContain("PR URL")
    expect(ceresolve).toContain("no comment fragment")
    expect(ceresolve).toMatch(/#discussion_r|#issuecomment/)
  })

  test("ce-resolve passes the GHE host inline on every bundled-script call, not via one export", async () => {
    // A single `export GH_HOST` does not survive between separate Bash tool calls, so each script
    // call carries the host inline; on GHE, dropping it silently queries github.com.
    const fullMode = await readRepoFile(CERESOLVE_FULL_MODE)
    const prefixCount = (fullMode.match(/GH_HOST=<derived-host>/g) || []).length
    expect(prefixCount, "each bundled-script call needs its own inline GH_HOST prefix").toBeGreaterThanOrEqual(4)
    expect(fullMode, "must state that a single export does not carry between Bash calls").toContain("does **not** carry")
  })

  test("babysit's final merge-ready checkpoint self-refreshes a stale PR description via ce-commit-push-pr", async () => {
    // Incremental commits during a watch leave the PR description stale; babysit must reflect on
    // that before declaring merge-ready and route a stale one to ce-commit-push-pr's description
    // update — autonomously, as an owned/pre-authorized mutation, not a user prompt.
    const babysit = await readRepoFile(BABYSIT)
    expect(babysit).toContain("PR-description freshness")
    expect(babysit).toContain("description-update mode")
    expect(babysit).toContain("ce-commit-push-pr")
    expect(babysit, "description refresh must be in the owned mutation envelope").toMatch(/refresh(es|ing) (the |a )PR description/)
  })

  test("bounded-class sweep contract: babysit routes it, ce-resolve classifies/enumerates/bounds it", async () => {
    // A correct finding recurring across sibling sites must be swept as one class, not dripped
    // one-per-head. The split is protocol: babysit only recognizes + routes ("request a
    // bounded-class assessment"); ce-resolve owns whether the sites are equivalent, the enumerated
    // locations, and the fixer's mutation boundary. Dropping either side silently reverts to
    // one-site-per-round (babysit routes a request nothing fulfills, or the resolver never sweeps).
    const [babysit, watchLoop, fullMode, rubric, fixer] = await Promise.all([
      readRepoFile(BABYSIT),
      readRepoFile("skills/ce-babysit-pr/references/watch-loop.md"),
      readRepoFile(CERESOLVE_FULL_MODE),
      readRepoFile("skills/ce-resolve-pr-feedback/references/evaluation-rubric.md"),
      readRepoFile("skills/ce-resolve-pr-feedback/references/agents/pr-comment-resolver.md"),
    ])
    // Babysit side: recognizes + routes, does not decide/execute the sweep.
    expect(babysit, "babysit Step 2 must request the assessment inline").toMatch(/bounded-class assessment/i)
    expect(watchLoop).toMatch(/bounded-class assessment/i)
    // Resolver side: owns classification, enumeration, and the fixer's enumerated mutation boundary.
    expect(rubric).toContain("A validated finding can span sites this PR itself introduced")
    expect(fullMode).toMatch(/Class fix:/)
    expect(fixer, "class-fix mutation boundary must reach the fixer prompt").toMatch(/enumerated set is the mutation boundary/i)
  })
})
