"""Focused safety tests for the Richard-only operator console."""

from __future__ import annotations

import http.client
import json
from pathlib import Path
from threading import Thread

import pytest

from checkin_cli.models import (
    build_draft_approved_event,
    build_draft_created_event,
    build_draft_sent_event,
)
from checkin_cli.operator_console import OperatorConsole, OperatorConsoleError


CUSTOMER = "client_001"
DRAFT = "draft_001"
TOKEN = "local-secret"


def _write_events(path: Path, *events: object) -> None:
    path.write_text(
        "".join(
            f"{event.model_dump_json(exclude_none=True)}\n"
            for event in events
        ),
        encoding="utf-8",
    )


def _request(
    server: object,
    method: str,
    target: str,
    *,
    token: str | None = None,
) -> http.client.HTTPResponse:
    address = server.server_address
    connection = http.client.HTTPConnection(address[0], address[1], timeout=3)
    headers = {} if token is None else {"X-Operator-Token": token}
    connection.request(method, target, body=b"{}" if method == "POST" else None, headers=headers)
    response = connection.getresponse()
    response.read()
    connection.close()
    return response


def _request_body(
    server: object,
    method: str,
    target: str,
    *,
    token: str | None = None,
) -> tuple[int, bytes]:
    address = server.server_address
    connection = http.client.HTTPConnection(address[0], address[1], timeout=3)
    headers = {} if token is None else {"X-Operator-Token": token}
    body = b"{}" if method == "POST" else None
    connection.request(method, target, body=body, headers=headers)
    response = connection.getresponse()
    payload = response.read()
    status = response.status
    connection.close()
    return status, payload
def _request_json(
    server: object,
    method: str,
    target: str,
    payload: object | None = None,
    *,
    token: str | None = None,
) -> tuple[int, dict[str, str], bytes]:
    address = server.server_address
    connection = http.client.HTTPConnection(address[0], address[1], timeout=3)
    headers = {} if token is None else {"X-Operator-Token": token}
    body = None
    if payload is not None:
        body = json.dumps(payload).encode("utf-8")
        headers["Content-Type"] = "application/json"
    connection.request(method, target, body=body, headers=headers)
    response = connection.getresponse()
    result = response.read()
    status = response.status
    response_headers = dict(response.getheaders())
    connection.close()
    return status, response_headers, result


def test_restart_reconstructs_approval_and_sent_state_from_draft_payload(tmp_path: Path) -> None:
    events_path = tmp_path / "events.jsonl"
    created = build_draft_created_event(CUSTOMER, DRAFT, "ai", "draft text")
    approved = build_draft_approved_event(
        CUSTOMER,
        DRAFT,
        "richard",
        "draft text",
        approved_from_draft_id=DRAFT,
    )
    sent = build_draft_sent_event(
        CUSTOMER,
        DRAFT,
        "richard",
        "draft text",
        approved_from_draft_id=DRAFT,
    )
    _write_events(events_path, created)
    callbacks: list[str] = []

    first = OperatorConsole(
        TOKEN,
        events_path=events_path,
        approve_draft=lambda customer, draft: callbacks.append(f"approve:{customer}:{draft}"),
        send_draft=lambda customer, draft: callbacks.append(f"send:{customer}:{draft}"),
    )
    first.approve(CUSTOMER, DRAFT)
    with pytest.raises(RuntimeError, match="approved"):
        first.send(CUSTOMER, DRAFT)
    assert callbacks == [f"approve:{CUSTOMER}:{DRAFT}"]

    _write_events(events_path, created, approved)
    restarted = OperatorConsole(
        TOKEN,
        events_path=events_path,
        send_draft=lambda customer, draft: callbacks.append(f"send:{customer}:{draft}"),
    )
    evidence = restarted.evidence(CUSTOMER, DRAFT)
    assert evidence["state"] == "approved"
    assert evidence["draft"] == {
        "draft_id": DRAFT,
        "actor": "richard",
        "text": "draft text",
        "approved_from_draft_id": DRAFT,
    }
    restarted.send(CUSTOMER, DRAFT)
    assert callbacks[-1] == f"send:{CUSTOMER}:{DRAFT}"

    _write_events(events_path, created, approved, sent)
    after_send_restart = OperatorConsole(
        TOKEN,
        events_path=events_path,
        send_draft=lambda customer, draft: callbacks.append(f"duplicate:{customer}:{draft}"),
    )
    assert after_send_restart.evidence(CUSTOMER, DRAFT)["state"] == "sent"
    with pytest.raises(RuntimeError, match="already been sent"):
        after_send_restart.send(CUSTOMER, DRAFT)
    assert callbacks[-1] == f"send:{CUSTOMER}:{DRAFT}"


