"""Tests for abreka.webui — config page prompt cards and API."""

from __future__ import annotations

import json

import pytest
from httpx import ASGITransport, AsyncClient

from abreka.config import load_prompts
from abreka.webui.app import create_app


class TestLoadPrompts:
    def test_loads_all_prompt_files(self, abreka_project):
        abreka_dir = abreka_project / "abreka"
        prompts = load_prompts(abreka_dir)
        assert list(prompts.keys()) == ["propose_exploit", "propose_explore", "implement", "test", "repair_run", "evaluate"]

    def test_prompt_content(self, abreka_project):
        abreka_dir = abreka_project / "abreka"
        prompts = load_prompts(abreka_dir)
        assert "# propose_exploit" in prompts["propose_exploit"]

    def test_empty_when_no_prompts_dir(self, abreka_project):
        abreka_dir = abreka_project / "abreka"
        import shutil
        shutil.rmtree(abreka_dir / "prompts")
        prompts = load_prompts(abreka_dir)
        assert prompts == {}

    def test_ignores_non_md_files(self, abreka_project):
        abreka_dir = abreka_project / "abreka"
        (abreka_dir / "prompts" / "notes.txt").write_text("ignore me")
        prompts = load_prompts(abreka_dir)
        assert "notes" not in prompts


@pytest.fixture
def app_client(abreka_project):
    """Create an async httpx client for the webui app."""
    abreka_dir = abreka_project / "abreka"
    app = create_app(abreka_dir)
    transport = ASGITransport(app=app)
    return AsyncClient(transport=transport, base_url="http://test")


@pytest.mark.anyio
class TestConfigViewPrompts:
    async def test_config_page_shows_prompts(self, app_client):
        async with app_client as client:
            resp = await client.get("/config")
        assert resp.status_code == 200
        html = resp.text
        # Each prompt should appear with its file path header
        for name in ("propose_exploit", "propose_explore", "implement", "test", "repair_run", "evaluate"):
            assert f"abreka/prompts/{name}.md" in html

    async def test_config_page_prompt_cards_are_editable(self, app_client):
        async with app_client as client:
            resp = await client.get("/config")
        html = resp.text
        # Each prompt card should have the correct API endpoint
        for name in ("propose_exploit", "propose_explore", "implement", "test", "repair_run", "evaluate"):
            assert f'/api/prompts/{name}' in html

    async def test_config_page_renders_prompt_content(self, app_client):
        async with app_client as client:
            resp = await client.get("/config")
        html = resp.text
        # The rendered markdown should contain heading text from the prompts
        assert "propose" in html


@pytest.mark.anyio
class TestPromptSaveAPI:
    async def test_save_prompt(self, app_client, abreka_project):
        async with app_client as client:
            resp = await client.put(
                "/api/prompts/propose_exploit",
                json={"content": "# New Proposal\nUpdated prompt."},
            )
        assert resp.status_code == 200
        assert resp.json() == {"ok": True}
        saved = (abreka_project / "abreka" / "prompts" / "propose_exploit.md").read_text()
        assert saved == "# New Proposal\nUpdated prompt."

    async def test_save_prompt_invalid_name(self, app_client):
        async with app_client as client:
            resp = await client.put(
                "/api/prompts/bad%20name!",
                json={"content": "hax"},
            )
        assert resp.status_code == 400

    async def test_save_prompt_unknown_name(self, app_client):
        async with app_client as client:
            resp = await client.put(
                "/api/prompts/nonexistent",
                json={"content": "test"},
            )
        assert resp.status_code == 404


@pytest.fixture
def tree_client(abreka_project):
    """Client with a populated experiment index for tree tests."""
    abreka_dir = abreka_project / "abreka"
    index = {
        "0001": {
            "id": "0001",
            "status": "completed",
            "hypothesis": "Root experiment",
            "parents": [],
            "metrics": {"val": {"accuracy": 0.85}},
            "created_at": "2026-01-01T00:00:00",
        },
        "0002": {
            "id": "0002",
            "status": "running",
            "hypothesis": "Child of root",
            "parents": ["0001"],
            "metrics": {},
            "created_at": "2026-01-02T00:00:00",
        },
        "0003": {
            "id": "0003",
            "status": "proposed",
            "hypothesis": "Grandchild experiment",
            "parents": ["0002"],
            "metrics": {},
            "created_at": "2026-01-03T00:00:00",
        },
        "0004": {
            "id": "0004",
            "status": "completed",
            "hypothesis": "Leaf with no children",
            "parents": [],
            "metrics": {},
            "created_at": "2026-01-04T00:00:00",
        },
    }
    (abreka_dir / "index.json").write_text(json.dumps(index))
    app = create_app(abreka_dir)
    transport = ASGITransport(app=app)
    return AsyncClient(transport=transport, base_url="http://test")


