"""Tests for the file/code viewer feature in the web UI."""

from __future__ import annotations

from pathlib import Path

import pytest
from httpx import ASGITransport, AsyncClient

from abreka.webui.files import (
    FileContent,
    FileEntry,
    highlight_code,
    is_binary_file,
    is_image_file,
    list_experiment_files,
    render_file_content,
    safe_resolve,
)

PNG_BYTES = (
    b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"
    b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4"
    b"\x89\x00\x00\x00\rIDATx\x9cc\xf8\xcf\xc0\xf0\x1f\x00\x05\x00"
    b"\x01\xff\xa7\x8d\xb2\xa1\x00\x00\x00\x00IEND\xaeB`\x82"
)


# -- Unit tests: is_binary_file ------------------------------------------------


class TestIsBinaryFile:
    def test_known_binary_extensions(self, tmp_path):
        for ext in (".png", ".jpg", ".pkl", ".pt", ".npz", ".bin", ".zip", ".gz"):
            p = tmp_path / f"file{ext}"
            p.write_text("not actually binary")
            assert is_binary_file(p) is True

    def test_text_extensions(self, tmp_path):
        for ext in (".py", ".toml", ".md", ".txt", ".json"):
            p = tmp_path / f"file{ext}"
            p.write_text("some text content")
            assert is_binary_file(p) is False

    def test_null_byte_detection(self, tmp_path):
        p = tmp_path / "mystery"
        p.write_bytes(b"hello\x00world")
        assert is_binary_file(p) is True

    def test_empty_file_is_not_binary(self, tmp_path):
        p = tmp_path / "empty"
        p.write_bytes(b"")
        assert is_binary_file(p) is False

    def test_text_file_no_extension(self, tmp_path):
        p = tmp_path / "Makefile"
        p.write_text("all:\n\techo hello\n")
        assert is_binary_file(p) is False

    def test_does_not_read_entire_file(self, tmp_path, monkeypatch):
        """Sniffing for binary content must not slurp the whole file.

        Regression: for a 3GB artifact JSON, `Path.read_bytes()[:8192]` was
        reading the entire file into memory before slicing, making the
        experiment detail page load extremely slow.
        """
        p = tmp_path / "huge.json"
        p.write_text("{" + "a" * 20_000 + "}")

        def _no_read_bytes(self):  # pragma: no cover - defensive
            raise AssertionError(
                "is_binary_file should stream, not call Path.read_bytes()"
            )

        monkeypatch.setattr(Path, "read_bytes", _no_read_bytes)
        assert is_binary_file(p) is False


# -- Unit tests: safe_resolve --------------------------------------------------


class TestSafeResolve:
    def test_normal_path(self, tmp_path):
        f = tmp_path / "code" / "model.py"
        f.parent.mkdir()
        f.write_text("pass")
        result = safe_resolve(tmp_path, "code/model.py")
        assert result == f.resolve()

    def test_traversal_blocked(self, tmp_path):
        assert safe_resolve(tmp_path, "../../etc/passwd") is None

    def test_dot_dot_in_middle(self, tmp_path):
        assert safe_resolve(tmp_path, "code/../../../etc/passwd") is None

    def test_null_byte_rejected(self, tmp_path):
        assert safe_resolve(tmp_path, "file\x00.py") is None

    def test_root_path(self, tmp_path):
        """Empty string resolves to the experiment directory itself."""
        result = safe_resolve(tmp_path, "")
        assert result == tmp_path.resolve()


# -- Unit tests: list_experiment_files -----------------------------------------