def test_corrupt_lifecycle_evidence_fails_closed_and_cannot_send(tmp_path: Path) -> None:
    events_path = tmp_path / "events.jsonl"
    created = build_draft_created_event(CUSTOMER, DRAFT, "ai", "draft text")
    malformed = {
        "event_id": "pilot_bad_event",
        "event_type": "draft_approved",
        "occurred_at_kst": "2026-07-22T10:00:00+09:00",
        "recorded_at_kst": "2026-07-22T10:00:00+09:00",
        "schema_version": "2.0",
        "provenance": {
            "source_type": "manual",
            "source_ref": f"pilot:{CUSTOMER}:draft_approved",
            "content_sha256": "0" * 64,
        },
        "status": "accepted",
        "dedupe_key": f"draft_approved:{CUSTOMER}:{DRAFT}",
        "draft": {"draft_id": DRAFT, "text": "missing actor"},
    }
    _write_events(events_path, created)
    events_path.write_text(
        events_path.read_text(encoding="utf-8") + json.dumps(malformed) + "\n",
        encoding="utf-8",
    )
    sent: list[str] = []
    console = OperatorConsole(
        TOKEN,
        events_path=events_path,
        send_draft=lambda customer, draft: sent.append(draft),
    )

    with pytest.raises(ValueError, match="invalid canonical evidence"):
        console.evidence(CUSTOMER, DRAFT)
    with pytest.raises(RuntimeError, match="approved"):
        console.send(CUSTOMER, DRAFT)
    assert sent == []


def test_missing_and_wrong_token_are_rejected(tmp_path: Path) -> None:
    console = OperatorConsole(TOKEN, evidence_loader=lambda customer, draft: {"events": []})
    server = console.create_server()
    thread = Thread(target=server.serve_forever, daemon=True)
    thread.start()
    try:
        assert _request(server, "GET", "/").status == 401
        assert _request(server, "GET", "/", token="wrong-token").status == 401
        assert _request(server, "GET", f"/evidence/{CUSTOMER}/{DRAFT}").status == 401
        assert _request(server, "POST", f"/draft/{CUSTOMER}/{DRAFT}/approve").status == 401
        assert _request(server, "GET", "/", token=TOKEN).status == 200
    finally:
        server.shutdown()
        thread.join(timeout=3)
        server.server_close()

def test_authenticated_root_renders_evidence_and_lifecycle_controls() -> None:
    console = OperatorConsole(
        TOKEN,
        customer_key=CUSTOMER,
        evidence_loader=lambda customer, draft: {
            "customer_key": customer,
            "draft_id": draft,
            "state": "created",
            "draft": {"draft_id": draft, "actor": "ai", "text": "draft text"},
            "events": [{"event_type": "draft_created"}],
        },
    )
    server = console.create_server()
    thread = Thread(target=server.serve_forever, daemon=True)
    thread.start()
    try:
        status, payload = _request_body(
            server,
            "GET",
            f"/?customer={CUSTOMER}&draft={DRAFT}",
            token=TOKEN,
        )
        page = payload.decode("utf-8")
        assert status == 200
        assert CUSTOMER in page
        assert DRAFT in page
        assert '<h2 id="evidence-heading">Draft evidence</h2>' in page
        assert f"/draft/{CUSTOMER}/{DRAFT}/edit" in page
        assert f"/draft/{CUSTOMER}/{DRAFT}/approve" in page
        assert f"/draft/{CUSTOMER}/{DRAFT}/send" in page
        assert f"/evidence/{CUSTOMER}/{DRAFT}" in page
        assert 'id="operator-token" type="password"' in page
        assert 'name="operator-token"' not in page
        assert "localStorage" not in page
        assert "sessionStorage" not in page
        assert '"X-Operator-Token"' in page
        assert 'JSON.stringify({text: text})' in page
        assert 'JSON.stringify({})' in page
        assert 'value="local-secret"' not in page
        assert 'name="text"' in page
        assert TOKEN not in page
    finally:
        server.shutdown()
        thread.join(timeout=3)
        server.server_close()


def test_console_csp_allows_inline_same_origin_fetch_only(capsys: pytest.CaptureFixture[str]) -> None:
    console = OperatorConsole(TOKEN, evidence_loader=lambda customer, draft: {"events": []})
    server = console.create_server()
    thread = Thread(target=server.serve_forever, daemon=True)
    thread.start()
    try:
        status, headers, payload = _request_json(server, "GET", "/", token=TOKEN)
        page = payload.decode("utf-8")
        csp = headers["Content-Security-Policy"]
        assert status == 200
        assert "default-src 'none'" in csp
        assert "script-src 'unsafe-inline'" in csp
        assert "connect-src 'self'" in csp
        assert "connect-src https:" not in csp
        assert TOKEN not in page
        assert "document.cookie" not in page
        captured = capsys.readouterr()
        assert TOKEN not in captured.out
        assert TOKEN not in captured.err
    finally:
        server.shutdown()
        thread.join(timeout=3)
        server.server_close()


