from __future__ import annotations

import copy
import importlib.util
import json
import sqlite3
import sys
import threading
import urllib.error
import urllib.request
from datetime import date, timedelta
from pathlib import Path

import pytest

ROOT = Path(__file__).resolve().parents[1]
HEALTH = ROOT / "scripts" / "health"
sys.path.insert(0, str(HEALTH))

from dashboard_v5.contracts import safe_json, validate_bundle  # noqa: E402
from dashboard_v5.data_provider import build_bundle, medication_data, rolling_baseline  # noqa: E402
from dashboard_v5.render import render  # noqa: E402

LABELS = (
    "Aphthen/Mundulzera", "GI/Darm", "Müdigkeit/Fatigue", "Haut", "Augen",
    "Gelenke", "Vaskulär/Thrombose-Warnzeichen",
)


def load_module(path: Path, name: str):
    spec = importlib.util.spec_from_file_location(name, path)
    assert spec and spec.loader
    module = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(module)
    return module


def database() -> sqlite3.Connection:
    connection = sqlite3.connect(":memory:")
    connection.row_factory = sqlite3.Row
    connection.executescript((ROOT / "database" / "schema.sql").read_text())
    return connection


def add_complete_day(connection: sqlite3.Connection, day: str, value: int = 0) -> None:
    connection.executemany(
        "INSERT INTO symptom_log(datum,symptom,schwergrad,kontext) VALUES(?,?,?,'daily_quick_score')",
        [(day, label, f"keine ({value})" if value == 0 else f"mittel ({value})") for label in LABELS],
    )


def test_bundle_preserves_partial_missingness_and_explicit_zero() -> None:
    connection = database()
    connection.execute("INSERT INTO symptom_log(datum,symptom,schwergrad,kontext) VALUES('2026-01-01','GI/Darm','keine (0)','daily_quick_score')")
    add_complete_day(connection, "2026-01-02", 0)
    bundle = build_bundle(connection, today="2026-01-03", generated_at="2026-01-03T12:00:00+00:00")
    assert bundle["series"]["symptom.total"] == [
        {"date": "2026-01-02", "value": 0, "quality": "complete", "baseline": None}
    ]
    assert {task["id"] for task in bundle["tasks"]} == {"symptom-checkin"}
    validate_bundle(bundle)


def test_registry_baseline_starts_after_exact_configured_prior_observations() -> None:
    points = [
        {"date": "2026-01-01", "value": 1.0},
        {"date": "2026-01-02", "value": 2.0},
        {"date": "2026-01-03", "value": 9.0},
        {"date": "2026-01-04", "value": 100.0},
    ]
    baseline = rolling_baseline(points, days=14, minimum=3)
    assert [point["value"] for point in baseline] == [None, None, None, 2.0]


def test_duplicate_symptom_dimension_invalidates_complete_day() -> None:
    connection = database()
    connection.execute("DROP INDEX IF EXISTS idx_symptom_log_daily_dimension")
    add_complete_day(connection, "2026-01-02", 0)
    connection.execute(
        "INSERT INTO symptom_log(datum,symptom,schwergrad,kontext) VALUES('2026-01-02','GI/Darm','schwer (3)','daily_quick_score')"
    )
    bundle = build_bundle(connection, today="2026-01-03", generated_at="2026-01-03T12:00:00+00:00")
    assert bundle["series"]["symptom.total"] == []


def test_invalid_duplicate_symptom_dimension_still_invalidates_complete_day() -> None:
    connection = database()
    connection.execute("DROP INDEX IF EXISTS idx_symptom_log_daily_dimension")
    add_complete_day(connection, "2026-01-02", 0)
    connection.execute(
        "INSERT INTO symptom_log(datum,symptom,schwergrad,kontext) VALUES('2026-01-02','GI/Darm','not-a-score','daily_quick_score')"
    )
    bundle = build_bundle(connection, today="2026-01-03", generated_at="2026-01-03T12:00:00+00:00")
    assert bundle["series"]["symptom.total"] == []


