"""Tests for the workflow-event timeline model and read API (#637).

Covers the acceptance criteria: versioned schema, adaptation of control-plane
events and Gitea handoff comments, filter by issue/PR/session, redaction of
secret-like payloads, and stable pagination.
"""
import json
import os
import sqlite3
import sys
import unittest
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from starlette.testclient import TestClient

import control_plane_db
from canonical_thread_handoff import (
    CTH_TYPES,
    MARKER,
    format_cth_body,
    is_known_cth_type,
    parse_cth_comment,
)
from webui import timeline
from webui.app import create_app


def _seed_db(path: str) -> None:
    """Create a control-plane DB and seed scoped work_items + events."""
    # Constructing ControlPlaneDB runs the schema migration once.
    control_plane_db.ControlPlaneDB(db_path=path)
    conn = sqlite3.connect(path)
    try:
        conn.execute(
            "INSERT INTO work_items(remote, org, repo, kind, number, state, updated_at) "
            "VALUES (?,?,?,?,?,?,?)",
            ("prgs", "Scaled-Tech-Consulting", "Gitea-Tools", "issue", 637, "open", "2026-07-23T00:00:00Z"),
        )
        issue_wid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
        conn.execute(
            "INSERT INTO work_items(remote, org, repo, kind, number, state, updated_at) "
            "VALUES (?,?,?,?,?,?,?)",
            ("prgs", "Scaled-Tech-Consulting", "Gitea-Tools", "pr", 813, "open", "2026-07-23T00:00:00Z"),
        )
        pr_wid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
        # A work item for a different repo — must never appear in prgs/Gitea-Tools scope.
        conn.execute(
            "INSERT INTO work_items(remote, org, repo, kind, number, state, updated_at) "
            "VALUES (?,?,?,?,?,?,?)",
            ("dadeschools", "Other", "Elsewhere", "issue", 1, "open", "2026-07-23T00:00:00Z"),
        )
        other_wid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]

        rows = [
            (issue_wid, "allocation", "assigned author work", "2026-07-23T01:00:00Z"),
            (issue_wid, "lease.renew", "token=ghs_ABCDEF1234567890abcdef lease renewed", "2026-07-23T02:00:00Z"),
            (pr_wid, "pr.opened", "PR opened for review", "2026-07-23T03:00:00Z"),
            (other_wid, "allocation", "off-scope event", "2026-07-23T04:00:00Z"),
        ]
        conn.executemany(
            "INSERT INTO events(work_item_id, event_type, message, created_at) VALUES (?,?,?,?)",
            rows,
        )
        conn.commit()
    finally:
        conn.close()


class TestSchema(unittest.TestCase):
    def test_schema_is_versioned(self):
        self.assertIsInstance(timeline.TIMELINE_SCHEMA_VERSION, int)
        self.assertGreaterEqual(timeline.TIMELINE_SCHEMA_VERSION, 1)

    def test_event_to_dict_shape(self):
        ev = timeline.WorkflowEvent(
            source=timeline.SOURCE_CONTROL_PLANE,
            event_type="allocation",
            event_key="cp:1",
            timestamp="2026-07-23T01:00:00Z",
            issue_number=637,
        )
        d = ev.to_dict()
        for key in (
            "source", "event_type", "event_key", "timestamp", "actor", "role",
            "issue_number", "pr_number", "session_id", "tool_name", "decision",
            "message", "correlation_id", "evidence_refs", "sensitive",
        ):
            self.assertIn(key, d)
        self.assertEqual(d["evidence_refs"], [])


class TestCpAdapter(unittest.TestCase):
    def test_issue_and_pr_mapping(self):
        rows = [
            {"event_id": 1, "event_type": "allocation", "message": "x", "created_at": "2026-07-23T01:00:00Z", "kind": "issue", "number": 637},
            {"event_id": 2, "event_type": "pr.opened", "message": "y", "created_at": "2026-07-23T02:00:00Z", "kind": "pr", "number": 813},
        ]
        events = timeline.adapt_cp_events(rows)
        self.assertEqual(len(events), 2)
        self.assertEqual(events[0].issue_number, 637)
        self.assertIsNone(events[0].pr_number)
        self.assertEqual(events[0].correlation_id, "issue#637")
        self.assertIsNone(events[1].issue_number)
        self.assertEqual(events[1].pr_number, 813)

    def test_malformed_rows_skipped(self):
        rows = [
            {"event_id": None, "event_type": "x", "kind": "issue", "number": 1},
            {"event_id": 5, "event_type": "", "kind": "issue", "number": 1},
            {"event_id": 6, "event_type": "ok", "message": "m", "created_at": None, "kind": "issue", "number": 1},
        ]
        events = timeline.adapt_cp_events(rows)
        self.assertEqual(len(events), 1)
        self.assertIsNone(events[0].timestamp)

    def test_sensitive_event_flagged(self):
        rows = [{"event_id": 1, "event_type": "lease.renew", "message": "m", "created_at": "2026-07-23T01:00:00Z", "kind": "issue", "number": 1}]
        events = timeline.adapt_cp_events(rows)
        self.assertTrue(events[0].sensitive)