class TestListExperimentFiles:
    def test_basic_listing(self, tmp_path):
        (tmp_path / "run.py").write_text("print('hi')")
        (tmp_path / "experiment.toml").write_text("[id]\nstatus='proposed'")
        entries = list_experiment_files(tmp_path)
        names = {e.name for e in entries if not e.is_dir}
        assert "run.py" in names
        assert "experiment.toml" in names

    def test_nested_files_have_depth(self, tmp_path):
        code_dir = tmp_path / "code"
        code_dir.mkdir()
        (code_dir / "model.py").write_text("class Model: pass")
        entries = list_experiment_files(tmp_path)
        dir_entry = next(e for e in entries if e.name == "code" and e.is_dir)
        file_entry = next(e for e in entries if e.name == "model.py")
        assert dir_entry.depth == 0
        assert file_entry.depth == 1

    def test_marks_binary_files(self, tmp_path):
        (tmp_path / "data.pkl").write_bytes(b"\x80\x04\x95\x00\x00")
        entries = list_experiment_files(tmp_path)
        pkl_entry = next(e for e in entries if e.name == "data.pkl")
        assert pkl_entry.is_binary is True

    def test_directories_listed_first(self, tmp_path):
        (tmp_path / "z_file.py").write_text("pass")
        (tmp_path / "a_dir").mkdir()
        (tmp_path / "a_dir" / "inner.py").write_text("pass")
        entries = list_experiment_files(tmp_path)
        # First top-level entry should be the directory
        top_level = [e for e in entries if e.depth == 0]
        assert top_level[0].is_dir is True

    def test_empty_directory(self, tmp_path):
        entries = list_experiment_files(tmp_path)
        assert entries == []

    def test_nonexistent_directory(self, tmp_path):
        entries = list_experiment_files(tmp_path / "nonexistent")
        assert entries == []

    def test_excludes_pycache(self, tmp_path):
        (tmp_path / "run.py").write_text("print('hi')")
        pycache = tmp_path / "__pycache__"
        pycache.mkdir()
        (pycache / "run.cpython-311.pyc").write_bytes(b"\x00\x00")
        pkg = tmp_path / "pkg"
        pkg.mkdir()
        (pkg / "mod.py").write_text("x = 1")
        nested_pycache = pkg / "__pycache__"
        nested_pycache.mkdir()
        (nested_pycache / "mod.cpython-311.pyc").write_bytes(b"\x00\x00")

        entries = list_experiment_files(tmp_path)

        assert not any(e.name == "__pycache__" for e in entries)
        assert not any("__pycache__" in e.relative_path for e in entries)
        assert any(e.name == "run.py" for e in entries)
        assert any(e.name == "pkg" and e.is_dir for e in entries)
        assert any(e.name == "mod.py" for e in entries)


# -- Unit tests: highlight_code ------------------------------------------------


class TestHighlightCode:
    def test_python_highlighting(self):
        html = highlight_code("def foo():\n    return 42\n", "test.py")
        assert "highlight" in html
        # Pygments should produce some spans
        assert "<span" in html

    def test_unknown_extension_fallback(self):
        html = highlight_code("some random text", "file.xyz123")
        # Should still produce valid HTML without crashing
        assert "highlight" in html

    def test_toml_highlighting(self):
        html = highlight_code('[section]\nkey = "value"', "config.toml")
        assert "<span" in html


# -- Unit tests: render_file_content ------------------------------------------


class TestRenderFileContent:
    def test_python_file(self, tmp_path):
        p = tmp_path / "example.py"
        p.write_text("x = 1\n")
        result = render_file_content(p)
        assert isinstance(result, FileContent)
        assert result.is_binary is False
        assert result.truncated is False
        assert "highlight" in result.highlighted_html
        assert result.content == "x = 1\n"

    def test_truncation(self, tmp_path):
        p = tmp_path / "big.py"
        content = "x = 1\n" * 50000  # well over 100KB
        p.write_text(content)
        result = render_file_content(p, max_bytes=1024)
        assert result.truncated is True
        assert result.original_size == len(content.encode())
        assert len(result.content) <= 1024

    def test_binary_refused(self, tmp_path):
        p = tmp_path / "data.bin"
        p.write_bytes(b"\x00\x01\x02\x03")
        result = render_file_content(p)
        assert result.is_binary is True
        assert result.content == ""
        assert result.highlighted_html == ""

    def test_empty_file(self, tmp_path):
        p = tmp_path / "empty.py"
        p.write_text("")
        result = render_file_content(p)
        assert result.is_binary is False
        assert result.content == ""

    def test_image_file(self, tmp_path):
        p = tmp_path / "plot.png"
        p.write_bytes(PNG_BYTES)
        result = render_file_content(p)
        assert result.is_image is True
        assert result.is_binary is True
        assert result.language == "image"
        assert result.original_size == len(PNG_BYTES)

    def test_is_image_file_extensions(self, tmp_path):
        for ext in (".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg"):
            p = tmp_path / f"img{ext}"
            p.write_bytes(b"\x00")
            assert is_image_file(p) is True
        not_img = tmp_path / "foo.txt"
        not_img.write_text("hi")
        assert is_image_file(not_img) is False

    def test_list_experiment_files_marks_images(self, tmp_path):
        (tmp_path / "plot.png").write_bytes(PNG_BYTES)
        (tmp_path / "data.pkl").write_bytes(b"\x00\x01")
        (tmp_path / "run.py").write_text("x = 1")
        entries = {e.name: e for e in list_experiment_files(tmp_path)}
        assert entries["plot.png"].is_image is True
        assert entries["plot.png"].is_binary is True
        assert entries["data.pkl"].is_image is False
        assert entries["run.py"].is_image is False


# -- Integration tests: HTTP routes --------------------------------------------