@pytest.mark.anyio
class TestTreeCollapsible:
    async def test_tree_page_renders(self, tree_client):
        async with tree_client as client:
            resp = await client.get("/tree")
        assert resp.status_code == 200
        html = resp.text
        assert "0001" in html
        assert "0004" in html

    async def test_parent_nodes_use_details_element(self, tree_client):
        """Parent nodes should be wrapped in <details> for collapsibility."""
        async with tree_client as client:
            resp = await client.get("/tree")
        html = resp.text
        # Nodes with children (0001, 0002) should use <details>
        assert "<details" in html
        assert "<summary" in html

    async def test_leaf_nodes_no_details(self, tree_client):
        """Only nodes with children get <details>; count should equal parent nodes."""
        async with tree_client as client:
            resp = await client.get("/tree")
        html = resp.text
        # 0001 and 0002 have children, so exactly 2 <details> elements
        assert html.count("<details") == 2

    async def test_expand_collapse_buttons_present(self, tree_client):
        async with tree_client as client:
            resp = await client.get("/tree")
        html = resp.text
        assert "Expand All" in html
        assert "Collapse All" in html

    async def test_details_open_by_default(self, tree_client):
        """All <details> should be open by default so tree starts expanded."""
        async with tree_client as client:
            resp = await client.get("/tree")
        html = resp.text
        # Every <details should have the open attribute
        import re
        details_tags = re.findall(r"<details[^>]*>", html)
        assert len(details_tags) > 0
        for tag in details_tags:
            assert "open" in tag


@pytest.mark.anyio
class TestExperimentsSortOrder:
    async def test_default_sort_is_created_descending(self, tree_client):
        """Experiments should be sorted by created_at descending by default."""
        async with tree_client as client:
            resp = await client.get("/experiments")
        html = resp.text
        # 0004 (newest) should appear before 0001 (oldest)
        assert html.index("0004") < html.index("0001")

    async def test_sort_created_ascending(self, tree_client):
        """?sort=created&order=asc should show oldest first."""
        async with tree_client as client:
            resp = await client.get("/experiments?sort=created&order=asc")
        html = resp.text
        assert html.index("0001") < html.index("0004")

    async def test_sort_created_descending_explicit(self, tree_client):
        """?sort=created&order=desc should show newest first."""
        async with tree_client as client:
            resp = await client.get("/experiments?sort=created&order=desc")
        html = resp.text
        assert html.index("0004") < html.index("0001")

    async def test_sort_metric_descending(self, tree_client):
        """?sort=metric&order=desc should show highest metric first."""
        async with tree_client as client:
            resp = await client.get("/experiments?sort=metric&order=desc")
        html = resp.text
        # 0001 has accuracy=0.85, others have no metric — 0001 should be first
        tbody = html[html.index("<tbody>"):]
        assert tbody.index("0001") < tbody.index("0002")

    async def test_sort_metric_ascending(self, tree_client):
        """?sort=metric&order=asc — experiments without metrics sort last."""
        async with tree_client as client:
            resp = await client.get("/experiments?sort=metric&order=asc")
        html = resp.text
        tbody = html[html.index("<tbody>"):]
        # 0001 has the only metric; with asc it should come first (others have None)
        assert tbody.index("0001") < tbody.index("0004")


@pytest.fixture
def detail_client(abreka_project):
    """Client with experiment TOML files and index for detail page tests."""
    abreka_dir = abreka_project / "abreka"
    from abreka.models import Experiment
    from abreka.persistence import save_experiment, update_index_entry

    parent = Experiment(id="0001", hypothesis="Root experiment")
    child_a = Experiment(id="0002", hypothesis="Child A", parents=["0001"])
    child_b = Experiment(id="0003", hypothesis="Child B", parents=["0001"])

    for exp in (parent, child_a, child_b):
        (abreka_dir / "experiments" / exp.id).mkdir(parents=True, exist_ok=True)
        save_experiment(exp, abreka_dir)
        update_index_entry(abreka_dir, exp)

    app = create_app(abreka_dir)
    transport = ASGITransport(app=app)
    return AsyncClient(transport=transport, base_url="http://test")


