"""Tests for PR6 `citadel curate` + `citadel status` + MCP/CLI parity.

Covers the whole surface: the offline detectors, the recompute-per-run plan, the
``--dry-run``/``--limit``/``--stale-rules``/``--diff`` driver, the attempt-capped revert-and-stop
cluster sessions, ``citadel status``, the CLI read/index/sources parity + the ``wiki_lint`` MCP
tool, and the locator lint checks. Each test imports the surface it exercises inside the test
body, so a collection stays green even mid-refactor.

All offline: the agent bridge is the conftest ``fake_agent`` (no real CLI is ever spawned); the
detectors and status readers are pure functions over a seeded ``tmp_citadel`` wiki + manifest.
"""

from __future__ import annotations

import argparse
import asyncio

from citadel import cli, config, lint, manifest, server


# --- seed helpers ---------------------------------------------------------------------------


def _seed_cited(
    seed_page,
    rel_path: str,
    resource: str,
    *,
    type_: str = "Concept",
    title: str = "Topic",
    body_fact: str = "A sourced fact.",
    timestamp: str | None = None,
    trailer: str = "",
) -> None:
    """Seed one valid OKF page whose single fact cites ``resource`` (a ``raw/…`` key), routed
    under ``rel_path``. ``trailer`` appends extra body markup (a second link, an [^llm] fact, a
    contradiction callout) before the ``## Sources`` section."""
    fm = {"type": type_, "title": title, "description": "d", "tags": ["t"], "resource": resource}
    if timestamp is not None:
        fm["timestamp"] = timestamp
    body = (
        f"{body_fact}[^s1]\n{trailer}\n\n## Sources\n\n"
        f"[^s1]: [{resource}](../../{resource}) - s (ingested 2026-06-21)\n"
    )
    seed_page(rel_path, fm, body)


def _track(key: str, sha: str, rules_version: str | None) -> None:
    """Record a manifest entry for a raw source ``key`` (load-modify-save), stamping ``sha`` and
    the effective-rules hash the importing session ran under — what curate's rules-drift and
    stale-reverify detectors read."""
    tracked = manifest.load()
    tracked[key] = manifest.make_entry(sha, "claude:sonnet", rules_version)
    manifest.save(tracked)


def _plan_pages(plan) -> set[str]:
    return {item.page for item in plan.items}


def _reasons_for(plan, page: str) -> set[str]:
    return {r for item in plan.items if item.page == page for r in item.reasons}


# --- offline detectors: each detector, one focused test ---


def test_detector_flags_rules_version_drift(tmp_citadel, seed_page):
    """A page whose source was ingested under an OLDER effective-rules hash than the current one
    is flagged for a re-curate pass (the manifest stamp vs config.rules_version())."""
    from citadel import curate

    (tmp_citadel.raw / "notes.md").write_text("body\n", encoding="utf-8")
    _seed_cited(seed_page, "concepts/topic.md", "raw/notes.md")
    _track("raw/notes.md", manifest.file_sha256(tmp_citadel.raw / "notes.md"), "0000oldrules")

    plan = curate.build_plan()
    assert "concepts/topic.md" in _plan_pages(plan)
    assert "rules_version_drift" in _reasons_for(plan, "concepts/topic.md")


def test_detector_flags_overlong_page_at_hard_threshold(tmp_citadel, seed_page):
    """Curate ACTS at the hard page-length threshold (~800 lines) — lint only warns at the soft
    one — so an oversized page is planned for a split."""
    from citadel import curate

    (tmp_citadel.raw / "notes.md").write_text("body\n", encoding="utf-8")
    long_body = "".join(f"Line {i} of a very long page.[^s1]\n\n" for i in range(900))
    seed_page(
        "concepts/huge.md",
        {"type": "Concept", "title": "Huge", "description": "d", "tags": ["t"], "resource": "raw/notes.md"},
        f"{long_body}## Sources\n\n[^s1]: [raw/notes.md](../../raw/notes.md) - s\n",
    )

    plan = curate.build_plan()
    assert "page_length_hard" in _reasons_for(plan, "concepts/huge.md")


def test_detector_flags_out_of_range_locator(tmp_citadel, seed_page):
    """The locator detector plans a re-curate for a `lines A-B` citation pointing past the end of
    its text source — the class of the harvested `lines 30-1992` on a 57-line source (#57). Detection
    reuses lint.check_locators; this locks that build_plan surfaces it as REASON_LOCATOR so the
    curate lifecycle repairs a hallucinated line range rather than leaving it as a silent advisory."""
    from citadel import curate

    (tmp_citadel.raw / "notes.md").write_text("line one\nline two\nline three\n", encoding="utf-8")  # 3 lines
    seed_page(
        "concepts/topic.md",
        {"type": "Concept", "title": "Topic", "description": "d", "tags": ["t"], "resource": "raw/notes.md"},
        "A fact.[^s1]\n\n## Sources\n\n[^s1]: [raw/notes.md](../../raw/notes.md), lines 30-1992 - out of range\n",
    )

    plan = curate.build_plan()
    assert "concepts/topic.md" in _plan_pages(plan)
    assert curate.REASON_LOCATOR in _reasons_for(plan, "concepts/topic.md")


def test_detector_flags_bad_source_citation(tmp_citadel, seed_page):
    """A page whose ``[^sN]`` definition links a raw file that does not exist (lint's
    "Fabricated/missing sources" — e.g. the dangling citations a FAILED deletion cleanup leaves
    behind) is planned under REASON_BAD_SOURCE, with repair guidance in the findings. This is
    what makes ingest's failure hint ("citadel lint and citadel curate can repair the dangling
    [^sN]") true: without the detector, curate never saw the defect."""
    from citadel import curate, store

    _seed_cited(seed_page, "concepts/dangling.md", "raw/gone.md")  # raw/gone.md is never written

    plan = curate.build_plan()
    assert "concepts/dangling.md" in _plan_pages(plan)
    assert curate.REASON_BAD_SOURCE in _reasons_for(plan, "concepts/dangling.md")

    # ... and the findings checklist carries the repair guidance for the agent.
    item = next(i for i in plan.items if i.page == "concepts/dangling.md")
    pages = {p.rel_path: p for p in store.load()}
    findings = curate._render_findings(item, pages["concepts/dangling.md"], {})
    assert "bad_source" in findings and "does not exist" in findings


def test_detector_ignores_healthy_citations(tmp_citadel, seed_page):
    """A page whose citation resolves to a real raw file is NOT flagged as bad_source."""
    from citadel import curate

    (tmp_citadel.raw / "notes.md").write_text("body\n", encoding="utf-8")
    _seed_cited(seed_page, "concepts/healthy.md", "raw/notes.md")

    plan = curate.build_plan()
    assert curate.REASON_BAD_SOURCE not in _reasons_for(plan, "concepts/healthy.md")