class TestCthAdapter(unittest.TestCase):
    def test_cth_comment_becomes_event(self):
        body = format_cth_body(
            cth_type="Author Handoff",
            status="ready",
            next_owner="reviewer",
            decision="implement timeline",
            proof="commit abc1234 closes #637",
            next_action="review PR",
            ready_to_paste_prompt="Review PR #900 as reviewer",
        )
        comments = [{"id": 42, "body": body, "created_at": "2026-07-23T05:00:00Z", "user": {"login": "jcwalker3"}}]
        events = timeline.adapt_cth_comments(comments, kind="issue", number=637)
        self.assertEqual(len(events), 1)
        ev = events[0]
        self.assertEqual(ev.source, timeline.SOURCE_GITEA_HANDOFF)
        self.assertEqual(ev.event_type, "handoff:Author Handoff")
        self.assertEqual(ev.actor, "jcwalker3")
        self.assertEqual(ev.issue_number, 637)
        self.assertEqual(ev.event_key, "cth:issue:637:42")
        self.assertIn("#637", ev.evidence_refs)
        self.assertIn("abc1234", ev.evidence_refs)

    def test_non_cth_comment_ignored(self):
        comments = [{"id": 1, "body": "just a normal comment", "created_at": "2026-07-23T05:00:00Z", "user": {"login": "x"}}]
        self.assertEqual(timeline.adapt_cth_comments(comments, kind="issue", number=1), [])


class TestRedaction(unittest.TestCase):
    def test_cp_message_redacted(self):
        rows = [{"event_id": 1, "event_type": "lease", "message": "token=ghs_ABCDEF1234567890abcdef here", "created_at": "2026-07-23T01:00:00Z", "kind": "issue", "number": 1}]
        events = timeline.adapt_cp_events(rows)
        self.assertNotIn("ghs_ABCDEF1234567890abcdef", events[0].message or "")

    def test_handoff_decision_redacted(self):
        body = format_cth_body(
            cth_type="Blocker",
            status="blocked",
            next_owner="author",
            decision="password=SuperSecret123! must rotate",
            proof="none",
            next_action="rotate",
            ready_to_paste_prompt="Rotate the credential and retry",
        )
        comments = [{"id": 7, "body": body, "created_at": "2026-07-23T05:00:00Z", "user": {"login": "x"}}]
        events = timeline.adapt_cth_comments(comments, kind="issue", number=1)
        self.assertNotIn("SuperSecret123!", events[0].decision or "")


class TestFilterSortPaginate(unittest.TestCase):
    def _events(self):
        return [
            timeline.WorkflowEvent(source="control_plane", event_type="a", event_key="cp:3", timestamp="2026-07-23T03:00:00Z", pr_number=813),
            timeline.WorkflowEvent(source="control_plane", event_type="b", event_key="cp:1", timestamp="2026-07-23T01:00:00Z", issue_number=637),
            timeline.WorkflowEvent(source="control_plane", event_type="c", event_key="cp:2", timestamp="2026-07-23T02:00:00Z", issue_number=637, session_id="sess-1"),
        ]

    def test_filter_by_issue(self):
        out = timeline.filter_events(self._events(), issue=637)
        self.assertEqual({e.event_key for e in out}, {"cp:1", "cp:2"})

    def test_filter_by_pr(self):
        out = timeline.filter_events(self._events(), pr=813)
        self.assertEqual([e.event_key for e in out], ["cp:3"])

    def test_filter_by_session(self):
        out = timeline.filter_events(self._events(), session="sess-1")
        self.assertEqual([e.event_key for e in out], ["cp:2"])

    def test_stable_sort_ascending(self):
        out = timeline.sort_events(self._events())
        self.assertEqual([e.event_key for e in out], ["cp:1", "cp:2", "cp:3"])

    def test_missing_timestamp_sorts_last(self):
        evs = self._events() + [
            timeline.WorkflowEvent(source="control_plane", event_type="z", event_key="cp:9", timestamp=None)
        ]
        out = timeline.sort_events(evs)
        self.assertEqual(out[-1].event_key, "cp:9")

    def test_pagination_windows_and_next_offset(self):
        evs = timeline.sort_events(self._events())
        page1 = timeline.paginate(evs, limit=2, offset=0)
        self.assertEqual(len(page1.events), 2)
        self.assertEqual(page1.total, 3)
        self.assertEqual(page1.next_offset, 2)
        page2 = timeline.paginate(evs, limit=2, offset=2)
        self.assertEqual(len(page2.events), 1)
        self.assertIsNone(page2.next_offset)

    def test_pagination_bounds_coerced(self):
        evs = self._events()
        page = timeline.paginate(evs, limit=-5, offset=-3)
        self.assertGreaterEqual(page.limit, 1)
        self.assertEqual(page.offset, 0)


class TestCpReader(unittest.TestCase):
    def test_reads_scoped_events_only(self):
        import tempfile

        with tempfile.TemporaryDirectory() as tmp:
            db = os.path.join(tmp, "cp.sqlite3")
            _seed_db(db)
            events, status = timeline.read_cp_events(
                remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools", db_path=db
            )
            self.assertTrue(status.ok)
            # 3 scoped events; the dadeschools/Other event is excluded.
            self.assertEqual(len(events), 3)
            self.assertTrue(all(e.source == "control_plane" for e in events))
            # Redaction applied to the token-bearing message.
            joined = " ".join(e.message or "" for e in events)
            self.assertNotIn("ghs_ABCDEF1234567890abcdef", joined)

    def test_missing_db_degrades(self):
        events, status = timeline.read_cp_events(
            remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools",
            db_path="/nonexistent/path/to/cp.sqlite3",
        )
        self.assertEqual(events, [])
        self.assertFalse(status.ok)
        self.assertIsNotNone(status.reason)