@pytest.fixture
def files_client(abreka_project):
    """Client with an experiment containing files for the file viewer."""
    abreka_dir = abreka_project / "abreka"
    from abreka.models import Experiment
    from abreka.persistence import save_experiment, update_index_entry

    exp = Experiment(id="0001", hypothesis="Test experiment")
    exp_dir = abreka_dir / "experiments" / "0001"
    exp_dir.mkdir(parents=True, exist_ok=True)
    save_experiment(exp, abreka_dir)
    update_index_entry(abreka_dir, exp)

    # Create experiment files
    (exp_dir / "run.py").write_text("import torch\n\ndef main():\n    print('training')\n")
    (exp_dir / "findings.md").write_text("# Findings\nGood results.\n")

    code_dir = exp_dir / "code"
    code_dir.mkdir()
    (code_dir / "__init__.py").write_text("")
    (code_dir / "model.py").write_text("class CNN:\n    pass\n")

    artifacts_dir = exp_dir / "artifacts"
    artifacts_dir.mkdir()
    (artifacts_dir / "run_output.txt").write_text("epoch 1: loss=0.5\n")

    # Binary file
    checkpoints_dir = artifacts_dir / "checkpoints"
    checkpoints_dir.mkdir()
    (checkpoints_dir / "best.pt").write_bytes(b"\x80\x02\x00\x01" * 100)

    # Image files
    (artifacts_dir / "plot.png").write_bytes(PNG_BYTES)
    (artifacts_dir / "diagram.svg").write_text(
        '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10">'
        '<rect width="10" height="10" fill="red"/></svg>'
    )

    from abreka.webui.app import create_app

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


@pytest.mark.anyio
class TestFilesListRoute:
    async def test_files_list_renders(self, files_client):
        async with files_client as client:
            resp = await client.get("/experiments/0001/files")
        assert resp.status_code == 200
        html = resp.text
        assert "run.py" in html
        assert "experiment.toml" in html

    async def test_files_list_shows_directories(self, files_client):
        async with files_client as client:
            resp = await client.get("/experiments/0001/files")
        html = resp.text
        assert "code" in html
        assert "artifacts" in html

    async def test_files_list_shows_binary_label(self, files_client):
        async with files_client as client:
            resp = await client.get("/experiments/0001/files")
        html = resp.text
        assert "binary" in html.lower()


@pytest.mark.anyio
class TestFileViewRoute:
    async def test_view_python_file(self, files_client):
        async with files_client as client:
            resp = await client.get("/experiments/0001/files/run.py")
        assert resp.status_code == 200
        html = resp.text
        assert "highlight" in html
        assert "torch" in html

    async def test_view_nested_file(self, files_client):
        async with files_client as client:
            resp = await client.get("/experiments/0001/files/code/model.py")
        assert resp.status_code == 200
        assert "CNN" in resp.text

    async def test_view_toml_file(self, files_client):
        async with files_client as client:
            resp = await client.get("/experiments/0001/files/experiment.toml")
        assert resp.status_code == 200

    async def test_binary_file_shows_message(self, files_client):
        async with files_client as client:
            resp = await client.get("/experiments/0001/files/artifacts/checkpoints/best.pt")
        assert resp.status_code == 200
        html = resp.text
        assert "binary" in html.lower()
        # Should NOT contain raw binary content
        assert "\x80" not in html

    async def test_image_file_renders_img_tag(self, files_client):
        async with files_client as client:
            resp = await client.get("/experiments/0001/files/artifacts/plot.png")
        assert resp.status_code == 200
        html = resp.text
        assert "<img" in html
        assert "/experiments/0001/raw/artifacts/plot.png" in html
        # Should not show the binary notice
        assert "cannot be displayed" not in html

    async def test_svg_file_renders_img_tag(self, files_client):
        async with files_client as client:
            resp = await client.get("/experiments/0001/files/artifacts/diagram.svg")
        assert resp.status_code == 200
        html = resp.text
        assert "<img" in html
        assert "/experiments/0001/raw/artifacts/diagram.svg" in html
        assert "cannot be displayed" not in html

    async def test_raw_endpoint_serves_image_bytes(self, files_client):
        async with files_client as client:
            resp = await client.get("/experiments/0001/raw/artifacts/plot.png")
        assert resp.status_code == 200
        assert resp.content == PNG_BYTES
        assert resp.headers["content-type"].startswith("image/")

    async def test_raw_endpoint_serves_svg(self, files_client):
        async with files_client as client:
            resp = await client.get("/experiments/0001/raw/artifacts/diagram.svg")
        assert resp.status_code == 200
        assert resp.headers["content-type"].startswith("image/svg+xml")

    async def test_raw_endpoint_path_traversal_blocked(self, files_client):
        async with files_client as client:
            resp = await client.get(
                "/experiments/0001/raw/%2e%2e/%2e%2e/config.toml"
            )
        assert resp.status_code in (400, 404)

    async def test_raw_endpoint_nonexistent_404(self, files_client):
        async with files_client as client:
            resp = await client.get("/experiments/0001/raw/no_such.png")
        assert resp.status_code == 404

    async def test_path_traversal_blocked(self, files_client):
        async with files_client as client:
            # Use %2e%2e to bypass HTTP path normalization
            resp = await client.get(
                "/experiments/0001/files/code/%2e%2e/%2e%2e/%2e%2e/config.toml"
            )
        assert resp.status_code in (400, 404)

    async def test_nonexistent_file_404(self, files_client):
        async with files_client as client:
            resp = await client.get("/experiments/0001/files/no_such_file.py")
        assert resp.status_code == 404

    async def test_large_file_truncated(self, files_client, abreka_project):
        # Create a large file
        exp_dir = abreka_project / "abreka" / "experiments" / "0001"
        (exp_dir / "big.txt").write_text("x" * 200_000)
        async with files_client as client:
            resp = await client.get("/experiments/0001/files/big.txt")
        assert resp.status_code == 200
        assert "truncated" in resp.text.lower()