def test_detector_flags_unresolved_contradiction(tmp_citadel, seed_page):
    """A page carrying a `> [!CONTRADICTION]` callout with no resolution line is planned for a
    curate pass (attempt a confident resolution, per schema.md § Contradictions)."""
    from citadel import curate

    (tmp_citadel.raw / "a.md").write_text("body\n", encoding="utf-8")
    _seed_cited(
        seed_page,
        "concepts/clash.md",
        "raw/a.md",
        trailer="\n> [!CONTRADICTION]\n> raw/a.md reports 12% [^s1], but the page said 9%.",
    )

    plan = curate.build_plan()
    assert "contradiction" in _reasons_for(plan, "concepts/clash.md")


def test_detector_flags_orphan_page(tmp_citadel, seed_page):
    """A page nothing links to and that links to nothing (an island) is planned for cross-linking."""
    from citadel import curate

    (tmp_citadel.raw / "notes.md").write_text("body\n", encoding="utf-8")
    _seed_cited(seed_page, "concepts/island.md", "raw/notes.md")

    plan = curate.build_plan()
    assert "orphan" in _reasons_for(plan, "concepts/island.md")


def test_detector_flags_llm_drift_outlier(tmp_citadel, seed_page):
    """A page dominated by model-supplied `[^llm]` facts with little-to-no `[^sN]` grounding is a
    per-page drift-ratio outlier — planned for re-grounding against its sources."""
    from citadel import curate

    (tmp_citadel.raw / "notes.md").write_text("body\n", encoding="utf-8")
    body = (
        "Sourced.[^s1]\n\n"
        "Claim one.[^llm1]\n\nClaim two.[^llm2]\n\nClaim three.[^llm3]\n\nClaim four.[^llm4]\n\n"
        "## Sources\n\n"
        "[^s1]: [raw/notes.md](../../raw/notes.md) - s\n"
        "[^llm1]: LLM - model knowledge\n[^llm2]: LLM - model knowledge\n"
        "[^llm3]: LLM - model knowledge\n[^llm4]: LLM - model knowledge\n"
    )
    seed_page(
        "concepts/drifted.md",
        {"type": "Concept", "title": "Drifted", "description": "d", "tags": ["t"], "resource": "raw/notes.md"},
        body,
    )

    plan = curate.build_plan()
    assert "llm_drift" in _reasons_for(plan, "concepts/drifted.md")


def test_detector_flags_type_folder_mismatch_for_resort(tmp_citadel, seed_page):
    """A page whose `type` routes to a different folder than the one it sits in (okf.folder_for_type)
    is planned for a re-sort — the owner's 'Information umsortieren', made concrete."""
    from citadel import curate

    (tmp_citadel.raw / "notes.md").write_text("body\n", encoding="utf-8")
    # A Person page mis-filed under concepts/ (folder_for_type("Person") == "persons").
    _seed_cited(seed_page, "concepts/alice.md", "raw/notes.md", type_="Person", title="Alice")

    plan = curate.build_plan()
    assert "resort" in _reasons_for(plan, "concepts/alice.md")


def test_registry_type_routes_cleanly_for_resort(tmp_citadel, seed_page):
    """`type: Registry` routes to `registries/` (okf.folder_for_type), so a registry page sitting
    there is correctly sorted — while the same type mis-filed under `concepts/` is still planned
    for a re-sort. Guards the type mapping landing together with the rules text: without it every
    registry page would be flagged (and moved to misc/) each run."""
    from citadel import curate

    (tmp_citadel.raw / "inventory.md").write_text("machines\n", encoding="utf-8")
    _seed_cited(
        seed_page, "registries/machine-registry.md", "raw/inventory.md", type_="Registry", title="Machine registry"
    )
    _seed_cited(seed_page, "concepts/misfiled.md", "raw/inventory.md", type_="Registry", title="Misfiled")

    plan = curate.build_plan()
    assert "resort" not in _reasons_for(plan, "registries/machine-registry.md")
    assert "resort" in _reasons_for(plan, "concepts/misfiled.md")


def test_registry_length_guidance_splits_by_range_not_topic(tmp_citadel, seed_page):
    """An over-threshold `Registry` page still trips page_length_hard, but its findings guidance
    tells the agent to split by entry range/subclass into sibling registries — the generic
    topic-split instruction would shred the rows across topic pages. A non-Registry page keeps
    the generic guidance."""
    from citadel import curate, store

    (tmp_citadel.raw / "inventory.md").write_text("machines\n", encoding="utf-8")
    rows = "".join(f"- **MX-{i:03d}** — machine {i}.[^s1]\n" for i in range(900))
    seed_page(
        "registries/machine-registry.md",
        {
            "type": "Registry",
            "title": "Machine registry",
            "description": "d",
            "tags": ["t"],
            "resource": "raw/inventory.md",
        },
        f"{rows}\n## Sources\n\n[^s1]: [raw/inventory.md](../../raw/inventory.md) - s\n",
    )
    long_body = "".join(f"Line {i} of a very long page.[^s1]\n\n" for i in range(900))
    seed_page(
        "concepts/huge.md",
        {"type": "Concept", "title": "Huge", "description": "d", "tags": ["t"], "resource": "raw/inventory.md"},
        f"{long_body}## Sources\n\n[^s1]: [raw/inventory.md](../../raw/inventory.md) - s\n",
    )

    plan = curate.build_plan()
    pages = {p.rel_path: p for p in store.load()}
    by_page = {item.page: item for item in plan.items}
    assert "page_length_hard" in _reasons_for(plan, "registries/machine-registry.md")

    registry_findings = curate._render_findings(
        by_page["registries/machine-registry.md"], pages["registries/machine-registry.md"], {}
    )
    assert "entry range or subclass" in registry_findings
    assert "Split it along its topics" not in registry_findings

    concept_findings = curate._render_findings(by_page["concepts/huge.md"], pages["concepts/huge.md"], {})
    assert "Split it along its topics" in concept_findings


def test_reverify_candidates_are_prefiltered_to_sha_unchanged_sources(tmp_citadel, seed_page):
    """Fact re-verification is pre-filtered offline via manifest shas: a source whose bytes
    CHANGED is reconcile's job, a source that is GONE is delete's job — only sha-UNCHANGED tracked
    sources need the agent's entailment pass, so only they are re-verify candidates."""
    from citadel import curate

    (tmp_citadel.raw / "same.md").write_text("stable\n", encoding="utf-8")
    (tmp_citadel.raw / "changed.md").write_text("new bytes\n", encoding="utf-8")
    _seed_cited(seed_page, "concepts/same.md", "raw/same.md")
    _seed_cited(seed_page, "concepts/changed.md", "raw/changed.md", title="Changed")
    _seed_cited(seed_page, "concepts/gone.md", "raw/gone.md", title="Gone")  # source never on disk
    _track("raw/same.md", manifest.file_sha256(tmp_citadel.raw / "same.md"), config.rules_version())
    _track("raw/changed.md", "deadbeef" * 8, config.rules_version())  # manifest sha != file sha
    _track("raw/gone.md", "cafef00d" * 8, config.rules_version())  # file vanished

    candidates = curate.reverify_candidates()
    assert "raw/same.md" in candidates
    assert "raw/changed.md" not in candidates  # changed -> reconcile
    assert "raw/gone.md" not in candidates  # gone -> delete