class TestLoadTimeline(unittest.TestCase):
    def test_handoff_not_run_without_thread_filter(self):
        import tempfile

        with tempfile.TemporaryDirectory() as tmp:
            db = os.path.join(tmp, "cp.sqlite3")
            _seed_db(db)
            snap = timeline.load_timeline(
                remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools", db_path=db
            )
            d = snap.to_dict()
            handoff = [s for s in d["sources"] if s["name"] == "gitea_handoff"][0]
            self.assertFalse(handoff["ok"])
            self.assertIn("thread-scoped", handoff["reason"])
            self.assertEqual(d["schema_version"], timeline.TIMELINE_SCHEMA_VERSION)

    def test_handoff_included_via_injected_source(self):
        import tempfile

        body = format_cth_body(
            cth_type="Author Handoff", status="ready", next_owner="reviewer",
            decision="d", proof="#637", next_action="review", ready_to_paste_prompt="Review PR #1 now",
        )

        def source(kind, number):
            return [{"id": 1, "body": body, "created_at": "2026-07-23T09:00:00Z", "user": {"login": "jcwalker3"}}]

        with tempfile.TemporaryDirectory() as tmp:
            db = os.path.join(tmp, "cp.sqlite3")
            _seed_db(db)
            snap = timeline.load_timeline(
                remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools",
                issue=637, db_path=db, comment_source=source,
            )
            d = snap.to_dict()
            handoff = [s for s in d["sources"] if s["name"] == "gitea_handoff"][0]
            self.assertTrue(handoff["ok"])
            self.assertEqual(handoff["count"], 1)
            # Both a CP event and the handoff event for issue 637 appear, sorted.
            kinds = {e["source"] for e in d["events"]}
            self.assertEqual(kinds, {"control_plane", "gitea_handoff"})

    def test_failing_comment_source_degrades_only_handoff(self):
        import tempfile

        def boom(kind, number):
            raise RuntimeError("network down")

        with tempfile.TemporaryDirectory() as tmp:
            db = os.path.join(tmp, "cp.sqlite3")
            _seed_db(db)
            snap = timeline.load_timeline(
                remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools",
                issue=637, db_path=db, comment_source=boom,
            )
            d = snap.to_dict()
            cp = [s for s in d["sources"] if s["name"] == "control_plane"][0]
            handoff = [s for s in d["sources"] if s["name"] == "gitea_handoff"][0]
            self.assertTrue(cp["ok"])
            self.assertFalse(handoff["ok"])
            self.assertIn("network down", handoff["reason"])


# A fabricated 40-character lowercase hex value with the shape of a Gitea
# personal access token. Never a real credential — its only job is to prove it
# cannot reach any part of a serialized timeline payload.
SYNTHETIC_SECRET_40_HEX = "a3f9c17be44d2058e6b17c9d0f5321ab77c4e9d1"


def _cth_comment(comment_id, *, created_at, session=None, decision="d", proof="none", **kw):
    """Build one real CTH comment record, optionally declaring a session."""
    extra = {"Session": session} if session is not None else None
    body = format_cth_body(
        cth_type=kw.pop("cth_type", "Author Handoff"),
        status=kw.pop("status", "ready"),
        next_owner=kw.pop("next_owner", "reviewer"),
        decision=decision,
        proof=proof,
        next_action=kw.pop("next_action", "review"),
        ready_to_paste_prompt=kw.pop("ready_to_paste_prompt", "Review PR #1 now"),
        extra_fields=extra,
    )
    return {
        "id": comment_id,
        "body": body,
        "created_at": created_at,
        "user": {"login": kw.pop("login", "jcwalker3")},
    }