def test_next_medication_is_earliest_future_and_status_safe() -> None:
    connection = database()
    connection.executemany(
        """INSERT INTO medication_administrations
           (datum,medication_name,dose,event_type,scheduled_next_date) VALUES(?,?,?,?,?)""",
        [
            ("2026-01-01", "Actual", "40 mg", "administered", "2026-02-20"),
            ("2026-01-02", "Cancelled", "40 mg", "cancelled", "2026-02-05"),
            ("2026-01-03", "Actual", "40 mg", "verabreicht", "2026-02-10"),
            ("2026-01-04", "Missed", "40 mg", "missed", "2026-02-01"),
            ("2026-02-03", "Explicit Plan", "40 mg", "planned", None),
        ],
    )
    medication, events = medication_data(connection, "2026-02-01")
    assert medication["last_administered"]["date"] == "2026-01-03"
    assert medication["next_planned"] == {"date": "2026-02-03", "name": "Explicit Plan", "dose": "40 mg", "status": "planned"}
    assert [event["label"] for event in events] == ["Actual", "Actual"]


def test_medication_planning_is_fail_closed_specific_and_strictly_future() -> None:
    connection = database()
    connection.executemany(
        """INSERT INTO medication_administrations
           (datum,medication_name,dose,event_type,scheduled_next_date) VALUES(?,?,?,?,?)""",
        [
            ("2026-01-01", "Drug A", "10 mg", "unknown typo", "2026-02-03"),
            ("2026-02-02", "Future source", "10 mg", "administered", "2026-02-10"),
            ("2026-01-20", "Drug B", "20 mg", "administered", "2026-02-04"),
            ("2026-02-04", "Drug A", "10 mg", "cancelled", None),
            ("2026-01-21", "Same day", "5 mg", "administered", "2026-02-01"),
        ],
    )
    medication, _events = medication_data(connection, "2026-02-01")
    assert medication["next_planned"] == {
        "date": "2026-02-04", "name": "Drug B", "dose": "20 mg", "status": "planned",
    }


def test_documented_german_injection_aliases_and_scheduled_next_date_are_projected() -> None:
    connection = database()
    connection.executemany(
        """INSERT INTO medication_administrations
           (datum,medication_name,dose,event_type,scheduled_next_date) VALUES(?,?,?,?,?)""",
        [
            ("2026-01-01", "Therapy", "10 mg", "Erste Injektion / Therapiebeginn", "2026-01-15"),
            ("2026-01-15", "Therapy", "10 mg", "Planmäßige Injektion", "2026-01-29"),
            ("2026-02-20", "Future", "10 mg", "Planmäßige Injektion", None),
        ],
    )
    medication, events = medication_data(connection, "2026-01-20")
    assert medication["last_administered"]["date"] == "2026-01-15"
    assert medication["next_planned"] == {
        "date": "2026-01-29", "name": "Therapy", "dose": "10 mg", "status": "planned",
    }
    assert [event["date"] for event in events] == ["2026-01-01", "2026-01-15"]


def test_only_original_verified_labs_enter_bundle() -> None:
    connection = database()
    connection.executemany(
        """INSERT INTO laborwerte
           (parameter_name,wert,einheit,reference_min,reference_max,abnahme_datum,validierungsstatus,
            reference_range_source,verified_against_original,source_type)
           VALUES(?,?,?,?,?,?,?,?,?,?)""",
        [
            ("C-Reaktives Protein (CRP)", "12", "mg/L", "0", "5", "2026-01-02", "validiert", "scanned_original", 1, "pdf"),
            ("D-Dimer", "99", "µg/L", "0", "5", "2026-01-02", "validiert", "xlsx", 0, "xlsx"),
            ("Ferritin", "8", "ng/mL", None, None, "2026-01-02", "validiert", "unknown", 1, "pdf"),
        ],
    )
    bundle = build_bundle(connection, today="2026-01-03", generated_at="2026-01-03T12:00:00+00:00")
    assert [lab["parameter"] for lab in bundle["labs"]] == ["CRP"]
    assert bundle["labs"][0]["quality"] == "verified_original"


def test_verified_labs_fail_closed_on_duplicates_censoring_and_unknown_features() -> None:
    connection = database()
    connection.executemany(
        """INSERT INTO laborwerte
           (parameter_name,wert,einheit,abnahme_datum,validierungsstatus,
            reference_range_source,verified_against_original)
           VALUES(?,?,?,?,?,?,?)""",
        [
            ("CRP", "2", "mg/L", "2026-01-02", "validiert", "scanned_original", 1),
            ("C-Reaktives Protein (CRP)", "<3", "mg/L", "2026-01-02", "validiert", "scanned_original", 1),
            ("D-Dimer", "<100", "µg/L", "2026-01-02", "validiert", "scanned_original", 1),
            ("UNKNOWN_PARAMETER", "4", "mg/L", "2026-01-02", "validiert", "scanned_original", 1),
        ],
    )
    bundle = build_bundle(connection, today="2026-01-03", generated_at="2026-01-03T12:00:00+00:00")
    assert bundle["labs"] == []