@pytest.mark.anyio
class TestExperimentDetailChildren:
    async def test_children_links_shown_on_parent(self, detail_client):
        """Parent's detail page should list links to its children."""
        async with detail_client as client:
            resp = await client.get("/experiments/0001")
        assert resp.status_code == 200
        html = resp.text
        assert 'href="/experiments/0002"' in html
        assert 'href="/experiments/0003"' in html

    async def test_no_children_shows_none(self, detail_client):
        """Leaf experiment should show 'none' for children."""
        async with detail_client as client:
            resp = await client.get("/experiments/0002")
        assert resp.status_code == 200
        html = resp.text
        assert "Children" in html
        # 0002 has no children
        assert html.count('Children') >= 1


@pytest.mark.anyio
class TestExperimentDetailRuntimeCosts:
    async def test_detail_page_shows_runtime_costs(self, abreka_project):
        from abreka.models import Experiment
        from abreka.persistence import save_experiment, update_index_entry

        abreka_dir = abreka_project / "abreka"
        exp = Experiment(
            id="0001",
            hypothesis="Runtime cost test",
            runtime_costs={
                "propose": {
                    "attempts": 1,
                    "seconds_total": 12.5,
                    "seconds_last": 12.5,
                    "last_status": "ok",
                },
                "run": {
                    "attempts": 2,
                    "seconds_total": 91.2,
                    "seconds_last": 45.6,
                    "last_status": "timed_out",
                },
            },
        )
        (abreka_dir / "experiments" / exp.id).mkdir(parents=True, exist_ok=True)
        save_experiment(exp, abreka_dir)
        update_index_entry(abreka_dir, exp)

        app = create_app(abreka_dir)
        transport = ASGITransport(app=app)
        async with AsyncClient(transport=transport, base_url="http://test") as client:
            resp = await client.get("/experiments/0001")

        assert resp.status_code == 200
        html = resp.text
        assert "Runtime Costs" in html
        assert "propose" in html
        assert "run" in html
        assert "12.50" in html
        assert "91.20" in html
        assert "45.60" in html
        assert "timed_out" in html
        assert "Total runtime: 1m 43.70s" in html


@pytest.fixture
def backlog_client(abreka_project):
    """Client with populated backlog index for backlog tests."""
    abreka_dir = abreka_project / "abreka"
    from abreka.models import BacklogItem
    from abreka.persistence import save_backlog_item, update_backlog_index_entry

    items = [
        BacklogItem(id="0001", title="Try attention mechanism", priority=5,
                    source_exp="0002", tags=["arch"]),
        BacklogItem(id="0002", title="Lower learning rate", priority=2,
                    tags=["hyperparam"]),
    ]
    for item in items:
        save_backlog_item(item, abreka_dir)
        update_backlog_index_entry(abreka_dir, item)

    app = create_app(abreka_dir)
    transport = ASGITransport(app=app)
    return AsyncClient(transport=transport, base_url="http://test")


@pytest.mark.anyio
class TestBacklogPages:
    async def test_backlog_list_renders(self, backlog_client):
        async with backlog_client as client:
            resp = await client.get("/backlog")
        assert resp.status_code == 200
        html = resp.text
        assert "Try attention mechanism" in html
        assert "Lower learning rate" in html

    async def test_backlog_list_filter_by_status(self, backlog_client):
        async with backlog_client as client:
            resp = await client.get("/backlog?status=dismissed")
        assert resp.status_code == 200
        html = resp.text
        assert "No backlog items" in html

    async def test_backlog_detail_renders(self, backlog_client):
        async with backlog_client as client:
            resp = await client.get("/backlog/1")
        assert resp.status_code == 200
        html = resp.text
        assert "Try attention mechanism" in html
        assert "0002" in html  # source_exp link

    async def test_backlog_nav_link(self, backlog_client):
        async with backlog_client as client:
            resp = await client.get("/")
        html = resp.text
        assert 'href="/backlog"' in html