class TestSessionFilterThroughAdapter(unittest.TestCase):
    """F1: the session dimension must be real, or explicitly refused.

    These drive the filter through the CTH adapter and the composed
    ``load_timeline``/API path, never through a hand-built ``WorkflowEvent``.
    """

    def _source(self, comments):
        return lambda kind, number: list(comments)

    def test_adapter_populates_declared_session(self):
        events = timeline.adapt_cth_comments(
            [_cth_comment(1, created_at="2026-07-23T05:00:00Z", session="sess-alpha")],
            kind="issue",
            number=637,
        )
        self.assertEqual(len(events), 1)
        self.assertEqual(events[0].session_id, "sess-alpha")

    def test_session_filter_matches_through_adapter(self):
        import tempfile

        comments = [
            _cth_comment(1, created_at="2026-07-23T09:00:00Z", session="sess-alpha"),
            _cth_comment(2, created_at="2026-07-23T08:00:00Z", session="sess-alpha"),
            _cth_comment(3, created_at="2026-07-23T10:00:00Z", session="sess-beta"),
        ]
        with tempfile.TemporaryDirectory() as tmp:
            db = os.path.join(tmp, "cp.sqlite3")
            _seed_db(db)
            snap = timeline.load_timeline(
                remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools",
                issue=637, session="sess-alpha", db_path=db,
                comment_source=self._source(comments),
            )
            d = snap.to_dict()
            self.assertTrue(d["ok"])
            self.assertIsNone(d["error"])
            keys = [e["event_key"] for e in d["events"]]
            # Only the two sess-alpha events, still in ascending timestamp order.
            self.assertEqual(keys, ["cth:issue:637:2", "cth:issue:637:1"])
            self.assertTrue(all(e["session_id"] == "sess-alpha" for e in d["events"]))
            self.assertEqual(d["pagination"]["total"], 2)

    def test_session_filter_paginates_and_keeps_order(self):
        import tempfile

        comments = [
            _cth_comment(i, created_at=f"2026-07-23T0{i}:00:00Z", session="sess-alpha")
            for i in range(1, 4)
        ]
        with tempfile.TemporaryDirectory() as tmp:
            db = os.path.join(tmp, "cp.sqlite3")
            _seed_db(db)
            kwargs = dict(
                remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools",
                issue=637, session="sess-alpha", db_path=db,
                comment_source=self._source(comments),
            )
            page1 = timeline.load_timeline(limit=2, offset=0, **kwargs).to_dict()
            page2 = timeline.load_timeline(limit=2, offset=2, **kwargs).to_dict()
            self.assertEqual(
                [e["event_key"] for e in page1["events"]],
                ["cth:issue:637:1", "cth:issue:637:2"],
            )
            self.assertEqual(page1["pagination"]["next_offset"], 2)
            self.assertEqual([e["event_key"] for e in page2["events"]], ["cth:issue:637:3"])
            self.assertIsNone(page2["pagination"]["next_offset"])

    def test_unknown_session_is_honestly_empty_when_supported(self):
        """A source that *can* answer the dimension may legitimately match nothing."""
        import tempfile

        comments = [_cth_comment(1, created_at="2026-07-23T09:00:00Z", session="sess-alpha")]
        with tempfile.TemporaryDirectory() as tmp:
            db = os.path.join(tmp, "cp.sqlite3")
            _seed_db(db)
            d = timeline.load_timeline(
                remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools",
                issue=637, session="sess-nope", db_path=db,
                comment_source=self._source(comments),
            ).to_dict()
            self.assertTrue(d["ok"])
            self.assertEqual(d["events"], [])

    def test_session_filter_refused_when_no_source_can_answer(self):
        """The F1 defect: an empty-and-healthy page for an unanswerable filter."""
        import tempfile

        with tempfile.TemporaryDirectory() as tmp:
            db = os.path.join(tmp, "cp.sqlite3")
            _seed_db(db)
            d = timeline.load_timeline(
                remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools",
                issue=637, session="sess-alpha", db_path=db, comment_source=None,
            ).to_dict()
            self.assertFalse(d["ok"])
            self.assertEqual(d["error"]["code"], "filter_not_supported")
            self.assertEqual(d["error"]["unsupported_filters"], ["session"])
            self.assertEqual(d["events"], [])
            self.assertEqual(d["pagination"]["total"], 0)
            # The refusal states which source could not answer, and why.
            explained = {r["source"] for r in d["error"]["sources"]}
            self.assertEqual(explained, {"control_plane", "gitea_handoff"})

    def test_control_plane_declares_session_unsupported(self):
        import tempfile

        with tempfile.TemporaryDirectory() as tmp:
            db = os.path.join(tmp, "cp.sqlite3")
            _seed_db(db)
            d = timeline.load_timeline(
                remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools",
                issue=637, session="sess-alpha", db_path=db,
                comment_source=self._source([]),
            ).to_dict()
            cp = [s for s in d["sources"] if s["name"] == "control_plane"][0]
            handoff = [s for s in d["sources"] if s["name"] == "gitea_handoff"][0]
            self.assertNotIn("session", cp["supported_filters"])
            self.assertEqual(cp["unsupported_filters"], ["session"])
            self.assertIn("session", handoff["supported_filters"])
            self.assertEqual(handoff["unsupported_filters"], [])

    def test_secret_shaped_session_value_is_dropped(self):
        events = timeline.adapt_cth_comments(
            [_cth_comment(1, created_at="2026-07-23T05:00:00Z", session=SYNTHETIC_SECRET_40_HEX)],
            kind="issue",
            number=637,
        )
        self.assertIsNone(events[0].session_id)
        self.assertNotIn(SYNTHETIC_SECRET_40_HEX, json.dumps(events[0].to_dict()))