def test_reverify_sampling_is_wired_into_the_plan(tmp_citadel, seed_page):
    """Fact re-verification is WIRED into build_plan: a stale page whose cited source is tracked
    and sha-UNCHANGED is sampled into the plan under reason ``reverify`` (a page whose source is
    changed/gone never lands here — that is reconcile's / delete's job)."""
    from citadel import curate

    (tmp_citadel.raw / "notes.md").write_text("stable\n", encoding="utf-8")
    _seed_cited(seed_page, "concepts/topic.md", "raw/notes.md", timestamp="2000-01-01T00:00:00Z")
    _track("raw/notes.md", manifest.file_sha256(tmp_citadel.raw / "notes.md"), config.rules_version())

    plan = curate.build_plan()
    assert "reverify" in _reasons_for(plan, "concepts/topic.md")


# --- the plan: recompute per run, --dry-run zero sessions, --limit, --stale-rules ------------


def test_dry_run_recomputes_plan_and_runs_zero_agent_sessions(tmp_citadel, seed_page, fake_agent):
    """There is NO persisted queue: --dry-run recomputes the plan from the detectors and prints
    it, spawning zero agent sessions and leaving the wiki byte-for-byte untouched (the wiki IS the
    database)."""
    from citadel import curate

    agent = fake_agent()
    (tmp_citadel.raw / "notes.md").write_text("body\n", encoding="utf-8")
    _seed_cited(seed_page, "concepts/alice.md", "raw/notes.md", type_="Person", title="Alice")
    before = (tmp_citadel.wiki / "concepts/alice.md").read_text(encoding="utf-8")

    report = curate.curate(dry_run=True)
    assert agent.count == 0  # no session on a dry run
    assert "concepts/alice.md" in _plan_pages(report.plan)
    assert (tmp_citadel.wiki / "concepts/alice.md").read_text(encoding="utf-8") == before


def test_limit_caps_the_recomputed_plan(tmp_citadel, seed_page):
    """--limit N takes only the first N clusters of the (deterministically ordered) plan."""
    from citadel import curate

    (tmp_citadel.raw / "notes.md").write_text("body\n", encoding="utf-8")
    for slug in ("a", "b", "c"):
        _seed_cited(seed_page, f"concepts/{slug}.md", "raw/notes.md", type_="Person", title=slug.upper())

    assert len(curate.build_plan().items) >= 3
    assert len(curate.build_plan(limit=2).items) == 2


def test_stale_rules_selects_only_stale_stamped_sources_pages(tmp_citadel, seed_page):
    """--stale-rules narrows the plan to pages whose source was ingested under an older rules hash;
    a page whose source carries the CURRENT hash is excluded even if other detectors would flag it."""
    from citadel import curate

    (tmp_citadel.raw / "old.md").write_text("body\n", encoding="utf-8")
    (tmp_citadel.raw / "new.md").write_text("body\n", encoding="utf-8")
    _seed_cited(seed_page, "concepts/old.md", "raw/old.md", title="Old")
    _seed_cited(seed_page, "concepts/new.md", "raw/new.md", title="New")
    _track("raw/old.md", manifest.file_sha256(tmp_citadel.raw / "old.md"), "0000oldrules")
    _track("raw/new.md", manifest.file_sha256(tmp_citadel.raw / "new.md"), config.rules_version())

    pages = _plan_pages(curate.build_plan(stale_rules=True))
    assert "concepts/old.md" in pages
    assert "concepts/new.md" not in pages


# --- the operator steer: `curate --guidance TEXT [paths]` ------------------------------------


def test_guidance_plans_the_named_pages_no_detector_needed(tmp_citadel, seed_page, monkeypatch):
    """`curate --guidance "…" <paths>`: the human IS the finding. A page every detector considers
    clean is planned under `operator_guidance` when it is named on the command line — that is the
    whole point (a badly named page trips no offline detector). Without the steer the same paths
    only FILTER the detector plan, so a clean page stays unplanned."""
    from citadel import curate

    (tmp_citadel.raw / "notes.md").write_text("body\n", encoding="utf-8")
    # The link keeps both pages out of the orphan detector: the plan below must come from the steer.
    _seed_cited(seed_page, "concepts/topic.md", "raw/notes.md", title="Topic", trailer="See [Other](other.md).")
    _seed_cited(seed_page, "concepts/other.md", "raw/notes.md", title="Other")

    assert _plan_pages(curate.curate(["concepts/topic.md"], dry_run=True).plan) == set()

    monkeypatch.setattr(config, "CURATE_GUIDANCE", "retitle these to '<machine no> <model>'", raising=False)
    report = curate.curate(["concepts/topic.md"], dry_run=True)
    assert _plan_pages(report.plan) == {"concepts/topic.md"}
    assert _reasons_for(report.plan, "concepts/topic.md") == {curate.REASON_OPERATOR}


def test_guidance_without_paths_never_fans_out_over_the_wiki(tmp_citadel, seed_page, fake_agent, monkeypatch):
    """A steer with NO paths rides along on the detector plan — it never plans a clean page. One
    paid session per page of a whole clean wiki is exactly what must not happen by accident."""
    from citadel import curate

    agent = fake_agent()
    (tmp_citadel.raw / "notes.md").write_text("body\n", encoding="utf-8")
    _seed_cited(seed_page, "concepts/topic.md", "raw/notes.md", title="Topic", trailer="See [Other](other.md).")
    _seed_cited(seed_page, "concepts/other.md", "raw/notes.md", title="Other")
    monkeypatch.setattr(config, "CURATE_GUIDANCE", "merge everything", raising=False)

    report = curate.curate()
    assert report.plan.items == [] and agent.count == 0