@pytest.fixture
def search_client(abreka_project):
    """Client with a rich experiment index for search tests."""
    abreka_dir = abreka_project / "abreka"
    index = {
        "0001": {
            "id": "0001",
            "status": "completed",
            "hypothesis": "Baseline CNN model",
            "method": "Train a simple CNN",
            "parents": [],
            "tags": ["cnn", "baseline"],
            "created_at": "2026-01-01T00:00:00",
            "metrics": {"val": {"accuracy": 0.85}},
        },
        "0002": {
            "id": "0002",
            "status": "running",
            "hypothesis": "Add attention mechanism",
            "method": "Integrate self-attention into CNN",
            "parents": ["0001"],
            "tags": ["cnn", "attention"],
            "created_at": "2026-01-02T00:00:00",
            "metrics": {},
        },
        "0003": {
            "id": "0003",
            "status": "completed",
            "hypothesis": "Transformer encoder replaces CNN",
            "method": "Use vision transformer",
            "parents": ["0001"],
            "tags": ["transformer"],
            "created_at": "2026-01-03T00:00:00",
            "metrics": {"val": {"accuracy": 0.92}},
        },
        "0004": {
            "id": "0004",
            "status": "failed",
            "hypothesis": "Data augmentation with mixup",
            "method": "Apply mixup augmentation",
            "parents": [],
            "tags": ["augmentation"],
            "created_at": "2026-01-04T00:00:00",
            "metrics": {},
        },
        "0005": {
            "id": "0005",
            "status": "proposed",
            "hypothesis": "Larger transformer with more heads",
            "method": "Scale up transformer architecture",
            "parents": ["0003"],
            "tags": ["transformer", "scaling"],
            "created_at": "2026-01-05T00:00:00",
            "metrics": {},
        },
    }
    (abreka_dir / "index.json").write_text(json.dumps(index))
    app = create_app(abreka_dir)
    transport = ASGITransport(app=app)
    return AsyncClient(transport=transport, base_url="http://test")