class TestEvidenceRefRedaction(unittest.TestCase):
    """F2: evidence_refs must not be a hole in the redaction boundary."""

    def _refs_for(self, *, proof="none", decision="d"):
        events = timeline.adapt_cth_comments(
            [_cth_comment(1, created_at="2026-07-23T05:00:00Z", proof=proof, decision=decision)],
            kind="issue",
            number=637,
        )
        self.assertEqual(len(events), 1)
        return events[0]

    def test_assigned_secret_never_reaches_evidence_refs(self):
        ev = self._refs_for(proof=f"authenticated with token={SYNTHETIC_SECRET_40_HEX}")
        self.assertNotIn(SYNTHETIC_SECRET_40_HEX, ev.evidence_refs)
        self.assertNotIn(SYNTHETIC_SECRET_40_HEX, json.dumps(ev.to_dict()))

    def test_bare_secret_shaped_value_never_reaches_evidence_refs(self):
        # An undeclared hex run in proof text is not evidence of anything, and
        # proof itself is never serialized — so the value has no way out.
        ev = self._refs_for(proof=f"proof {SYNTHETIC_SECRET_40_HEX}", decision="rotate the credential")
        self.assertNotIn(SYNTHETIC_SECRET_40_HEX, ev.evidence_refs)
        self.assertNotIn(SYNTHETIC_SECRET_40_HEX, json.dumps(ev.to_dict()))

    def test_secret_absent_from_complete_serialized_payload(self):
        import tempfile

        comments = [
            _cth_comment(
                1,
                created_at="2026-07-23T09:00:00Z",
                proof=f"lease token={SYNTHETIC_SECRET_40_HEX} and bare {SYNTHETIC_SECRET_40_HEX}",
                decision=f"rotate api_key={SYNTHETIC_SECRET_40_HEX}",
            )
        ]
        with tempfile.TemporaryDirectory() as tmp:
            db = os.path.join(tmp, "cp.sqlite3")
            _seed_db(db)
            snap = timeline.load_timeline(
                remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools",
                issue=637, db_path=db, comment_source=lambda k, n: comments,
            )
            payload = json.dumps(snap.to_dict())
            self.assertNotIn(SYNTHETIC_SECRET_40_HEX, payload)
            # And the surface is clean by the redaction policy's own detectors.
            from webui import console_redaction

            self.assertEqual(console_redaction.scan_for_secrets(snap.to_dict()), [])

    def test_legitimate_references_still_usable(self):
        head = "4f3a464a1c455b6ecaaae9b6eef496c8f8ed451a"
        ev = self._refs_for(
            proof=f"closes #637, PR #849, commit abc1234, at head {head}",
            decision="none",
        )
        for token in ("#637", "#849", "abc1234", head):
            self.assertIn(token, ev.evidence_refs)

    def test_undeclared_hex_words_are_not_references(self):
        ev = self._refs_for(proof="the record was defaced and the facade decayed")
        self.assertEqual(ev.evidence_refs, ())

    def test_refs_revalidated_independently_before_serialization(self):
        """Extraction is not trusted: the validator drops anything unproven."""
        safe, dropped = timeline._validated_evidence_refs(
            ["#637", "abc1234", "not-a-ref", f"token={SYNTHETIC_SECRET_40_HEX}", ""]
        )
        self.assertEqual(safe, ("#637", "abc1234"))
        self.assertTrue(dropped)
        self.assertNotIn(SYNTHETIC_SECRET_40_HEX, json.dumps(list(safe)))

    def test_clean_event_is_not_marked_sensitive(self):
        ev = self._refs_for(proof="closes #637")
        self.assertEqual(ev.evidence_refs, ("#637",))
        self.assertFalse(ev.sensitive)


class TestTimelineApi(unittest.TestCase):
    def setUp(self):
        self._prev_db = os.environ.get(control_plane_db.DB_PATH_ENV)
        self._prev_offline = os.environ.get("WEBUI_TEST_OFFLINE")
        import tempfile

        self._tmpdir = tempfile.TemporaryDirectory()
        self._db = os.path.join(self._tmpdir.name, "cp.sqlite3")
        _seed_db(self._db)
        os.environ[control_plane_db.DB_PATH_ENV] = self._db
        os.environ["WEBUI_TEST_OFFLINE"] = "1"
        self.client = TestClient(create_app())

    def tearDown(self):
        if self._prev_db is None:
            os.environ.pop(control_plane_db.DB_PATH_ENV, None)
        else:
            os.environ[control_plane_db.DB_PATH_ENV] = self._prev_db
        if self._prev_offline is None:
            os.environ.pop("WEBUI_TEST_OFFLINE", None)
        else:
            os.environ["WEBUI_TEST_OFFLINE"] = self._prev_offline
        self._tmpdir.cleanup()

    def test_api_returns_timeline(self):
        resp = self.client.get("/api/v1/timeline")
        self.assertEqual(resp.status_code, 200)
        body = resp.json()
        self.assertEqual(body["schema_version"], timeline.TIMELINE_SCHEMA_VERSION)
        self.assertIn("events", body)
        self.assertIn("pagination", body)
        self.assertGreaterEqual(body["pagination"]["total"], 1)

    def test_api_filter_by_issue(self):
        resp = self.client.get("/api/v1/timeline?issue=637")
        self.assertEqual(resp.status_code, 200)
        events = resp.json()["events"]
        self.assertTrue(events)
        self.assertTrue(all(e["issue_number"] == 637 for e in events))

    def test_api_pagination(self):
        resp = self.client.get("/api/v1/timeline?limit=1&offset=0")
        self.assertEqual(resp.status_code, 200)
        pg = resp.json()["pagination"]
        self.assertEqual(pg["limit"], 1)
        self.assertEqual(len(resp.json()["events"]), 1)
        if pg["total"] > 1:
            self.assertTrue(pg["has_more"])

    def test_api_is_read_only(self):
        resp = self.client.post("/api/v1/timeline")
        self.assertIn(resp.status_code, (404, 405))

    def test_api_no_secret_leak(self):
        resp = self.client.get("/api/v1/timeline?issue=637")
        self.assertNotIn("ghs_ABCDEF1234567890abcdef", resp.text)

    def test_api_refuses_unanswerable_session_filter(self):
        """No handoff source is configured offline, so nothing can carry a session."""
        resp = self.client.get("/api/v1/timeline?issue=637&session=sess-alpha")
        self.assertEqual(resp.status_code, 422)
        body = resp.json()
        self.assertFalse(body["ok"])
        self.assertEqual(body["error"]["code"], "filter_not_supported")
        self.assertEqual(body["error"]["unsupported_filters"], ["session"])
        self.assertEqual(body["events"], [])
        self.assertEqual(body["pagination"]["total"], 0)

    def test_api_unfiltered_read_stays_ok(self):
        body = self.client.get("/api/v1/timeline?issue=637").json()
        self.assertTrue(body["ok"])
        self.assertIsNone(body["error"])