def test_guidance_reaches_the_cluster_findings_file(tmp_citadel, seed_page, fake_agent, monkeypatch):
    """The steer travels in the per-cluster FINDINGS file (curate's designated instruction channel,
    read by path), together with the frame that bounds it: structure yes, facts and provenance
    never. The cluster's cited raw sources are named there too — a retitle is decided against the
    sources, not the page's own wording."""
    from pathlib import Path

    from citadel import curate

    seen: list[str] = []

    def capture(*_args, **kwargs):
        seen.append(Path(kwargs["read_path"]).read_text(encoding="utf-8"))

    (tmp_citadel.raw / "notes.md").write_text("body\n", encoding="utf-8")
    _seed_cited(seed_page, "concepts/topic.md", "raw/notes.md", title="Topic")
    fake_agent(side_effect=capture)
    monkeypatch.setattr(config, "CURATE_GUIDANCE", "move the machine pages into registries/", raising=False)

    curate.curate(["concepts/topic.md"])
    assert len(seen) == 1
    findings = seen[0]
    assert "Operator guidance for THIS run" in findings
    assert "move the machine pages into registries/" in findings
    assert "raw/notes.md" in findings  # the cluster's cited sources, to be re-read
    assert "re-attributed" in findings  # the frame: a steer can never change what the wiki SAYS
    assert curate.REASON_OPERATOR in findings


def test_findings_carry_no_operator_section_without_a_steer(tmp_citadel, seed_page):
    """Default off: with no steer configured the findings file is byte-for-byte the pre-feature
    checklist — no empty section, no dangling heading."""
    from citadel import curate, store

    (tmp_citadel.raw / "notes.md").write_text("body\n", encoding="utf-8")
    _seed_cited(seed_page, "concepts/topic.md", "raw/notes.md", title="Topic")
    pages = {page.rel_path: page for page in store.load()}
    item = curate.PlanItem(page="concepts/topic.md", reasons=[curate.REASON_ORPHAN])

    assert "Operator guidance" not in curate._render_findings(item, pages["concepts/topic.md"], {})


def test_paths_select_by_folder_and_glob_and_report_what_matched_nothing(tmp_citadel, seed_page, monkeypatch):
    """A structural steer usually applies to a FAMILY of pages, so a path argument may be a folder
    or a glob as well as one page. An argument that matches nothing is reported on the run report
    — under a steer, a typo'd path would otherwise read as "the wiki is clean"."""
    from citadel import curate

    (tmp_citadel.raw / "notes.md").write_text("body\n", encoding="utf-8")
    for slug in ("m-1", "m-2"):
        _seed_cited(seed_page, f"registries/{slug}.md", "raw/notes.md", type_="Registry", title=slug)
    _seed_cited(seed_page, "concepts/topic.md", "raw/notes.md", title="Topic")
    monkeypatch.setattr(config, "CURATE_GUIDANCE", "retitle them", raising=False)

    assert _plan_pages(curate.curate(["registries/"], dry_run=True).plan) == {"registries/m-1.md", "registries/m-2.md"}
    assert _plan_pages(curate.curate(["registries/m-*.md"], dry_run=True).plan) == {
        "registries/m-1.md",
        "registries/m-2.md",
    }

    report = curate.curate(["concepts/typo.md"], dry_run=True)
    assert report.plan.items == [] and report.unmatched == ["concepts/typo.md"]
    assert "concepts/typo.md" in report.render()


def test_scope_is_applied_before_the_limit(tmp_citadel, seed_page, monkeypatch):
    """--limit N counts the SCOPED plan ("the first N of what I asked for"), not the first N of the
    whole wiki filtered afterwards — which would silently curate nothing."""
    from citadel import curate

    (tmp_citadel.raw / "notes.md").write_text("body\n", encoding="utf-8")
    for slug in ("a", "b", "c"):
        _seed_cited(seed_page, f"concepts/{slug}.md", "raw/notes.md", title=slug.upper())
    monkeypatch.setattr(config, "CURATE_GUIDANCE", "tighten the titles", raising=False)

    report = curate.curate(["concepts/c.md"], dry_run=True, limit=1)
    assert _plan_pages(report.plan) == {"concepts/c.md"}


def test_stale_rules_never_drops_an_explicitly_guided_page(tmp_citadel, seed_page, monkeypatch):
    """--stale-rules is a selector over the DETECTOR plan; an explicitly named page under a steer is
    an instruction and survives it."""
    from citadel import curate

    (tmp_citadel.raw / "notes.md").write_text("body\n", encoding="utf-8")
    _seed_cited(seed_page, "concepts/topic.md", "raw/notes.md", title="Topic")
    _track("raw/notes.md", manifest.file_sha256(tmp_citadel.raw / "notes.md"), config.rules_version())
    monkeypatch.setattr(config, "CURATE_GUIDANCE", "re-sort", raising=False)

    report = curate.curate(["concepts/topic.md"], dry_run=True, stale_rules=True)
    assert _plan_pages(report.plan) == {"concepts/topic.md"}


def test_guided_retitle_moves_the_page_and_keeps_every_citation(tmp_citadel, seed_page, fake_agent, monkeypatch):
    """The steer's headline use case, end to end: a badly named page is RETITLED and re-routed
    into another folder. No detector flags a bad name, so this run exists only because a human
    asked — and the restructure must obey the invariant that makes it safe: the fact travels
    unchanged with its `[^sN]` marker AND its `## Sources` definition, the old file is gone, and the
    neighbor's inbound link follows the page (`core.md` § Restructuring). The promote is
    all-or-nothing, so `applied` means the result passed the same gate `citadel check` runs."""
    from citadel import curate, store

    wiki, raw = tmp_citadel.wiki, tmp_citadel.raw
    (raw / "register.md").write_text("HAL-7 is the Hallenkran in bay 7.\n", encoding="utf-8")
    _seed_cited(seed_page, "concepts/m-1.md", "raw/register.md", title="Maschine 1", body_fact="HAL-7 stands in bay 7.")
    _seed_cited(seed_page, "concepts/hall.md", "raw/register.md", title="Hall", trailer="See [Maschine 1](./m-1.md).")

    def retitle(*_args, **_kwargs):
        """What the agent does under the steer: write the renamed page (carrying the fact and its
        citation verbatim), delete the old file, repoint the inbound link."""
        seed_page(
            "registries/hal-7.md",
            {
                "type": "Registry",
                "title": "HAL-7 Hallenkran",
                "description": "d",
                "tags": ["t"],
                "resource": "raw/register.md",
            },
            "HAL-7 stands in bay 7.[^s1]\n\n## Sources\n\n"
            "[^s1]: [raw/register.md](../../raw/register.md) - s (ingested 2026-06-21)\n",
        )
        (config.wiki_dir() / "concepts" / "m-1.md").unlink()
        hall = config.wiki_dir() / "concepts" / "hall.md"
        hall.write_text(
            hall.read_text(encoding="utf-8").replace("(./m-1.md)", "(../registries/hal-7.md)"), encoding="utf-8"
        )

    fake_agent(side_effect=retitle)
    monkeypatch.setattr(config, "CURATE_GUIDANCE", "retitle the machine pages to '<machine no> <model>'", raising=False)

    report = curate.curate(["concepts/m-1.md"])

    assert report.applied == ["concepts/m-1.md"] and report.failed == []
    assert not (wiki / "concepts" / "m-1.md").exists()
    renamed = (wiki / "registries" / "hal-7.md").read_text(encoding="utf-8")
    assert "HAL-7 stands in bay 7.[^s1]" in renamed  # the fact and its marker, unchanged
    assert "[^s1]: [raw/register.md](../../raw/register.md)" in renamed  # ... and its definition
    assert "(../registries/hal-7.md)" in (wiki / "concepts" / "hall.md").read_text(encoding="utf-8")
    assert store.find_broken_links() == []  # the link graph survived the move