@pytest.mark.anyio
class TestSearchPage:
    async def test_search_page_renders(self, search_client):
        async with search_client as client:
            resp = await client.get("/search")
        assert resp.status_code == 200
        assert "Search" in resp.text

    async def test_search_text_query(self, search_client):
        async with search_client as client:
            resp = await client.get("/search?q=transformer")
        html = resp.text
        assert "0003" in html
        assert "0005" in html
        # CNN-only experiments should not appear
        assert "Baseline CNN model" not in html

    async def test_search_word_boundary(self, search_client):
        """Plain text uses word-boundary matching, not substring."""
        async with search_client as client:
            # "CNN" should match "Baseline CNN model" but NOT "CNNs" if it existed
            # More importantly: "attention" should match exact word
            resp = await client.get("/search?q=attention")
        html = resp.text
        assert "0002" in html  # "Add attention mechanism"
        # "augmentation" contains "attention" as substring — should NOT match
        assert "Data augmentation" not in html

    async def test_search_regex_syntax(self, search_client):
        """Queries wrapped in /slashes/ use regex matching."""
        async with search_client as client:
            # /self-attention/ should match as substring (regex, not word-boundary)
            resp = await client.get("/search?q=/self-attention/")
        html = resp.text
        assert "0002" in html  # method contains "self-attention"

    async def test_search_regex_case_sensitive(self, search_client):
        """Regex without i flag is case-sensitive."""
        async with search_client as client:
            # "Baseline" has capital B; /baseline/ (lowercase) should not match
            resp = await client.get("/search?q=/baseline CNN/")
        html = resp.text
        # hypothesis is "Baseline CNN model" — capital B, so no match
        assert "0001" not in html

    async def test_search_invalid_regex_shows_error(self, search_client):
        """Invalid regex should return results page (not crash)."""
        async with search_client as client:
            resp = await client.get("/search?q=/[unclosed/")
        assert resp.status_code == 200
        assert "Invalid regex" in resp.text

    async def test_search_text_no_results(self, search_client):
        async with search_client as client:
            resp = await client.get("/search?q=nonexistent")
        assert "No experiments found" in resp.text

    async def test_search_filter_status(self, search_client):
        async with search_client as client:
            resp = await client.get("/search?status=completed")
        html = resp.text
        assert "0001" in html
        assert "0003" in html
        # Running experiment should not appear
        assert "Add attention mechanism" not in html

    async def test_search_filter_multiple_status(self, search_client):
        async with search_client as client:
            resp = await client.get("/search?status=completed&status=running")
        html = resp.text
        assert "0001" in html
        assert "0002" in html
        assert "0003" in html
        # Failed and proposed should not appear
        assert "Data augmentation" not in html
        assert "Larger transformer" not in html

    async def test_search_filter_tag(self, search_client):
        async with search_client as client:
            resp = await client.get("/search?tag=cnn")
        html = resp.text
        assert "0001" in html
        assert "0002" in html
        # Non-CNN experiments should not appear
        assert "Transformer encoder" not in html

    async def test_search_filter_has_metrics_yes(self, search_client):
        async with search_client as client:
            resp = await client.get("/search?has_metrics=yes")
        html = resp.text
        assert "0001" in html
        assert "0003" in html
        # Experiments without metrics should not appear
        assert "Add attention mechanism" not in html
        assert "Data augmentation" not in html

    async def test_search_filter_has_metrics_no(self, search_client):
        async with search_client as client:
            resp = await client.get("/search?has_metrics=no")
        html = resp.text
        assert "0002" in html
        assert "0004" in html
        assert "0005" in html
        # Experiments with metrics should not appear
        assert "Baseline CNN" not in html

    async def test_search_filter_root_only(self, search_client):
        async with search_client as client:
            resp = await client.get("/search?root_only=1")
        html = resp.text
        assert "0001" in html
        assert "0004" in html
        # Experiments with parents should not appear
        assert "Add attention mechanism" not in html
        assert "Transformer encoder" not in html

    async def test_search_combined_filters(self, search_client):
        """Text query + status + tag should all apply together."""
        async with search_client as client:
            resp = await client.get("/search?q=CNN&status=completed&tag=cnn")
        html = resp.text
        # Only 0001 matches: completed + tag=cnn + "CNN" in text
        assert "0001" in html
        assert "0002" not in html  # running, not completed
        assert "0003" not in html  # no cnn tag

    async def test_search_facet_counts(self, search_client):
        """Facet sidebar should show status counts."""
        async with search_client as client:
            resp = await client.get("/search")
        html = resp.text
        # All 5 experiments unfiltered — check that status facets are present
        assert "completed" in html
        assert "running" in html
        assert "failed" in html
        assert "proposed" in html

    async def test_search_nav_link(self, search_client):
        async with search_client as client:
            resp = await client.get("/")
        html = resp.text
        assert 'href="/search"' in html


class TestHumanDurationFilter:
    def test_formats_seconds_under_one_minute(self):
        from abreka.webui.app import _human_duration_filter

        assert _human_duration_filter(43.7) == "43.70s"

    def test_formats_minutes_and_seconds(self):
        from abreka.webui.app import _human_duration_filter

        assert _human_duration_filter(103.7) == "1m 43.70s"

    def test_formats_hours_minutes_and_seconds(self):
        from abreka.webui.app import _human_duration_filter

        assert _human_duration_filter(3661.2) == "1h 1m 1.20s"


