"""Offline tests for the open-points threading overlay: the store parser + derived catalog
(``store.parse_open_points`` / ``collect_open_points`` / ``_render_open_points_catalog`` and the
``open-points/index.md`` generated by ``rebuild_indexes``) and the two advisory lint checks
(near-duplicate + malformed points).

No network, no LLM — pure-function parsing over hand-written page bodies plus a tmp wiki for the
rebuild wiring, mirroring ``test_sources_index.py``.
"""

from __future__ import annotations

import pytest

from citadel import catalogs, lint, okf, store
from citadel.okf import Page


# A page carrying two threads: one still open (latest bullet has no done keyword), one done
# (latest bullet says "resolved"). One bullet carries an inline link, to test flattening.
TWO_POINTS_BODY = """Checkout Service finalizes orders.[^s1]

## Open Points

### Checkout latency under load
id: op-checkout-latency
- 2026-05-04: raised; users report ~30s hangs. [^s1]
- 2026-06-10: root-caused in [Postgres](../systems/postgres.md); fix targeted for 4.2. [^s2]

### Logging gap
id: op-logging-gap
- 2026-05-04: no structured logs in checkout. [^s1]
- 2026-06-01: added structured logging; resolved. [^s3]

## See also

- [Postgres](../systems/postgres.md)
"""


def _page(rel_path: str, body: str, type_: str = "System", title: str = "Checkout Service") -> Page:
    return Page(rel_path=rel_path, frontmatter={"type": type_, "title": title}, body=body)


def test_parse_open_points_basic():
    points = store.parse_open_points(_page("systems/checkout.md", TWO_POINTS_BODY))
    assert [p.title for p in points] == ["Checkout latency under load", "Logging gap"]
    p0, p1 = points
    assert p0.point_id == "op-checkout-latency"
    assert len(p0.bullets) == 2
    assert p0.bullets[0] == ("2026-05-04", "raised; users report ~30s hangs. [^s1]")
    assert p0.status == "open"  # latest bullet ("fix targeted") has no done keyword
    assert p0.last_date == "2026-06-10"
    assert p1.point_id == "op-logging-gap"
    assert p1.status == "done"  # latest bullet says "resolved"
    assert p1.last_date == "2026-06-01"


def test_status_reopen_beats_done():
    body = """## Open Points

### Flaky checkout test
id: op-flaky
- 2026-01-01: flaky. [^s1]
- 2026-02-01: fixed in CI. [^s2]
- 2026-03-01: regression, flaky again. [^s3]
"""
    (pt,) = store.parse_open_points(_page("systems/x.md", body))
    assert pt.status == "open"  # the newest bullet is a regression, which reopens it
    assert pt.last_date == "2026-03-01"


def test_wrapped_bullet_joins_continuation_lines():
    # The ingest agent naturally hard-wraps long prose across several indented continuation
    # lines. The parser must fold them into ONE full bullet (whitespace normalized) so neither
    # the per-page parse nor the derived index truncates at the first physical line.
    body = """## Open Points

### Full staff MFA coverage
id: op-mfa-full-coverage
- 2025-09-12: 92% of staff accounts covered; remaining 8% are shared operational accounts
  needing a hardware-token path rather than an app; target for full coverage end of October
  2025 (reported by Devin Osei). [^s1]

## See also

- x
"""
    (pt,) = store.parse_open_points(_page("projects/mfa.md", body))
    assert len(pt.bullets) == 1
    date, text = pt.bullets[0]
    assert date == "2025-09-12"
    # the complete, single-spaced sentence — not truncated at "... needing a"
    assert text == (
        "92% of staff accounts covered; remaining 8% are shared operational accounts needing a "
        "hardware-token path rather than an app; target for full coverage end of October 2025 "
        "(reported by Devin Osei). [^s1]"
    )
    # and the derived catalog entry carries the whole sentence (footnote marker dropped)
    catalog = catalogs._render_open_points_catalog(
        store.collect_open_points([_page("projects/mfa.md", body)]), {"projects/mfa.md": "MFA Rollout"}
    )
    assert "target for full coverage end of October 2025 (reported by Devin Osei)." in catalog
    assert "[^s1]" not in catalog


def test_no_section_returns_empty():
    assert store.parse_open_points(_page("systems/x.md", "Just a fact.[^s1]\n\n## See also\n")) == []