def test_guided_repivot_regroups_projects_into_product_pages(tmp_citadel, seed_page, fake_agent, monkeypatch):
    """The steer's hardest restructure: RE-PIVOT the wiki's axis. The source lists projects (each
    with its product and location), so ingest built one page per PROJECT — the only shape the
    source itself suggests. The owner wants the wiki keyed by PRODUCT instead, each product page
    carrying its projects and their locations. No detector can want that: both shapes are
    structurally clean, and which one is USEFUL is exactly the judgment only the owner has.

    What must survive the regrouping is the per-fact provenance (`schema.md`): every fact keeps its
    own `[^sN]` marker AND its `## Sources` definition WITH its line locator, so a fact that moves
    from a project page to a product page still points at the line of the source it came from. The
    hub page's links follow the pages, and two projects sharing a product MERGE into one page —
    curate runs one session per cluster serially, so the second session sees (and extends) what the
    first one promoted."""
    from pathlib import Path

    from citadel import curate, store

    wiki, raw = tmp_citadel.wiki, tmp_citadel.raw
    (raw / "projects.md").write_text(
        "Project Alpha - product Kran-X - location Bremen\n"
        "Project Beta - product Kran-X - location Hamburg\n"
        "Project Gamma - product Foerderband-Y - location Bremen\n",
        encoding="utf-8",
    )

    def _project(slug: str, title: str, fact: str, line: int) -> None:
        seed_page(
            f"projects/{slug}.md",
            {"type": "Project", "title": title, "description": "d", "tags": ["t"], "resource": "raw/projects.md"},
            f"{fact}[^s1]\n\n## Sources\n\n"
            f"[^s1]: [raw/projects.md](../../raw/projects.md), lines {line}-{line} - s (ingested 2026-06-21)\n",
        )

    _project("alpha", "Alpha", "Alpha builds Kran-X at Bremen.", 1)
    _project("beta", "Beta", "Beta builds Kran-X at Hamburg.", 2)
    _project("gamma", "Gamma", "Gamma builds Foerderband-Y at Bremen.", 3)
    # A hub page links all three, so none of them is an orphan: the plan below comes from the steer
    # alone — and the hub's links have to follow the pages the re-pivot dissolves.
    seed_page(
        "concepts/portfolio.md",
        {"type": "Concept", "title": "Portfolio", "description": "d", "tags": ["t"], "resource": "raw/projects.md"},
        "The portfolio spans [Alpha](../projects/alpha.md), [Beta](../projects/beta.md) and "
        "[Gamma](../projects/gamma.md).[^s1]\n\n## Sources\n\n"
        "[^s1]: [raw/projects.md](../../raw/projects.md) - s (ingested 2026-06-21)\n",
    )

    def _product_page(slug: str, title: str, fact: str, line: int) -> None:
        seed_page(
            f"objects/{slug}.md",
            {"type": "Object", "title": title, "description": "d", "tags": ["t"], "resource": "raw/projects.md"},
            f"{fact}[^s1]\n\n## Sources\n\n"
            f"[^s1]: [raw/projects.md](../../raw/projects.md), lines {line}-{line} - s (ingested 2026-06-21)\n",
        )

    def _repoint(old: str, new: str) -> None:
        hub = config.wiki_dir() / "concepts" / "portfolio.md"
        hub.write_text(hub.read_text(encoding="utf-8").replace(old, new), encoding="utf-8")

    findings_seen: dict[str, str] = {}

    def repivot(*args, **kwargs):
        """One session per cluster, in plan order — what the agent does under the steer."""
        anchor = args[0]
        findings_seen[anchor] = Path(kwargs["read_path"]).read_text(encoding="utf-8")
        if anchor == "projects/alpha.md":
            _product_page("kran-x", "Kran-X", "Alpha builds Kran-X at Bremen.", 1)
            _repoint("[Alpha](../projects/alpha.md)", "[Kran-X](../objects/kran-x.md)")
        elif anchor == "projects/beta.md":
            # MERGE into the page the previous cluster promoted: Beta's fact carries its own
            # marker + definition, so both projects stay individually cited on one product page.
            target = config.wiki_dir() / "objects" / "kran-x.md"
            text = target.read_text(encoding="utf-8")
            text = text.replace("\n## Sources", "\nBeta builds Kran-X at Hamburg.[^s2]\n\n## Sources")
            text = text.rstrip("\n") + (
                "\n[^s2]: [raw/projects.md](../../raw/projects.md), lines 2-2 - s (ingested 2026-06-21)\n"
            )
            target.write_text(text, encoding="utf-8")
            _repoint("[Beta](../projects/beta.md)", "[Kran-X](../objects/kran-x.md)")
        else:
            _product_page("foerderband-y", "Foerderband-Y", "Gamma builds Foerderband-Y at Bremen.", 3)
            _repoint("[Gamma](../projects/gamma.md)", "[Foerderband-Y](../objects/foerderband-y.md)")
        (config.wiki_dir() / anchor).unlink()

    fake_agent(side_effect=repivot)
    monkeypatch.setattr(
        config,
        "CURATE_GUIDANCE",
        "organise these by PRODUCT, not by project: one page per product listing its projects and locations",
        raising=False,
    )

    report = curate.curate(["projects/"])

    assert report.applied == ["projects/alpha.md", "projects/beta.md", "projects/gamma.md"]
    assert report.failed == []
    assert not (wiki / "projects").exists() or not list((wiki / "projects").glob("*.md"))

    kran = (wiki / "objects" / "kran-x.md").read_text(encoding="utf-8")
    assert "Alpha builds Kran-X at Bremen.[^s1]" in kran  # both projects merged onto ONE product page
    assert "Beta builds Kran-X at Hamburg.[^s2]" in kran
    assert "lines 1-1" in kran and "lines 2-2" in kran  # ... each keeping its own line locator
    band = (wiki / "objects" / "foerderband-y.md").read_text(encoding="utf-8")
    assert "Gamma builds Foerderband-Y at Bremen.[^s1]" in band and "lines 3-3" in band

    hub = (wiki / "concepts" / "portfolio.md").read_text(encoding="utf-8")
    assert "../projects/" not in hub  # the hub follows the re-pivot
    assert store.find_broken_links() == []
    assert lint.check_locators(store.load()) == []  # the moved locators still resolve

    # What lets the three independent sessions converge instead of each inventing its own product
    # page: every guided cluster's checklist names the OTHER anchors this run carries the same
    # steer to (never itself), and says they run in sequence.
    alpha_findings = findings_seen["projects/alpha.md"]
    assert "projects/beta.md, projects/gamma.md" in alpha_findings
    assert "# Curate findings — projects/alpha.md" in alpha_findings
    assert "each, in sequence: projects/alpha.md" not in alpha_findings  # the anchor is not its own sibling
    assert "search the wiki before creating anything" in alpha_findings