class TestComputeMetricTimeline:
    def test_filters_experiments_without_primary_metric(self):
        from abreka.webui.app import _compute_metric_timeline
        index = {
            "0001": {
                "id": "0001", "status": "completed", "parents": [],
                "metrics": {"val": {"accuracy": 0.80}},
            },
            "0002": {
                "id": "0002", "status": "running", "parents": ["0001"],
                "metrics": {},
            },
            "0003": {
                "id": "0003", "status": "completed", "parents": ["0001"],
                "metrics": {"val": {"other_metric": 0.5}},
            },
        }
        result = _compute_metric_timeline(index, "val:accuracy", "maximize")
        ids = [p["id"] for p in result["points"]]
        assert ids == ["0001"]

    def test_sorts_by_experiment_number(self):
        from abreka.webui.app import _compute_metric_timeline
        index = {
            "0003": {"id": "0003", "parents": [], "metrics": {"val": {"accuracy": 0.9}}},
            "0001": {"id": "0001", "parents": [], "metrics": {"val": {"accuracy": 0.7}}},
            "0002": {"id": "0002", "parents": [], "metrics": {"val": {"accuracy": 0.8}}},
        }
        result = _compute_metric_timeline(index, "val:accuracy", "maximize")
        assert [p["x"] for p in result["points"]] == [1, 2, 3]
        assert [p["id"] for p in result["points"]] == ["0001", "0002", "0003"]

    def test_computes_branch_root_via_parent_chain(self):
        from abreka.webui.app import _compute_metric_timeline
        index = {
            "0001": {"id": "0001", "parents": [], "metrics": {"val": {"accuracy": 0.8}}},
            "0002": {"id": "0002", "parents": ["0001"], "metrics": {"val": {"accuracy": 0.82}}},
            "0003": {"id": "0003", "parents": ["0002"], "metrics": {"val": {"accuracy": 0.85}}},
            "0004": {"id": "0004", "parents": [], "metrics": {"val": {"accuracy": 0.79}}},
            "0005": {"id": "0005", "parents": ["0004"], "metrics": {"val": {"accuracy": 0.81}}},
        }
        result = _compute_metric_timeline(index, "val:accuracy", "maximize")
        roots = {p["id"]: p["branch_root"] for p in result["points"]}
        assert roots == {
            "0001": "0001",
            "0002": "0001",
            "0003": "0001",
            "0004": "0004",
            "0005": "0004",
        }

    def test_branch_root_handles_missing_parent(self):
        from abreka.webui.app import _compute_metric_timeline
        # parent "9999" doesn't exist in index — treat 0002 as its own root
        index = {
            "0002": {"id": "0002", "parents": ["9999"], "metrics": {"val": {"accuracy": 0.7}}},
        }
        result = _compute_metric_timeline(index, "val:accuracy", "maximize")
        assert result["points"][0]["branch_root"] == "0002"

    def test_running_best_maximize(self):
        from abreka.webui.app import _compute_metric_timeline
        index = {
            "0001": {"id": "0001", "parents": [], "metrics": {"val": {"accuracy": 0.80}}},
            "0002": {"id": "0002", "parents": ["0001"], "metrics": {"val": {"accuracy": 0.75}}},
            "0003": {"id": "0003", "parents": ["0002"], "metrics": {"val": {"accuracy": 0.90}}},
            "0004": {"id": "0004", "parents": ["0003"], "metrics": {"val": {"accuracy": 0.85}}},
        }
        result = _compute_metric_timeline(index, "val:accuracy", "maximize")
        assert [p["best_so_far"] for p in result["points"]] == [0.80, 0.80, 0.90, 0.90]

    def test_running_best_minimize(self):
        from abreka.webui.app import _compute_metric_timeline
        index = {
            "0001": {"id": "0001", "parents": [], "metrics": {"val": {"loss": 1.2}}},
            "0002": {"id": "0002", "parents": ["0001"], "metrics": {"val": {"loss": 1.5}}},
            "0003": {"id": "0003", "parents": ["0002"], "metrics": {"val": {"loss": 0.9}}},
            "0004": {"id": "0004", "parents": ["0003"], "metrics": {"val": {"loss": 1.0}}},
        }
        result = _compute_metric_timeline(index, "val:loss", "minimize")
        assert [p["best_so_far"] for p in result["points"]] == [1.2, 1.2, 0.9, 0.9]

    def test_empty_index_returns_empty_points(self):
        from abreka.webui.app import _compute_metric_timeline
        result = _compute_metric_timeline({}, "val:accuracy", "maximize")
        assert result["points"] == []
        assert result["primary_metric"] == "val:accuracy"
        assert result["direction"] == "maximize"

    def test_point_includes_id_and_hypothesis(self):
        from abreka.webui.app import _compute_metric_timeline
        index = {
            "0001": {
                "id": "0001", "hypothesis": "Baseline CNN",
                "parents": [], "metrics": {"val": {"accuracy": 0.8}},
            },
        }
        result = _compute_metric_timeline(index, "val:accuracy", "maximize")
        p = result["points"][0]
        assert p["id"] == "0001"
        assert p["hypothesis"] == "Baseline CNN"
        assert p["y"] == 0.8