def _seed_event(path: str, *, event_type: str, message: str, created_at: str) -> None:
    """Append one control-plane event with a caller-chosen ``event_type``.

    The ``events`` table stores whatever a producer writes, so this seeds the
    adapter the way a hostile or buggy producer would.
    """
    conn = sqlite3.connect(path)
    try:
        wid = conn.execute(
            "SELECT work_item_id FROM work_items WHERE kind='issue' AND number=637"
        ).fetchone()[0]
        conn.execute(
            "INSERT INTO events(work_item_id, event_type, message, created_at) VALUES (?,?,?,?)",
            (wid, event_type, message, created_at),
        )
        conn.commit()
    finally:
        conn.close()


def _raw_cth_body(heading: str, **fields) -> str:
    """Build a CTH comment with an arbitrary heading.

    ``format_cth_body`` refuses an undeclared type, which is exactly the write
    path already covered. The read path must cope with a body that never went
    through it, so this writes the marker and heading directly.
    """
    lines = [MARKER, f"## CTH: {heading}", ""]
    base = {
        "Status": "ready",
        "Next owner": "reviewer",
        "Current blocker": "none",
        "Decision": "d",
        "Proof": "none",
        "Next action": "review",
        "Ready-to-paste prompt": "Review PR #1 now",
    }
    base.update(fields)
    lines.extend(f"{key}: {value}" for key, value in base.items())
    return "\n".join(lines)