def test_open_point_id_normalized_to_lowercase():
    body = "## Open Points\n\n### Login timeout\nid: OP-Login-Timeout\n- 2026-01-01: raised. [^s1]\n"
    (pt,) = store.parse_open_points(_page("systems/x.md", body))
    assert pt.point_id == "op-login-timeout"  # ids follow the lowercase slugify rule


def test_open_points_ignores_code_fence():
    body = """## Open Points

### Real point
id: op-real
- 2026-01-01: real. [^s1]

```
### Fake in a fence
id: op-fake
- 2026-01-01: must be ignored. [^s9]
```
"""
    points = store.parse_open_points(_page("systems/x.md", body))
    assert [p.point_id for p in points] == ["op-real"]


def test_render_catalog_groups_flattens_and_links():
    points = store.collect_open_points([_page("systems/checkout.md", TWO_POINTS_BODY)])
    body = catalogs._render_open_points_catalog(points, {"systems/checkout.md": "Checkout Service"})
    assert body is not None
    assert "# Open Points" in body
    assert "## Open (1)" in body and "## Done (1)" in body
    # host link is relative to open-points/index.md
    assert "[Checkout Service](../systems/checkout.md)" in body
    assert "id: op-checkout-latency" in body
    # footnote markers are dropped and inline links flattened to their text
    assert "[^s1]" not in body and "[^s2]" not in body
    assert "../systems/postgres.md" not in body
    assert "Postgres" in body


def test_render_catalog_none_when_empty():
    assert catalogs._render_open_points_catalog([], {}) is None


# ----- rebuild_indexes wiring (tmp wiki), mirroring test_sources_index -----


_FM = {
    "type": "System",
    "title": "Checkout Service",
    "description": "Orders.",
    "tags": ["checkout"],
    "resource": "raw/a.md",
}


def test_rebuild_writes_catalog_and_index_summary(tmp_citadel, seed_page):
    wiki = tmp_citadel.wiki
    seed_page("systems/checkout.md", _FM, TWO_POINTS_BODY)

    store.rebuild_indexes()

    catalog = wiki / "open-points" / "index.md"
    assert catalog.exists()
    assert "## Open (1)" in catalog.read_text(encoding="utf-8")

    index = tmp_citadel.index_path.read_text(encoding="utf-8")
    assert "## Open Points" in index
    assert "2 tracked open point(s), 1 still open" in index
    assert "[open points](open-points/index.md)" in index  # See also link

    # The generated catalog is skipped by the loader and refused by delete_page.
    assert "open-points/index.md" not in {p.rel_path for p in store.load()}
    with pytest.raises(okf.OKFError):
        store.delete_page("open-points/index.md")


def test_rebuild_removes_stale_catalog(tmp_citadel, seed_page):
    wiki = tmp_citadel.wiki
    seed_page("systems/checkout.md", _FM, TWO_POINTS_BODY)
    store.rebuild_indexes()
    assert (wiki / "open-points" / "index.md").exists()

    # Re-seed the same page WITHOUT its Open Points section, then rebuild: the catalog is removed.
    seed_page("systems/checkout.md", _FM, "Just a fact.[^s1]\n\n## See also\n\n- x\n")
    store.rebuild_indexes()
    assert not (wiki / "open-points" / "index.md").exists()
    assert "## Open Points" not in tmp_citadel.index_path.read_text(encoding="utf-8")


# ----- lint advisories (do NOT flip ok()) -----


def test_lint_flags_near_duplicate_points_without_failing():
    body_a = "## Open Points\n\n### Login timeout\nid: op-login-timeout\n- 2026-01-01: raised.\n"
    body_b = "## Open Points\n\n### Login timeouts\nid: op-login-timeouts\n- 2026-01-02: still open.\n"
    pages = [_page("systems/a.md", body_a, title="A"), _page("systems/b.md", body_b, title="B")]
    report = lint.lint(pages)
    assert len(report.duplicate_open_points) == 1
    pair = report.duplicate_open_points[0]
    assert "systems/a.md#Login timeout" in pair and "systems/b.md#Login timeouts" in pair
    assert report.ok()  # advisory only — near-duplicate points never flip the structural gate


def test_lint_flags_malformed_points():
    # One thread has no id: line, another has an id but no dated bullet.
    body = "## Open Points\n\n### No id here\n- 2026-01-01: a bullet.\n\n### No date here\nid: op-no-date\n"
    report = lint.lint([_page("systems/x.md", body)])
    flagged = {title for _rel, title in report.malformed_open_points}
    assert flagged == {"No id here", "No date here"}
    assert report.ok()  # advisory only