@pytest.fixture
def timeline_client(abreka_project):
    """Client with a populated experiment index for metric timeline tests."""
    abreka_dir = abreka_project / "abreka"
    index = {
        "0001": {
            "id": "0001", "status": "completed", "hypothesis": "Baseline",
            "parents": [], "metrics": {"val": {"accuracy": 0.80}},
            "created_at": "2026-01-01T00:00:00",
        },
        "0002": {
            "id": "0002", "status": "completed", "hypothesis": "Child A",
            "parents": ["0001"], "metrics": {"val": {"accuracy": 0.85}},
            "created_at": "2026-01-02T00:00:00",
        },
        "0003": {
            "id": "0003", "status": "running", "hypothesis": "No metric yet",
            "parents": ["0002"], "metrics": {},
            "created_at": "2026-01-03T00:00:00",
        },
        "0004": {
            "id": "0004", "status": "completed", "hypothesis": "New root",
            "parents": [], "metrics": {"val": {"accuracy": 0.82}},
            "created_at": "2026-01-04T00:00:00",
        },
    }
    (abreka_dir / "index.json").write_text(json.dumps(index))
    app = create_app(abreka_dir)
    transport = ASGITransport(app=app)
    return AsyncClient(transport=transport, base_url="http://test")


@pytest.mark.anyio
class TestDashboardMetricTimeline:
    async def test_dashboard_renders_chart_card(self, timeline_client):
        async with timeline_client as client:
            resp = await client.get("/")
        assert resp.status_code == 200
        html = resp.text
        assert 'id="metric-timeline"' in html
        assert "Metric Timeline" in html


@pytest.mark.anyio
class TestUserGuidePage:
    async def test_guide_page_reads_package_docs(self, tmp_path, monkeypatch):
        from abreka.webui import app as webui_app

        package_root = tmp_path / "site-packages" / "abreka"
        (package_root / "docs").mkdir(parents=True)
        (package_root / "webui").mkdir(parents=True)
        (package_root / "docs" / "user_guide.md").write_text("# Package Guide\n")

        monkeypatch.setattr(webui_app, "__file__", str(package_root / "webui" / "app.py"))

        app = webui_app.create_app(tmp_path / "project" / "abreka")
        transport = ASGITransport(app=app)
        async with AsyncClient(transport=transport, base_url="http://test") as client:
            resp = await client.get("/guide")

        assert resp.status_code == 200
        html = resp.text
        assert "Package Guide" in html
        assert "src/abreka/docs/user_guide.md" in html
        assert "User guide not found" not in html

    async def test_dashboard_embeds_timeline_data(self, timeline_client):
        async with timeline_client as client:
            resp = await client.get("/")
        html = resp.text
        assert 'id="timeline-data"' in html
        # Data block should include the completed experiment ids with metrics
        start = html.index('id="timeline-data"')
        end = html.index("</script>", start)
        payload = html[start:end]
        assert '"0001"' in payload
        assert '"0002"' in payload
        assert '"0004"' in payload
        # Experiment without the metric should not appear in the payload
        assert '"0003"' not in payload

    async def test_dashboard_timeline_shows_primary_metric_label(self, timeline_client):
        async with timeline_client as client:
            resp = await client.get("/")
        html = resp.text
        assert "val:accuracy" in html

    async def test_dashboard_chart_hidden_when_no_metrics(self, abreka_project):
        abreka_dir = abreka_project / "abreka"
        (abreka_dir / "index.json").write_text("{}")
        app = create_app(abreka_dir)
        transport = ASGITransport(app=app)
        async with AsyncClient(transport=transport, base_url="http://test") as client:
            resp = await client.get("/")
        html = resp.text
        # With no points, the chart card should not render
        assert 'id="metric-timeline"' not in html

    async def test_dashboard_chart_has_outlier_toggle(self, timeline_client):
        async with timeline_client as client:
            resp = await client.get("/")
        html = resp.text
        assert 'id="timeline-fit-all"' in html
        assert "Include outliers in scale" in html

    async def test_dashboard_chart_has_caption(self, timeline_client):
        async with timeline_client as client:
            resp = await client.get("/")
        html = resp.text
        assert 'id="timeline-caption"' in html

    async def test_dashboard_shows_graceful_stop_notice_when_requested(self, abreka_project):
        from abreka.run_control import request_stop_after_current

        abreka_dir = abreka_project / "abreka"
        request_stop_after_current(abreka_dir)
        app = create_app(abreka_dir)
        transport = ASGITransport(app=app)
        async with AsyncClient(transport=transport, base_url="http://test") as client:
            resp = await client.get("/")
        html = resp.text
        assert "Graceful stop queued" in html
        assert "after the current experiment completes" in html