class TestEventTypeBoundary(unittest.TestCase):
    """F2 residual: event_type must cross the same boundary as every other field.

    The canary is seeded through ``event_type`` *only*, with a benign message,
    so message redaction cannot be what makes these pass. Each case inspects
    ``event_type`` explicitly and then the complete serialized payload.
    """

    # ---- control-plane stored event_type ---------------------------------- #

    def test_cp_event_type_canary_never_serialized(self):
        rows = [{
            "event_id": 1,
            "event_type": SYNTHETIC_SECRET_40_HEX,
            "message": "deploy completed",   # benign: no redaction happens here
            "created_at": "2026-07-23T01:00:00Z",
            "kind": "issue",
            "number": 637,
        }]
        events = timeline.adapt_cp_events(rows)
        self.assertEqual(len(events), 1)
        ev = events[0]
        # The field itself, inspected directly.
        self.assertNotIn(SYNTHETIC_SECRET_40_HEX, ev.event_type)
        self.assertEqual(ev.event_type, timeline.UNSAFE_EVENT_TYPE)
        # Message redaction is provably not what saved us: it is untouched.
        self.assertEqual(ev.message, "deploy completed")
        # And nowhere in the serialized record.
        self.assertNotIn(SYNTHETIC_SECRET_40_HEX, json.dumps(ev.to_dict()))
        # An unsafe value is a visible fact, not a silent substitution.
        self.assertTrue(ev.sensitive)

    def test_cp_same_canary_in_message_and_event_type(self):
        """The decisive case: one record, one value, two fields, one verdict."""
        rows = [{
            "event_id": 2,
            "event_type": SYNTHETIC_SECRET_40_HEX,
            "message": f"deploy token={SYNTHETIC_SECRET_40_HEX}",
            "created_at": "2026-07-23T01:00:00Z",
            "kind": "issue",
            "number": 637,
        }]
        ev = timeline.adapt_cp_events(rows)[0]
        self.assertNotIn(SYNTHETIC_SECRET_40_HEX, ev.message or "")
        self.assertNotIn(SYNTHETIC_SECRET_40_HEX, ev.event_type)
        self.assertNotIn(SYNTHETIC_SECRET_40_HEX, json.dumps(ev.to_dict()))

    def test_cp_unsafe_event_type_is_not_rewritten_as_a_valid_one(self):
        """A refused value must not be disguised as some other real event type."""
        rows = [{
            "event_id": 3,
            "event_type": SYNTHETIC_SECRET_40_HEX,
            "message": "m",
            "created_at": "2026-07-23T01:00:00Z",
            "kind": "issue",
            "number": 637,
        }]
        ev = timeline.adapt_cp_events(rows)[0]
        for legitimate in ("allocation", "pr.opened", "lease.renew", "assigned"):
            self.assertNotEqual(ev.event_type, legitimate)

    def test_cp_assigned_secret_in_event_type_refused(self):
        rows = [{
            "event_id": 4,
            "event_type": f"token={SYNTHETIC_SECRET_40_HEX}",
            "message": "m",
            "created_at": "2026-07-23T01:00:00Z",
            "kind": "issue",
            "number": 637,
        }]
        ev = timeline.adapt_cp_events(rows)[0]
        self.assertEqual(ev.event_type, timeline.UNSAFE_EVENT_TYPE)
        self.assertNotIn(SYNTHETIC_SECRET_40_HEX, json.dumps(ev.to_dict()))

    def test_cp_legitimate_event_types_preserved(self):
        """Every real producer type in this repo must survive untouched."""
        legitimate = [
            "allocation", "pr.opened", "lease.renew", "assigned",
            "lease_released", "lease_expired", "lease_abandoned",
            "lease_adopted", "dependency_edge_state_change",
        ]
        rows = [
            {
                "event_id": i,
                "event_type": name,
                "message": "m",
                "created_at": "2026-07-23T01:00:00Z",
                "kind": "issue",
                "number": 637,
            }
            for i, name in enumerate(legitimate, start=1)
        ]
        events = timeline.adapt_cp_events(rows)
        self.assertEqual([e.event_type for e in events], legitimate)

    def test_cp_malformed_event_type_shapes_refused(self):
        for bad in ("has space", "1leading-digit", "x" * 200, "semi;colon", "new\nline"):
            with self.subTest(event_type=bad):
                rows = [{
                    "event_id": 1,
                    "event_type": bad,
                    "message": "m",
                    "created_at": "2026-07-23T01:00:00Z",
                    "kind": "issue",
                    "number": 637,
                }]
                events = timeline.adapt_cp_events(rows)
                self.assertEqual(events[0].event_type, timeline.UNSAFE_EVENT_TYPE)
                self.assertNotIn(bad, json.dumps(events[0].to_dict()))

    # ---- CTH heading event_type ------------------------------------------- #

    def test_cth_heading_canary_never_serialized(self):
        comments = [{
            "id": 11,
            "body": _raw_cth_body(SYNTHETIC_SECRET_40_HEX),
            "created_at": "2026-07-23T05:00:00Z",
            "user": {"login": "jcwalker3"},
        }]
        events = timeline.adapt_cth_comments(comments, kind="issue", number=637)
        self.assertEqual(len(events), 1)
        ev = events[0]
        self.assertNotIn(SYNTHETIC_SECRET_40_HEX, ev.event_type)
        self.assertEqual(ev.event_type, timeline.UNKNOWN_HANDOFF_EVENT_TYPE)
        # No message redaction is doing the work here — the message is benign.
        self.assertEqual(ev.message, "review")
        self.assertNotIn(SYNTHETIC_SECRET_40_HEX, json.dumps(ev.to_dict()))
        self.assertTrue(ev.sensitive)

    def test_cth_assigned_secret_heading_refused(self):
        comments = [{
            "id": 12,
            "body": _raw_cth_body(f"token={SYNTHETIC_SECRET_40_HEX}"),
            "created_at": "2026-07-23T05:00:00Z",
            "user": {"login": "jcwalker3"},
        }]
        ev = timeline.adapt_cth_comments(comments, kind="issue", number=637)[0]
        self.assertEqual(ev.event_type, timeline.UNKNOWN_HANDOFF_EVENT_TYPE)
        self.assertNotIn(SYNTHETIC_SECRET_40_HEX, json.dumps(ev.to_dict()))

    def test_cth_unknown_and_whitespace_headings_normalized(self):
        for heading in ("Totally Made Up", "Author  Handoff", "author handoff"):
            with self.subTest(heading=heading):
                comments = [{
                    "id": 13,
                    "body": _raw_cth_body(heading),
                    "created_at": "2026-07-23T05:00:00Z",
                    "user": {"login": "x"},
                }]
                events = timeline.adapt_cth_comments(comments, kind="issue", number=637)
                self.assertEqual(len(events), 1)
                self.assertEqual(events[0].event_type, timeline.UNKNOWN_HANDOFF_EVENT_TYPE)

    def test_cth_declared_types_all_preserved(self):
        """Point 5: no legitimate declared type is lost to the new check."""
        for cth_type in sorted(CTH_TYPES):
            with self.subTest(cth_type=cth_type):
                comments = [{
                    "id": 14,
                    "body": _raw_cth_body(cth_type),
                    "created_at": "2026-07-23T05:00:00Z",
                    "user": {"login": "x"},
                }]
                ev = timeline.adapt_cth_comments(comments, kind="issue", number=637)[0]
                self.assertEqual(ev.event_type, f"handoff:{cth_type}")
                self.assertFalse(ev.sensitive)

    def test_cth_surrounding_whitespace_still_matches_contract(self):
        comments = [{
            "id": 15,
            "body": _raw_cth_body("   Author Handoff   "),
            "created_at": "2026-07-23T05:00:00Z",
            "user": {"login": "x"},
        }]
        ev = timeline.adapt_cth_comments(comments, kind="issue", number=637)[0]
        self.assertEqual(ev.event_type, "handoff:Author Handoff")

    # ---- complete serialized payload, both paths -------------------------- #

    def _payload_clean(self, snapshot):
        payload = json.dumps(snapshot.to_dict())
        self.assertNotIn(SYNTHETIC_SECRET_40_HEX, payload)
        from webui import console_redaction

        self.assertEqual(console_redaction.scan_for_secrets(snapshot.to_dict()), [])
        return snapshot.to_dict()

    def test_canary_absent_from_full_payload_via_cp_event_type(self):
        import tempfile

        with tempfile.TemporaryDirectory() as tmp:
            db = os.path.join(tmp, "cp.sqlite3")
            _seed_db(db)
            _seed_event(
                db,
                event_type=SYNTHETIC_SECRET_40_HEX,
                message="routine deploy",
                created_at="2026-07-23T06:00:00Z",
            )
            snap = timeline.load_timeline(
                remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools",
                issue=637, db_path=db, comment_source=lambda k, n: [],
            )
            d = self._payload_clean(snap)
            types = [e["event_type"] for e in d["events"]]
            self.assertIn(timeline.UNSAFE_EVENT_TYPE, types)
            # The legitimate seeded types are still present and unchanged.
            self.assertIn("allocation", types)

    def test_canary_absent_from_full_payload_via_cth_heading(self):
        import tempfile

        comments = [{
            "id": 21,
            "body": _raw_cth_body(SYNTHETIC_SECRET_40_HEX),
            "created_at": "2026-07-23T09:00:00Z",
            "user": {"login": "jcwalker3"},
        }]
        with tempfile.TemporaryDirectory() as tmp:
            db = os.path.join(tmp, "cp.sqlite3")
            _seed_db(db)
            snap = timeline.load_timeline(
                remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools",
                issue=637, db_path=db, comment_source=lambda k, n: comments,
            )
            d = self._payload_clean(snap)
            self.assertIn(
                timeline.UNKNOWN_HANDOFF_EVENT_TYPE,
                [e["event_type"] for e in d["events"]],
            )

    def test_canary_absent_when_seeded_through_both_paths_at_once(self):
        import tempfile

        comments = [{
            "id": 22,
            "body": _raw_cth_body(SYNTHETIC_SECRET_40_HEX),
            "created_at": "2026-07-23T09:00:00Z",
            "user": {"login": "jcwalker3"},
        }]
        with tempfile.TemporaryDirectory() as tmp:
            db = os.path.join(tmp, "cp.sqlite3")
            _seed_db(db)
            _seed_event(
                db,
                event_type=SYNTHETIC_SECRET_40_HEX,
                message=f"deploy token={SYNTHETIC_SECRET_40_HEX}",
                created_at="2026-07-23T06:00:00Z",
            )
            snap = timeline.load_timeline(
                remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools",
                issue=637, db_path=db, comment_source=lambda k, n: comments,
            )
            self._payload_clean(snap)


