"""Tests for ifcurl.bcf and the /bcf service endpoint."""

from __future__ import annotations

import io
import zipfile
from unittest.mock import patch

import pytest
from fastapi.testclient import TestClient

from ifcurl.bcf import build_bcf
from ifcurl.service import _t2_cache, _t3_cache, app, configure_allowed_hosts

client = TestClient(app)

FAKE_HEXSHA = "b" * 40
MUTABLE_URL = "ifc://example.com/org/repo@heads/main?path=model.ifc"
CAMERA_URL = (
    "ifc://example.com/org/repo@heads/main"
    "?path=model.ifc&camera=1,2,3,0,0,-1,0,1,0&fov=60"
)
CLIP_URL = (
    "ifc://example.com/org/repo@heads/main"
    "?path=model.ifc&camera=0,0,5,0,0,-1,0,1,0&fov=60"
    "&clip=0,0,2,0,0,-1"
)


@pytest.fixture(autouse=True)
def reset_service(tmp_path, monkeypatch):
    monkeypatch.setattr("ifcurl.service.user_cache_dir", lambda *a, **kw: str(tmp_path))
    _t2_cache.clear()
    _t3_cache.clear()
    configure_allowed_hosts(None)
    yield
    _t2_cache.clear()
    _t3_cache.clear()
    configure_allowed_hosts(None)


def _zip_names(data: bytes) -> set[str]:
    with zipfile.ZipFile(io.BytesIO(data)) as zf:
        return set(zf.namelist())


def _zip_read(data: bytes, name: str) -> str:
    with zipfile.ZipFile(io.BytesIO(data)) as zf:
        return zf.read(name).decode()


# ---------------------------------------------------------------------------
# build_bcf unit tests
# ---------------------------------------------------------------------------


class TestBuildBcf:
    def test_returns_valid_zip(self):
        data = build_bcf()
        assert zipfile.is_zipfile(io.BytesIO(data))

    def test_contains_version_file(self):
        data = build_bcf()
        assert "bcf.version" in _zip_names(data)

    def test_contains_markup(self):
        data = build_bcf(title="Test topic")
        names = _zip_names(data)
        markup_files = [n for n in names if n.endswith("markup.bcf")]
        assert len(markup_files) == 1
        content = _zip_read(data, markup_files[0])
        assert "Test topic" in content

    def test_no_viewpoint_without_camera(self):
        data = build_bcf()
        names = _zip_names(data)
        assert not any(n.endswith("viewpoint.bcfv") for n in names)

    def test_viewpoint_present_with_camera(self):
        cam = (1.0, 2.0, 3.0, 0.0, 0.0, -1.0, 0.0, 1.0, 0.0)
        data = build_bcf(camera=cam, fov=60.0)
        names = _zip_names(data)
        assert any(n.endswith("viewpoint.bcfv") for n in names)

    def test_perspective_camera_in_viewpoint(self):
        cam = (1.0, 2.0, 3.0, 0.0, 0.0, -1.0, 0.0, 1.0, 0.0)
        data = build_bcf(camera=cam, fov=60.0)
        vp_file = next(n for n in _zip_names(data) if n.endswith("viewpoint.bcfv"))
        content = _zip_read(data, vp_file)
        assert "PerspectiveCamera" in content
        assert "<FieldOfView>60.0000</FieldOfView>" in content

    def test_orthographic_camera_in_viewpoint(self):
        cam = (0.0, 0.0, 10.0, 0.0, 0.0, -1.0, 0.0, 1.0, 0.0)
        data = build_bcf(camera=cam, scale=50.0)
        vp_file = next(n for n in _zip_names(data) if n.endswith("viewpoint.bcfv"))
        content = _zip_read(data, vp_file)
        assert "OrthogonalCamera" in content
        assert "ViewToWorldScale" in content

    def test_clipping_planes_in_viewpoint(self):
        cam = (0.0, 0.0, 5.0, 0.0, 0.0, -1.0, 0.0, 1.0, 0.0)
        data = build_bcf(camera=cam, fov=60.0, clips=[(0.0, 0.0, 2.0, 0.0, 0.0, -1.0)])
        vp_file = next(n for n in _zip_names(data) if n.endswith("viewpoint.bcfv"))
        content = _zip_read(data, vp_file)
        assert "ClippingPlane" in content

    def test_comment_in_markup(self):
        cam = (0.0, 0.0, 5.0, 0.0, 0.0, -1.0, 0.0, 1.0, 0.0)
        data = build_bcf(camera=cam, fov=60.0, comment="Look at this crack")
        markup = next(n for n in _zip_names(data) if n.endswith("markup.bcf"))
        content = _zip_read(data, markup)
        assert "Look at this crack" in content

    def test_guids_in_viewpoint_selection(self):
        cam = (0.0, 0.0, 5.0, 0.0, 0.0, -1.0, 0.0, 1.0, 0.0)
        data = build_bcf(camera=cam, fov=60.0, guids=["abc123", "def456"])
        vp_file = next(n for n in _zip_names(data) if n.endswith("viewpoint.bcfv"))
        content = _zip_read(data, vp_file)
        assert 'IfcGuid="abc123"' in content
        assert 'IfcGuid="def456"' in content

    def test_isolate_visibility_uses_exceptions(self):
        cam = (0.0, 0.0, 5.0, 0.0, 0.0, -1.0, 0.0, 1.0, 0.0)
        data = build_bcf(camera=cam, fov=60.0, guids=["abc123"], visibility="isolate")
        vp_file = next(n for n in _zip_names(data) if n.endswith("viewpoint.bcfv"))
        content = _zip_read(data, vp_file)
        assert 'DefaultVisibility="false"' in content
        assert "Exceptions" in content

    def test_description_in_markup(self):
        data = build_bcf(description="ifc://example.com/org/repo@heads/main?path=m.ifc")
        markup = next(n for n in _zip_names(data) if n.endswith("markup.bcf"))
        content = _zip_read(data, markup)
        assert "<Description>ifc://example.com" in content

    def test_no_description_element_when_empty(self):
        data = build_bcf()
        markup = next(n for n in _zip_names(data) if n.endswith("markup.bcf"))
        content = _zip_read(data, markup)
        assert "<Description>" not in content