@pytest.mark.anyio
class TestExperimentDetailFilesTab:
    async def test_detail_page_has_files_tab(self, files_client):
        async with files_client as client:
            resp = await client.get("/experiments/0001")
        assert resp.status_code == 200
        html = resp.text
        assert 'data-tab="files"' in html
        assert 'tab-files' in html

    async def test_detail_page_has_overview_tab(self, files_client):
        async with files_client as client:
            resp = await client.get("/experiments/0001")
        assert resp.status_code == 200
        html = resp.text
        assert 'data-tab="overview"' in html

    async def test_detail_page_file_tree_inline(self, files_client):
        """File entries should be rendered inline in the experiment page."""
        async with files_client as client:
            resp = await client.get("/experiments/0001")
        html = resp.text
        assert "file-sidebar" in html
        assert "run.py" in html

    async def test_detail_page_has_file_splitter(self, files_client):
        async with files_client as client:
            resp = await client.get("/experiments/0001")
        html = resp.text
        assert "file-splitter" in html
        assert "Drag to resize" in html

    async def test_detail_page_persists_file_splitter_width(self, files_client):
        async with files_client as client:
            resp = await client.get("/experiments/0001")
        html = resp.text
        assert "localStorage" in html
        assert "file-browser-width" in html

    async def test_run_output_full_width(self, files_client):
        """Run output should be full-width, outside the grid."""
        async with files_client as client:
            resp = await client.get("/experiments/0001")
        html = resp.text
        assert "run-output-card" in html


@pytest.mark.anyio
class TestFileContentAPI:
    async def test_api_returns_file_content(self, files_client):
        async with files_client as client:
            resp = await client.get("/api/experiments/0001/file/run.py")
        assert resp.status_code == 200
        data = resp.json()
        assert data["language"] == "Python"
        assert data["is_binary"] is False
        assert "highlight" in data["highlighted_html"]

    async def test_api_binary_file(self, files_client):
        async with files_client as client:
            resp = await client.get(
                "/api/experiments/0001/file/artifacts/checkpoints/best.pt"
            )
        assert resp.status_code == 200
        data = resp.json()
        assert data["is_binary"] is True

    async def test_api_nonexistent_file(self, files_client):
        async with files_client as client:
            resp = await client.get("/api/experiments/0001/file/nope.py")
        assert resp.status_code == 404

    async def test_api_path_traversal_blocked(self, files_client):
        async with files_client as client:
            resp = await client.get(
                "/api/experiments/0001/file/code/%2e%2e/%2e%2e/config.toml"
            )
        assert resp.status_code in (400, 404)

    async def test_api_image_file_has_raw_url(self, files_client):
        async with files_client as client:
            resp = await client.get("/api/experiments/0001/file/artifacts/plot.png")
        assert resp.status_code == 200
        data = resp.json()
        assert data["is_image"] is True
        assert data["is_binary"] is True
        assert data["raw_url"] == "/experiments/0001/raw/artifacts/plot.png"

    async def test_api_svg_file_has_raw_url(self, files_client):
        async with files_client as client:
            resp = await client.get("/api/experiments/0001/file/artifacts/diagram.svg")
        assert resp.status_code == 200
        data = resp.json()
        assert data["is_image"] is True
        assert data["is_binary"] is True
        assert data["raw_url"] == "/experiments/0001/raw/artifacts/diagram.svg"