def test_incomplete_histamine_is_null_not_zero() -> None:
    connection = database()
    connection.executemany(
        """INSERT INTO nutrition_daily_summary_v2
           (datum,kcal,item_count,histamine_score,histamine_unknown_count,histamine_label)
           VALUES(?,?,?,?,?,?)""",
        [
            ("2026-01-01", None, 1, 0, 1, "green"),
            ("2026-01-02", 0, 1, 0, 0, "green"),
        ],
    )
    bundle = build_bundle(connection, today="2026-01-03", generated_at="2026-01-03T12:00:00+00:00")
    daily = bundle["nutrition"]["daily"]
    assert daily[0]["histamine_score"] is None and daily[0]["quality"] == "incomplete"
    assert daily[1]["histamine_score"] == 0 and daily[1]["kcal"] == 0


def test_render_has_real_hidden_views_and_safe_json() -> None:
    connection = database()
    bundle = build_bundle(connection, today="2026-01-03", generated_at="2026-01-03T12:00:00+00:00")
    bundle["tasks"].append({"id": "escape", "label": "</script><script>alert(1)</script>", "action": "none"})
    page = render(bundle)
    assert page.count("data-dashboard-panel='") == 6
    assert "id='view-today'" in page
    assert "id='view-cockpit'" in page and "data-dashboard-panel='cockpit'" in page
    assert "\\u003c/script>" in page
    assert "<script>alert(1)</script>" not in page
    assert "localStorage" not in page
    json.loads(safe_json(bundle))


def test_contract_rejects_missing_unknown_nested_and_path_text() -> None:
    connection = database()
    bundle = build_bundle(connection, today="2026-01-03", generated_at="2026-01-03T12:00:00+00:00")

    missing = copy.deepcopy(bundle)
    del missing["nutrition"]["mapping_open_count"]
    with pytest.raises(ValueError):
        validate_bundle(missing)

    unknown = copy.deepcopy(bundle)
    unknown["nutrition"]["unexpected"] = "value"
    with pytest.raises(ValueError):
        validate_bundle(unknown)

    raw_path = copy.deepcopy(bundle)
    raw_path["medication"]["next_planned"] = {
        "date": "2026-01-04", "name": "/home/person/private.pdf", "dose": "40 mg", "status": "planned"
    }
    with pytest.raises(ValueError):
        validate_bundle(raw_path)

    unsafe_lab = copy.deepcopy(bundle)
    unsafe_lab["labs"] = [{
        "parameter": "FREE TEXT HOSPITAL LABEL", "value": "1", "unit": "arbitrary",
        "date": "2026-01-02", "reference_min": None, "reference_max": None,
        "quality": "verified_original", "source_type": "xlsx",
    }]
    with pytest.raises(ValueError, match="lab provenance"):
        validate_bundle(unsafe_lab)

    wrong_pair = copy.deepcopy(bundle)
    wrong_pair["labs"] = [{
        "parameter": "CRP", "value": "1", "unit": "arbitrary",
        "date": "2026-01-02", "reference_min": None, "reference_max": None,
        "quality": "verified_original", "source_type": "scanned_original",
    }]
    with pytest.raises(ValueError, match="allowlisted lab"):
        validate_bundle(wrong_pair)

    for unsafe_value in ("NOT A NUMBER", "١٢", "１２", "<5", "1e3", "NaN"):
        non_numeric = copy.deepcopy(bundle)
        non_numeric["labs"] = [{
            "parameter": "CRP", "value": unsafe_value, "unit": "mg/L",
            "date": "2026-01-02", "reference_min": None, "reference_max": None,
            "quality": "verified_original", "source_type": "scanned_original",
        }]
        with pytest.raises(ValueError, match="non-numeric verified lab"):
            validate_bundle(non_numeric)

    unsafe_reference = copy.deepcopy(bundle)
    unsafe_reference["labs"] = [{
        "parameter": "CRP", "value": "5.2", "unit": "mg/L", "date": "2026-01-02",
        "reference_min": "not numeric", "reference_max": None,
        "quality": "verified_original", "source_type": "scanned_original",
    }]
    with pytest.raises(ValueError, match="non-numeric lab reference"):
        validate_bundle(unsafe_reference)