def test_guided_restructure_that_breaks_a_link_is_rolled_back(tmp_citadel, seed_page, fake_agent, monkeypatch):
    """The other half of the same use case: a rename is the operation most likely to leave a
    dangling link behind, and a steer makes renames common. A cluster whose promote WOULD introduce
    a broken cross-link is failed and rolled back whole — the live wiki is left exactly as it was,
    rather than carrying the damage until someone runs `citadel lint`."""
    from citadel import curate, store

    wiki, raw = tmp_citadel.wiki, tmp_citadel.raw
    (raw / "register.md").write_text("HAL-7 is the Hallenkran in bay 7.\n", encoding="utf-8")
    _seed_cited(seed_page, "concepts/m-1.md", "raw/register.md", title="Maschine 1")
    _seed_cited(seed_page, "concepts/hall.md", "raw/register.md", title="Hall", trailer="See [Maschine 1](./m-1.md).")
    before = (wiki / "concepts" / "m-1.md").read_text(encoding="utf-8")

    def retitle_forgetting_the_link(*_args, **_kwargs):
        seed_page(
            "registries/hal-7.md",
            {
                "type": "Registry",
                "title": "HAL-7 Hallenkran",
                "description": "d",
                "tags": ["t"],
                "resource": "raw/register.md",
            },
            "A sourced fact.[^s1]\n\n## Sources\n\n"
            "[^s1]: [raw/register.md](../../raw/register.md) - s (ingested 2026-06-21)\n",
        )
        (config.wiki_dir() / "concepts" / "m-1.md").unlink()  # concepts/hall.md still links here

    fake_agent(side_effect=retitle_forgetting_the_link)
    monkeypatch.setattr(config, "CURATE_GUIDANCE", "retitle the machine pages", raising=False)

    report = curate.curate(["concepts/m-1.md"])

    assert report.failed == ["concepts/m-1.md"] and report.applied == []
    assert "broken" in report.failed_details["concepts/m-1.md"].lower()
    assert (wiki / "concepts" / "m-1.md").read_text(encoding="utf-8") == before  # rolled back whole
    assert not (wiki / "registries" / "hal-7.md").exists()
    assert store.find_broken_links() == []


# --- the cluster session: staging diff decides NOOP / applied / failed (one _SourceJob) -----


def _seed_resort_cluster(tmp_citadel, seed_page):
    """A single deterministically-flagged cluster: a Person page mis-filed under concepts/."""
    (tmp_citadel.raw / "notes.md").write_text("body\n", encoding="utf-8")
    _seed_cited(seed_page, "concepts/alice.md", "raw/notes.md", type_="Person", title="Alice")


def test_cluster_applied_when_agent_edits_and_page_validates(tmp_citadel, seed_page, fake_agent):
    """A curate session that makes a clean, validated edit promotes onto the live wiki — the
    staging diff-by-hash is the SINGLE result arbiter (a non-empty, clean diff = applied)."""
    from citadel import curate

    _seed_resort_cluster(tmp_citadel, seed_page)
    fake_agent(
        pages={
            "concepts/alice.md": (
                {"type": "Person", "title": "Alice", "description": "d", "tags": ["t"], "resource": "raw/notes.md"},
                "Improved sourced fact.[^s1]\n\n## Sources\n\n[^s1]: [raw/notes.md](../../raw/notes.md) - s\n",
            )
        }
    )

    report = curate.curate()
    assert "concepts/alice.md" in report.applied
    assert "Improved sourced fact." in (tmp_citadel.wiki / "concepts/alice.md").read_text(encoding="utf-8")


def test_cluster_noop_when_agent_makes_no_edits(tmp_citadel, seed_page, fake_agent):
    """An agent that decides the findings do not hold up makes no edits; the empty staging diff is
    a NOOP — the wiki is unchanged and nothing is recorded as a failure."""
    from citadel import curate

    _seed_resort_cluster(tmp_citadel, seed_page)
    before = (tmp_citadel.wiki / "concepts/alice.md").read_text(encoding="utf-8")
    fake_agent(pages={})  # writes nothing into the staging copy

    report = curate.curate()
    assert "concepts/alice.md" in report.noop
    assert (tmp_citadel.wiki / "concepts/alice.md").read_text(encoding="utf-8") == before
    assert "concepts/alice.md" not in report.failed


def test_cluster_failed_validate_reverts_and_records_failure(tmp_citadel, seed_page, fake_agent):
    """A curate session whose edit fails the check gate promotes NOTHING — the live wiki is left
    exactly as it was (revert-and-stop) and the cluster is recorded in the failures catalog."""
    from citadel import curate, failures

    _seed_resort_cluster(tmp_citadel, seed_page)
    before = (tmp_citadel.wiki / "concepts/alice.md").read_text(encoding="utf-8")
    fake_agent(pages={"concepts/alice.md": "garbage with no frontmatter and no citations"})

    report = curate.curate()
    assert "concepts/alice.md" in report.failed
    assert (tmp_citadel.wiki / "concepts/alice.md").read_text(encoding="utf-8") == before  # untouched
    stuck = failures.load()
    assert "concepts/alice.md" in stuck
    assert stuck["concepts/alice.md"].get("attempts", 0) >= 1


def test_failed_cluster_reason_is_surfaced_in_the_report(tmp_citadel, seed_page, fake_agent):
    """WHY a cluster failed is rendered in the curate report itself — the same detail recorded in
    the failures catalog — so 'Failed (rolled back): N' is never a dead end the user must go read
    the catalog file to explain."""
    from citadel import curate

    _seed_resort_cluster(tmp_citadel, seed_page)
    fake_agent(pages={"concepts/alice.md": "garbage with no frontmatter and no citations"})

    report = curate.curate()
    assert "concepts/alice.md" in report.failed
    detail = report.failed_details["concepts/alice.md"]
    assert detail  # the recorded reason travels on the report, not just into the catalog
    rendered = report.render()
    assert curate._detail_line(detail) in rendered
    assert "--retry" in rendered  # the escape-hatch hint rides along when anything failed