def test_http_edit_json_body_rejects_text_over_canonical_bound() -> None:
    edited: list[str] = []
    console = OperatorConsole(
        TOKEN,
        customer_key=CUSTOMER,
        edit_draft=lambda customer, draft, text: edited.append(text),
    )
    server = console.create_server()
    thread = Thread(target=server.serve_forever, daemon=True)
    thread.start()
    try:
        status, _, _ = _request_json(
            server,
            "POST",
            f"/draft/{CUSTOMER}/{DRAFT}/edit",
            {"text": "x" * 8001},
            token=TOKEN,
        )
        assert status == 400
        assert edited == []
    finally:
        server.shutdown()
        thread.join(timeout=3)
        server.server_close()

def test_root_escapes_rendered_evidence() -> None:
    console = OperatorConsole(
        TOKEN,
        evidence_loader=lambda customer, draft: {
            "customer_key": customer,
            "draft_id": draft,
            "state": "created",
            "draft": {
                "draft_id": draft,
                "actor": "ai",
                "text": '<script>alert("x")</script> & "quoted"',
            },
            "note": '<img src=x onerror="alert(1)">',
        },
    )
    server = console.create_server()
    thread = Thread(target=server.serve_forever, daemon=True)
    thread.start()
    try:
        status, payload = _request_body(
            server,
            "GET",
            f"/?customer={CUSTOMER}&draft={DRAFT}",
            token=TOKEN,
        )
        page = payload.decode("utf-8")
        assert status == 200
        assert "<script>alert" not in page
        assert "<img" not in page
        assert "&lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt;" in page
        assert "&lt;img src=x onerror=" in page
        assert "alert(1)" in page
        assert "&quot;" in page
    finally:
        server.shutdown()
        thread.join(timeout=3)
        server.server_close()



def test_non_loopback_bind_is_rejected() -> None:
    console = OperatorConsole(TOKEN)
    with pytest.raises(OperatorConsoleError, match="127.0.0.1"):
        console.create_server(bind_host="0.0.0.0")
    with pytest.raises(OperatorConsoleError, match="127.0.0.1"):
        console.create_server(bind_host="::1")


def test_http_approve_then_send_order_uses_canonical_events(tmp_path: Path) -> None:
    events_path = tmp_path / "events.jsonl"
    created = build_draft_created_event(CUSTOMER, DRAFT, "ai", "draft text")
    approved = build_draft_approved_event(
        CUSTOMER,
        DRAFT,
        "richard",
        "draft text",
        approved_from_draft_id=DRAFT,
    )
    sent = build_draft_sent_event(
        CUSTOMER,
        DRAFT,
        "richard",
        "draft text",
        approved_from_draft_id=DRAFT,
    )
    _write_events(events_path, created)
    callback_order: list[str] = []

    def approve(customer: str, draft: str) -> str:
        callback_order.append("approve")
        _write_events(events_path, created, approved)
        return "approved"

    def send(customer: str, draft: str) -> str:
        callback_order.append("send")
        _write_events(events_path, created, approved, sent)
        return "sent"

    console = OperatorConsole(
        TOKEN,
        events_path=events_path,
        approve_draft=approve,
        send_draft=send,
        customer_key=CUSTOMER,
    )
    server = console.create_server()
    thread = Thread(target=server.serve_forever, daemon=True)
    thread.start()
    try:
        target = f"/draft/{DRAFT}/send"
        response = _request(server, "POST", target, token=TOKEN)
        assert response.status == 409
        assert callback_order == []

        assert _request(server, "POST", f"/draft/{DRAFT}/approve", token=TOKEN).status == 200
        assert _request(server, "POST", target, token=TOKEN).status == 200
        assert callback_order == ["approve", "send"]
        assert console.evidence(CUSTOMER, DRAFT)["state"] == "sent"
    finally:
        server.shutdown()
        thread.join(timeout=3)
        server.server_close()
@pytest.mark.parametrize(
    ("text", "accepted"),
    (
        ("a" * 4096, True),
        ("a" * 4097, False),
        ("😀" * 2048, True),
        ("😀" * 2048 + "a", False),
    ),
)
def test_console_edit_uses_telegram_utf16_limit_before_callback(
    text: str,
    accepted: bool,
) -> None:
    edited: list[str] = []
    console = OperatorConsole(
        TOKEN,
        customer_key=CUSTOMER,
        edit_draft=lambda customer, draft, value: edited.append(value),
    )

    if accepted:
        result = console.edit(CUSTOMER, DRAFT, text)
        assert result is None
        assert edited == [text]
    else:
        with pytest.raises(ValueError, match="draft text is too long"):
            console.edit(CUSTOMER, DRAFT, text)
        assert edited == []