class TestSerializedFieldAudit(unittest.TestCase):
    """The remaining externally influenced strings that reach the payload."""

    def test_event_key_ids_must_be_plain_identifiers(self):
        rows = [
            {"event_id": SYNTHETIC_SECRET_40_HEX, "event_type": "allocation",
             "message": "m", "created_at": "2026-07-23T01:00:00Z",
             "kind": "issue", "number": 637},
            {"event_id": 8, "event_type": "allocation", "message": "m",
             "created_at": "2026-07-23T01:00:00Z", "kind": "issue", "number": 637},
        ]
        events = timeline.adapt_cp_events(rows)
        self.assertEqual([e.event_key for e in events], ["cp:8"])

    def test_comment_id_must_be_a_plain_identifier(self):
        comments = [{
            "id": SYNTHETIC_SECRET_40_HEX,
            "body": _raw_cth_body("Author Handoff"),
            "created_at": "2026-07-23T05:00:00Z",
            "user": {"login": "x"},
        }]
        self.assertEqual(timeline.adapt_cth_comments(comments, kind="issue", number=637), [])

    def test_adapter_refuses_a_scope_it_cannot_express(self):
        comments = [{
            "id": 1,
            "body": _raw_cth_body("Author Handoff"),
            "created_at": "2026-07-23T05:00:00Z",
            "user": {"login": "x"},
        }]
        self.assertEqual(
            timeline.adapt_cth_comments(comments, kind="issue", number=SYNTHETIC_SECRET_40_HEX),
            [],
        )
        self.assertEqual(
            timeline.adapt_cth_comments(comments, kind=SYNTHETIC_SECRET_40_HEX, number=1),
            [],
        )

    def test_source_failure_reason_is_redacted(self):
        def boom(kind, number):
            raise RuntimeError(f"auth failed with token={SYNTHETIC_SECRET_40_HEX}")

        import tempfile

        with tempfile.TemporaryDirectory() as tmp:
            db = os.path.join(tmp, "cp.sqlite3")
            _seed_db(db)
            snap = timeline.load_timeline(
                remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools",
                issue=637, db_path=db, comment_source=boom,
            )
            d = snap.to_dict()
            handoff = [s for s in d["sources"] if s["name"] == "gitea_handoff"][0]
            self.assertFalse(handoff["ok"])
            self.assertIn("handoff source failed", handoff["reason"])
            self.assertNotIn(SYNTHETIC_SECRET_40_HEX, json.dumps(d))

    def test_echoed_scope_and_filters_are_guarded(self):
        import tempfile

        with tempfile.TemporaryDirectory() as tmp:
            db = os.path.join(tmp, "cp.sqlite3")
            _seed_db(db)
            d = timeline.load_timeline(
                remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools",
                issue=637, session=SYNTHETIC_SECRET_40_HEX, db_path=db,
                comment_source=lambda k, n: [],
            ).to_dict()
            self.assertNotIn(SYNTHETIC_SECRET_40_HEX, json.dumps(d))
            # Ordinary scope values are untouched, so the echo stays useful.
            self.assertEqual(
                d["scope"],
                {"remote": "prgs", "org": "Scaled-Tech-Consulting", "repo": "Gitea-Tools"},
            )
            self.assertEqual(d["filters"]["issue"], 637)


class TestCthTypeContract(unittest.TestCase):
    """The contract has one authority; the read path consults it."""

    def test_declared_types_are_known(self):
        for cth_type in CTH_TYPES:
            self.assertTrue(is_known_cth_type(cth_type))

    def test_undeclared_types_are_not_known(self):
        for value in ("", None, "Made Up", SYNTHETIC_SECRET_40_HEX, "author handoff"):
            self.assertFalse(is_known_cth_type(value))

    def test_parse_reports_contract_membership(self):
        known = parse_cth_comment(_raw_cth_body("Author Handoff"))
        self.assertTrue(known["cth_type_known"])
        self.assertEqual(known["cth_type"], "Author Handoff")
        unknown = parse_cth_comment(_raw_cth_body("Not A Real Type"))
        # Parsing stays total: the type is still reported, just not endorsed.
        self.assertEqual(unknown["cth_type"], "Not A Real Type")
        self.assertFalse(unknown["cth_type_known"])


if __name__ == "__main__":
    unittest.main()