def test_failed_cluster_is_attempt_capped_across_runs(tmp_citadel, seed_page, fake_agent):
    """A failing curate cluster is never auto-retried: its failures-catalog ``attempts`` counter
    increments per run and, once it reaches the cap (default 2), build_plan DROPS it from the
    runnable plan (into ``plan.skipped``) so no further agent session runs — and ``--dry-run``/
    ``--limit`` reflect that — until an explicit re-try."""
    from citadel import curate, failures

    _seed_resort_cluster(tmp_citadel, seed_page)
    agent = fake_agent(pages={"concepts/alice.md": "still garbage"})

    curate.curate()  # attempt 1
    assert agent.count == 1
    curate.curate()  # attempt 2 -> reaches the cap
    assert agent.count == 2
    assert failures.load()["concepts/alice.md"]["attempts"] == 2

    report = curate.curate()  # capped: dropped from the runnable plan, no new session
    assert agent.count == 2
    assert "concepts/alice.md" in report.skipped
    assert "concepts/alice.md" not in _plan_pages(report.plan)

    # --dry-run reflects what would actually run: the capped cluster is not a runnable item.
    dry = curate.curate(dry_run=True)
    assert "concepts/alice.md" not in _plan_pages(dry.plan)
    assert "concepts/alice.md" in dry.skipped

    # `citadel curate --retry` (force=True) is the documented escape hatch: the capped cluster
    # reappears as a runnable plan item instead of being skipped.
    retried = curate.curate(dry_run=True, force=True)
    assert "concepts/alice.md" in _plan_pages(retried.plan)
    assert "concepts/alice.md" not in retried.skipped


# --- transparency side-effects: log.md edit summary + --diff report --------------------------


def test_applied_cluster_appends_edit_summary_to_log(tmp_citadel, seed_page, fake_agent):
    from citadel import curate

    _seed_resort_cluster(tmp_citadel, seed_page)
    fake_agent(
        pages={
            "concepts/alice.md": (
                {"type": "Person", "title": "Alice", "description": "d", "tags": ["t"], "resource": "raw/notes.md"},
                "Improved sourced fact.[^s1]\n\n## Sources\n\n[^s1]: [raw/notes.md](../../raw/notes.md) - s\n",
            )
        }
    )

    curate.curate()
    log_text = config.log_path().read_text(encoding="utf-8")
    assert "curate" in log_text.lower()
    assert "concepts/alice.md" in log_text


def test_diff_report_is_written(tmp_citadel, seed_page, fake_agent):
    from citadel import curate

    _seed_resort_cluster(tmp_citadel, seed_page)
    fake_agent(
        pages={
            "concepts/alice.md": (
                {"type": "Person", "title": "Alice", "description": "d", "tags": ["t"], "resource": "raw/notes.md"},
                "Improved sourced fact.[^s1]\n\n## Sources\n\n[^s1]: [raw/notes.md](../../raw/notes.md) - s\n",
            )
        }
    )
    report_path = tmp_citadel.root / "curate-diff.md"

    curate.curate(diff=str(report_path))
    assert report_path.is_file()
    assert "concepts/alice.md" in report_path.read_text(encoding="utf-8")


# --- citadel status: per-source table from the manifest + failures catalog ------------


def test_status_reports_ingested_and_failed_sources(tmp_citadel, seed_page):
    """`citadel status` answers 'what state is my corpus in' — the ingested sources (with model +
    rules_version stamp) and the failed ones (reason + attempts), read from the manifest + the
    failures catalog."""
    from citadel import failures, status

    (tmp_citadel.raw / "good.md").write_text("body\n", encoding="utf-8")
    _track("raw/good.md", manifest.file_sha256(tmp_citadel.raw / "good.md"), config.rules_version())
    stuck: dict[str, dict] = {}
    failures.record(stuck, "raw/scan.tiff", failures.UNREADABLE, "no extractable text", "claude:sonnet")
    failures.save(stuck)

    report = status.build_status()
    text = report.render()
    assert "raw/good.md" in text  # the ingested side
    assert "raw/scan.tiff" in text and "unreadable" in text  # the failed side
    assert report.ingested and report.failed  # structured collections, not just prose


# --- MCP <-> CLI parity: read/index/sources CLI wrappers + wiki_lint tool --------------


def test_every_mcp_tool_has_a_cli_counterpart():
    """Full MCP<->CLI parity: every MCP tool has a CLI equivalent so an AI without MCP access can
    do everything through the CLI. lint/view stay CLI-only by design; wiki_lint closes the gap
    from the MCP side."""
    tools = {t.name for t in asyncio.run(server.mcp.list_tools())}
    assert "wiki_lint" in tools  # the MCP side of the parity

    parser = cli.build_parser()
    sub = next(a for a in parser._actions if isinstance(a, argparse._SubParsersAction))
    commands = set(sub.choices)
    mcp_to_cli = {
        "wiki_search": "search",
        "wiki_read": "read",
        "wiki_index": "index",
        "wiki_sources": "sources",
        "wiki_tags": "tags",
        "wiki_validate": "check",
        "wiki_ingest": "ingest",
        "wiki_lint": "lint",
    }
    for tool, subcommand in mcp_to_cli.items():
        assert subcommand in commands, f"MCP {tool} has no CLI counterpart {subcommand!r}"


def test_curate_retry_flag_maps_to_force(tmp_citadel, monkeypatch):
    """The `citadel curate --retry` CLI flag reaches ``curate`` as ``force=True`` — the documented
    retry path for attempt-capped clusters. Without it, ``force`` stays False."""
    from citadel import curate

    class _Report:
        failed: list[str] = []

        def render(self) -> str:
            return ""

    seen: list[dict] = []

    def spy(*_args, **kwargs):
        seen.append(kwargs)
        return _Report()

    monkeypatch.setattr(curate, "curate", spy)

    assert cli.main(["curate", "--retry"]) == 0
    assert seen[-1].get("force") is True

    assert cli.main(["curate"]) == 0
    assert seen[-1].get("force") is False
    """The new wiki_lint MCP tool honors the server's never-raise contract: an internal failure
    comes back as a clear error STRING, not an exception, so the server stays up."""
    wiki_lint = server.wiki_lint  # the never-raise MCP tool

    def boom(*a, **k):
        raise RuntimeError("kaboom")

    monkeypatch.setattr(lint, "lint", boom)
    out = wiki_lint()
    assert isinstance(out, str) and out.lower().startswith("error")


# --- soft-vs-hard page length: lint warns, curate acts ---------------------------------