def test_contract_rejects_unordered_future_and_inconsistent_series_points() -> None:
    connection = database()
    for index, day in enumerate(("2026-01-01", "2026-01-02", "2026-01-03", "2026-01-04")):
        add_complete_day(connection, day, index % 3)
    bundle = build_bundle(connection, today="2026-01-04", generated_at="2026-01-04T12:00:00+00:00")
    validate_bundle(bundle)
    assert bundle["series"]["symptom.total"][3]["baseline"] is not None

    unordered = copy.deepcopy(bundle)
    unordered["series"]["symptom.total"][0], unordered["series"]["symptom.total"][1] = (
        unordered["series"]["symptom.total"][1], unordered["series"]["symptom.total"][0],
    )
    with pytest.raises(ValueError):
        validate_bundle(unordered)

    future = copy.deepcopy(bundle)
    future["series"]["symptom.total"][-1]["date"] = "2026-01-05"
    with pytest.raises(ValueError):
        validate_bundle(future)

    inconsistent = copy.deepcopy(bundle)
    inconsistent["series"]["symptom.total"][0]["value"] = None
    with pytest.raises(ValueError):
        validate_bundle(inconsistent)

    wrong_baseline = copy.deepcopy(bundle)
    wrong_baseline["series"]["symptom.total"][3]["baseline"] = 999
    with pytest.raises(ValueError):
        validate_bundle(wrong_baseline)


def test_generator_uses_injected_database_and_atomic_private_output(tmp_path: Path) -> None:
    db = tmp_path / "synthetic.db"
    connection = sqlite3.connect(db)
    connection.executescript((ROOT / "database" / "schema.sql").read_text())
    connection.close()
    spec = importlib.util.spec_from_file_location("dashboard_v5_generator", HEALTH / "health_dashboard_v5.py")
    assert spec and spec.loader
    module = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(module)
    output = tmp_path / "preview.html"
    assert module.generate(db, output, today="2026-01-03") == output
    assert output.stat().st_mode & 0o777 == 0o600
    assert "JARVIS Health Dashboard v5" in output.read_text()


def test_generator_cli_requires_database_before_opening_any_database(monkeypatch: pytest.MonkeyPatch) -> None:
    module = load_module(HEALTH / "health_dashboard_v5.py", "dashboard_v5_required_db_test")
    opened = False

    def forbidden_connect(_path: Path) -> sqlite3.Connection:
        nonlocal opened
        opened = True
        raise AssertionError("database must not be opened")

    monkeypatch.setattr(module, "connect", forbidden_connect)
    with pytest.raises(SystemExit) as error:
        module.main([])
    assert error.value.code == 2
    assert opened is False