# ---------------------------------------------------------------------------
# /bcf service endpoint tests
# ---------------------------------------------------------------------------


class TestBcfEndpoint:
    def test_returns_zip_bytes(self):
        r = client.post("/bcf", json={"url": CAMERA_URL})
        assert r.status_code == 200
        assert zipfile.is_zipfile(io.BytesIO(r.content))

    def test_content_disposition_header(self):
        r = client.post("/bcf", json={"url": CAMERA_URL})
        assert "view.bcf" in r.headers["content-disposition"]

    def test_contains_viewpoint_when_camera_in_url(self):
        r = client.post("/bcf", json={"url": CAMERA_URL})
        names = _zip_names(r.content)
        assert any(n.endswith("viewpoint.bcfv") for n in names)

    def test_no_viewpoint_when_no_camera(self):
        r = client.post("/bcf", json={"url": MUTABLE_URL})
        assert r.status_code == 200
        names = _zip_names(r.content)
        assert not any(n.endswith("viewpoint.bcfv") for n in names)

    def test_title_and_comment_in_markup(self):
        r = client.post(
            "/bcf", json={"url": CAMERA_URL, "title": "My issue", "comment": "Fix this"}
        )
        markup = next(n for n in _zip_names(r.content) if n.endswith("markup.bcf"))
        content = _zip_read(r.content, markup)
        assert "My issue" in content
        assert "Fix this" in content

    def test_source_url_in_description(self):
        r = client.post("/bcf", json={"url": CAMERA_URL})
        markup = next(n for n in _zip_names(r.content) if n.endswith("markup.bcf"))
        content = _zip_read(r.content, markup)
        # & is XML-escaped to &amp; in the description element
        assert CAMERA_URL.replace("&", "&amp;") in content

    def test_clip_plane_in_viewpoint(self):
        r = client.post("/bcf", json={"url": CLIP_URL})
        vp = next(n for n in _zip_names(r.content) if n.endswith("viewpoint.bcfv"))
        assert "ClippingPlane" in _zip_read(r.content, vp)

    def test_ssrf_local_transport_rejected(self):
        r = client.post("/bcf", json={"url": "ifc:///some/path@HEAD?path=m.ifc"})
        assert r.status_code == 403

    def test_ssrf_private_ip_rejected(self):
        r = client.post(
            "/bcf", json={"url": "ifc://192.168.1.1/org/repo@HEAD?path=m.ifc"}
        )
        assert r.status_code == 403

    def test_invalid_url_returns_400(self):
        r = client.post("/bcf", json={"url": "https://example.com/model.ifc"})
        assert r.status_code == 400

    def test_selector_resolves_guids(self, model_with_geometry):
        ifc_bytes = model_with_geometry.to_string().encode()

        def mock_fetch(ifc_url, token=None):
            return FAKE_HEXSHA, ifc_bytes, False

        selector_url = MUTABLE_URL.replace(
            "?path=model.ifc", "?path=model.ifc&selector=IfcWall"
        )
        with patch("ifcurl.service.fetch_ifc", mock_fetch):
            r = client.post("/bcf", json={"url": selector_url})
        assert r.status_code == 200
        vp_files = [n for n in _zip_names(r.content) if n.endswith("viewpoint.bcfv")]
        # No camera → no viewpoint even with selector
        assert len(vp_files) == 0