def test_lint_warns_on_soft_overlong_page_as_advisory(tmp_citadel, seed_page):
    """ "lint warns at soft, curate acts at hard": a page over the SOFT page-length threshold is
    listed under lint's advisory ``long_pages`` but does NOT flip ok() — only `citadel curate` acts,
    and only once a page crosses the HARD threshold."""
    (tmp_citadel.raw / "notes.md").write_text("body\n", encoding="utf-8")
    long_body = "".join(f"Line {i}.[^s1]\n\n" for i in range(config.CURATE_PAGE_SOFT_LINES + 50))
    seed_page(
        "concepts/long.md",
        {"type": "Concept", "title": "Long", "description": "d", "tags": ["t"], "resource": "raw/notes.md"},
        f"{long_body}## Sources\n\n[^s1]: [raw/notes.md](../../raw/notes.md) - s\n",
    )

    report = lint.lint()
    assert "concepts/long.md" in report.long_pages
    assert report.ok()  # advisory: the soft threshold never flips the lint exit


# --- Locator lint checks for text-bearing raw sources ------------------------------------


def test_lint_flags_out_of_range_line_locator(tmp_citadel, seed_page):
    """A `lines A-B` locator pointing past the end of its (immutable) text source is a lint
    warning — the deterministic half of the locator provenance precision."""
    (tmp_citadel.raw / "notes.md").write_text("line one\nline two\nline three\n", encoding="utf-8")  # 3 lines
    seed_page(
        "concepts/topic.md",
        {"type": "Concept", "title": "Topic", "description": "d", "tags": ["t"], "resource": "raw/notes.md"},
        "A fact.[^s1]\n\n## Sources\n\n[^s1]: [raw/notes.md](../../raw/notes.md), lines 40-52 - out of range\n",
    )

    report = lint.lint()
    issues = report.locator_issues  # new LintReport field
    assert any("concepts/topic.md" == rel for rel, _detail in issues)


def test_lint_flags_missing_heading_locator(tmp_citadel, seed_page):
    """A `§ Heading` locator naming a heading that does not exist in its text source is a lint
    warning."""
    (tmp_citadel.raw / "spec.md").write_text("# Real Heading\n\nprose\n", encoding="utf-8")
    seed_page(
        "concepts/topic.md",
        {"type": "Concept", "title": "Topic", "description": "d", "tags": ["t"], "resource": "raw/spec.md"},
        "A fact.[^s1]\n\n## Sources\n\n[^s1]: [raw/spec.md](../../raw/spec.md), § Nonexistent Heading - x\n",
    )

    report = lint.lint()
    issues = report.locator_issues
    assert any("concepts/topic.md" == rel for rel, _detail in issues)


def test_lint_flags_prose_text_locator_that_is_not_a_heading(tmp_citadel, seed_page):
    """A `§ …` locator naming text that appears only in PROSE (never as a markdown heading) is
    flagged: the candidate set is restricted to real heading lines, so prose text can no longer
    smuggle a `§` locator past the check (the Copilot-review fix)."""
    (tmp_citadel.raw / "spec.md").write_text("# Real Heading\n\nDeep dive into the details\n", encoding="utf-8")
    seed_page(
        "concepts/topic.md",
        {"type": "Concept", "title": "Topic", "description": "d", "tags": ["t"], "resource": "raw/spec.md"},
        "A fact.[^s1]\n\n## Sources\n\n[^s1]: [raw/spec.md](../../raw/spec.md), § Deep dive into the details - x\n",
    )

    report = lint.lint()
    assert any("concepts/topic.md" == rel for rel, _detail in report.locator_issues)


def test_lint_passes_combined_heading_and_line_locator(tmp_citadel, seed_page):
    """The combined `§ Heading, line N` form the ingest agent emits verifies BOTH halves: a real
    heading plus an in-range line raises no warning. Previously the `, line N` tail was folded into
    the heading name and defeated the match, so a valid citation produced a false positive."""
    (tmp_citadel.raw / "spec.md").write_text(
        "# Overview\n\n## Real Heading\n\nprose line\nmore\n", encoding="utf-8"
    )  # 6 lines
    seed_page(
        "concepts/topic.md",
        {"type": "Concept", "title": "Topic", "description": "d", "tags": ["t"], "resource": "raw/spec.md"},
        "A fact.[^s1]\n\n## Sources\n\n[^s1]: [raw/spec.md](../../raw/spec.md), § Real Heading, line 5\n",
    )

    report = lint.lint()
    assert not any("concepts/topic.md" == rel for rel, _detail in report.locator_issues)


def test_lint_flags_combined_locator_with_out_of_range_line(tmp_citadel, seed_page):
    """The combined form verifies the LINE half too: a real heading but a line past the file's end is
    still flagged (both halves are checked, not just the heading)."""
    (tmp_citadel.raw / "spec.md").write_text("# Real Heading\n\nprose\n", encoding="utf-8")  # 3 lines
    seed_page(
        "concepts/topic.md",
        {"type": "Concept", "title": "Topic", "description": "d", "tags": ["t"], "resource": "raw/spec.md"},
        "A fact.[^s1]\n\n## Sources\n\n[^s1]: [raw/spec.md](../../raw/spec.md), § Real Heading, line 40\n",
    )

    report = lint.lint()
    assert any("concepts/topic.md" == rel for rel, _detail in report.locator_issues)


def test_lint_passes_genuine_heading_locator(tmp_citadel, seed_page):
    """A `§ Heading` locator that names a real markdown heading in its text source raises no
    locator warning — the restricted candidate set still recognizes true headings."""
    (tmp_citadel.raw / "spec.md").write_text("# Overview\n\n## Real Heading\n\nprose\n", encoding="utf-8")
    seed_page(
        "concepts/topic.md",
        {"type": "Concept", "title": "Topic", "description": "d", "tags": ["t"], "resource": "raw/spec.md"},
        "A fact.[^s1]\n\n## Sources\n\n[^s1]: [raw/spec.md](../../raw/spec.md), § Real Heading - x\n",
    )

    report = lint.lint()
    assert not any("concepts/topic.md" == rel for rel, _detail in report.locator_issues)


def test_lint_flags_fenced_pseudo_heading_locator(tmp_citadel, seed_page):
    """A `# heading` line that lives inside a ``` code fence is literal text, not a heading, so a
    `§` locator naming it is flagged (fence-aware candidate collection)."""
    (tmp_citadel.raw / "spec.md").write_text("# Real Heading\n\n```\n# Fenced Pseudo Heading\n```\n", encoding="utf-8")
    seed_page(
        "concepts/topic.md",
        {"type": "Concept", "title": "Topic", "description": "d", "tags": ["t"], "resource": "raw/spec.md"},
        "A fact.[^s1]\n\n## Sources\n\n[^s1]: [raw/spec.md](../../raw/spec.md), § Fenced Pseudo Heading - x\n",
    )

    report = lint.lint()
    assert any("concepts/topic.md" == rel for rel, _detail in report.locator_issues)