def test_worker_passes_explicit_database_to_v5_regeneration(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    worker = load_module(HEALTH / "health_dashboard_action_worker.py", "health_dashboard_action_worker_v5_test")
    calls: list[list[str]] = []

    def fake_run(command: list[str], **_kwargs: object) -> object:
        calls.append(command)
        return object()

    monkeypatch.setattr(worker, "DASHBOARD_V4", tmp_path / "v4.py")
    monkeypatch.setattr(worker, "DASHBOARD_V5", tmp_path / "v5.py")
    monkeypatch.setattr(worker, "DASHBOARD_FILE", tmp_path / "v4.html")
    monkeypatch.setattr(worker, "DASHBOARD_V5_FILE", tmp_path / "v5.html")
    monkeypatch.setattr(worker, "DASHBOARD_DB", tmp_path / "synthetic.db")
    monkeypatch.setattr(worker.subprocess, "run", fake_run)
    worker.apply_action("2026-01-03", {field: 0 for field in worker.FIELDS}, "")
    assert [Path(command[1]).name for command in calls] == [
        "health_symptom_quick_add.py",
        "v5.py",
    ]
    assert calls[1][2:4] == ["--db", str(tmp_path / "synthetic.db")]


def test_canonical_synthetic_fixture_drives_complete_v5_bundle(tmp_path: Path) -> None:
    spec = importlib.util.spec_from_file_location(
        "dashboard_v5_fixture", ROOT / "tests/fixtures/dashboard_v5_fixture.py"
    )
    assert spec and spec.loader
    fixture = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(fixture)
    db = tmp_path / "fixture.db"
    manifest = fixture.build_dashboard_v5_fixture(db)
    checks = fixture.validate_fixture(db)
    assert manifest["synthetic_only"] is True
    assert checks["checks"]["integrity"] == "ok"
    connection = connect_for_test(db)
    try:
        bundle = build_bundle(
            connection,
            today=str(manifest["anchor_date"]),
            generated_at="2026-06-15T12:00:00+00:00",
        )
    finally:
        connection.close()
    assert len(bundle["series"]["symptom.total"]) == 8
    assert all(
        point["date"] <= bundle["today"]
        for points in bundle["series"].values()
        for point in points
    )
    assert len(bundle["labs"]) == 6
    assert bundle["medication"]["last_administered"]["name"] == "SYNTHETIC_ADMINISTERED_MEDICATION"
    assert bundle["nutrition"]["daily"][-1]["histamine_score"] is None


def connect_for_test(path: Path) -> sqlite3.Connection:
    connection = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
    connection.row_factory = sqlite3.Row
    return connection


def test_server_exposes_v5_preview_and_exact_asset_allowlist(tmp_path: Path, monkeypatch) -> None:
    v4 = tmp_path / "v4.html"
    v5 = tmp_path / "v5.html"
    v4.write_text("<html>v4</html>", encoding="utf-8")
    v5.write_text("<html><script>window.v5=true</script></html>", encoding="utf-8")
    assets = tmp_path / "assets"
    assets.mkdir()
    for name in ("chart.umd.min.js", "dashboard-v5.js", "dashboard-v5.css"):
        (assets / name).write_text("/* synthetic asset */", encoding="utf-8")
    monkeypatch.setenv("HEALTH_DASHBOARD_FILE", str(v4))
    monkeypatch.setenv("HEALTH_DASHBOARD_V5_FILE", str(v5))
    monkeypatch.delenv("HEALTH_DASHBOARD_DB", raising=False)
    spec = importlib.util.spec_from_file_location(
        "health_dashboard_server_v5_test", HEALTH / "health_dashboard_server.py"
    )
    assert spec and spec.loader
    server_module = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(server_module)
    monkeypatch.setattr(server_module, "ASSET_DIR", assets)
    server = server_module.ThreadingHTTPServer(("127.0.0.1", 0), server_module.Handler)
    thread = threading.Thread(target=server.serve_forever, daemon=True)
    thread.start()
    base = f"http://127.0.0.1:{server.server_port}"
    try:
        response = urllib.request.urlopen(base + "/health-dashboard-v5", timeout=5)
        assert response.status == 200
        assert "nonce-" in response.headers["Content-Security-Policy"]
        assert b"window.v5=true" in response.read()
        for name in ("dashboard-v5.js", "dashboard-v5.css"):
            assert urllib.request.urlopen(base + "/health-assets/" + name, timeout=5).status == 200
        with pytest_http_error(404):
            urllib.request.urlopen(base + "/health-assets/not-allowlisted.js", timeout=5)
    finally:
        server.shutdown()
        server.server_close()
        thread.join(timeout=5)


def test_bundle_count_and_serialized_byte_budgets_are_enforced() -> None:
    bundle = build_bundle(database(), today="2026-01-03", generated_at="2026-01-03T12:00:00+00:00")
    oversized_count = copy.deepcopy(bundle)
    oversized_count["events"] = [
        {"date": "2026-01-01", "type": "medication_administered", "label": "Synthetic"}
        for _ in range(1001)
    ]
    with pytest.raises(ValueError, match="too many items"):
        validate_bundle(oversized_count)

    oversized_bytes = copy.deepcopy(bundle)
    anchor = date(2026, 1, 3)
    raw_points = [
        {"date": (anchor - timedelta(days=1499 - index)).isoformat(), "value": float(index)}
        for index in range(1500)
    ]
    oversized_bytes["series"] = {}
    for metric in oversized_bytes["metrics"]:
        baselines = rolling_baseline(
            raw_points,
            days=metric["baseline_days"],
            minimum=metric["baseline_min_observations"],
        )
        oversized_bytes["series"][metric["id"]] = [
            {
                "date": point["date"], "value": point["value"], "quality": "observed",
                "baseline": baselines[index]["value"],
            }
            for index, point in enumerate(raw_points)
        ]
    with pytest.raises(ValueError, match="serialized size budget"):
        safe_json(oversized_bytes)


class pytest_http_error:
    def __init__(self, code: int):
        self.code = code

    def __enter__(self):
        return self

    def __exit__(self, kind, value, traceback):
        return kind is urllib.error.HTTPError and value.code == self.code
