"""Tests for local HTTP server."""

from __future__ import annotations

import json
import socket
import threading
import time
import urllib.error
import urllib.request
from http.server import HTTPServer
from unittest.mock import MagicMock

from orxaq.enterprise.auth import APIKeyStore, AuthMiddleware, RateLimiter, TokenProvider
from orxaq.experiences.api import OrxaqAPI
from orxaq.experiences.editor import DAGEditorAPI
from orxaq.experiences.server import OrxaqHandler, _make_handler
from orxaq.experiences.websocket import WebSocketHub


def _find_free_port() -> int:
    """Find a free port on localhost."""
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.bind(("localhost", 0))
        return s.getsockname()[1]


class TestServerResponses:
    @classmethod
    def setup_class(cls):
        cls.port = _find_free_port()
        api = OrxaqAPI()
        editor = DAGEditorAPI(api.engine)

        def handler(*a, **kw):
            return _make_handler(api, editor, *a, **kw)

        cls.server = HTTPServer(("localhost", cls.port), handler)
        cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
        cls.thread.start()
        time.sleep(0.3)
        cls.base = f"http://localhost:{cls.port}"

    @classmethod
    def teardown_class(cls):
        cls.server.shutdown()

    def test_root_returns_html(self):
        req = urllib.request.Request(self.base + "/")
        with urllib.request.urlopen(req) as resp:
            assert resp.status == 200
            ct = resp.getheader("Content-Type")
            assert "text/html" in ct

    def test_api_status_returns_json(self):
        req = urllib.request.Request(self.base + "/api/status")
        with urllib.request.urlopen(req) as resp:
            assert resp.status == 200
            ct = resp.getheader("Content-Type")
            assert "application/json" in ct
            data = json.loads(resp.read())
            assert "dag" in data

    def test_api_ecl_returns_json(self):
        req = urllib.request.Request(self.base + "/api/ecl")
        with urllib.request.urlopen(req) as resp:
            assert resp.status == 200
            data = json.loads(resp.read())
            assert data["total_ecl"] > 0

    def test_api_unknown_returns_404(self):
        try:
            req = urllib.request.Request(self.base + "/api/unknown")
            urllib.request.urlopen(req)
            assert False, "Should have raised"
        except urllib.error.HTTPError as e:
            assert e.code == 404

    def test_get_not_found(self):
        try:
            req = urllib.request.Request(self.base + "/nonexistent")
            urllib.request.urlopen(req)
            assert False, "Should have raised"
        except urllib.error.HTTPError as e:
            assert e.code == 404

    def test_options_cors(self):
        req = urllib.request.Request(self.base + "/api/status", method="OPTIONS")
        with urllib.request.urlopen(req) as resp:
            assert resp.status == 204
            assert resp.getheader("Access-Control-Allow-Origin") == "*"

    def test_dag_nodes_get(self):
        req = urllib.request.Request(self.base + "/api/dag/nodes")
        with urllib.request.urlopen(req) as resp:
            assert resp.status == 200
            data = json.loads(resp.read())
            assert isinstance(data, dict)

    def test_dag_export_json(self):
        req = urllib.request.Request(self.base + "/api/dag/export/json")
        with urllib.request.urlopen(req) as resp:
            assert resp.status == 200
            data = json.loads(resp.read())
            assert "entities" in data or "nodes" in data or isinstance(data, dict)

    def test_dag_export_dot(self):
        req = urllib.request.Request(self.base + "/api/dag/export/dot")
        with urllib.request.urlopen(req) as resp:
            assert resp.status == 200
            text = resp.read().decode()
            assert "digraph" in text

    def test_dag_export_mermaid(self):
        req = urllib.request.Request(self.base + "/api/dag/export/mermaid")
        with urllib.request.urlopen(req) as resp:
            assert resp.status == 200
            text = resp.read().decode()
            assert "graph" in text or "flowchart" in text or "-->" in text

    def test_dag_export_unknown_format(self):
        try:
            req = urllib.request.Request(self.base + "/api/dag/export/xml")
            urllib.request.urlopen(req)
            assert False, "Should have raised"
        except urllib.error.HTTPError as e:
            assert e.code == 400

    def test_post_propose_edges(self):
        body = json.dumps({}).encode()
        req = urllib.request.Request(
            self.base + "/api/dag/propose-edges",
            data=body,
            headers={"Content-Type": "application/json"},
        )
        with urllib.request.urlopen(req) as resp:
            assert resp.status == 200
            data = json.loads(resp.read())
            assert "proposals" in data

    def test_post_propose_edges_with_node(self):
        body = json.dumps({"node": "unemployment_rate"}).encode()
        req = urllib.request.Request(
            self.base + "/api/dag/propose-edges",
            data=body,
            headers={"Content-Type": "application/json"},
        )
        with urllib.request.urlopen(req) as resp:
            assert resp.status == 200
            data = json.loads(resp.read())
            assert "proposals" in data

    def test_post_score(self):
        req = urllib.request.Request(
            self.base + "/api/dag/score",
            data=b"{}",
            headers={"Content-Type": "application/json"},
        )
        with urllib.request.urlopen(req) as resp:
            assert resp.status == 200
            data = json.loads(resp.read())
            assert "overall" in data
            assert "components" in data

    def test_post_dag_node(self):
        body = json.dumps(
            {
                "entity_id": "test_var_x",
                "name": "Test Variable X",
                "causal_role": "exogenous",
                "variable_type": "static",
            }
        ).encode()
        req = urllib.request.Request(
            self.base + "/api/dag/nodes",
            data=body,
            headers={"Content-Type": "application/json"},
        )
        try:
            with urllib.request.urlopen(req) as resp:
                assert resp.status in (200, 201)
        except urllib.error.HTTPError as e:
            # 400 is acceptable if schema doesn't match
            assert e.code in (400, 409)

    def test_dag_edges_get(self):
        req = urllib.request.Request(self.base + "/api/dag/edges")
        with urllib.request.urlopen(req) as resp:
            assert resp.status == 200
            data = json.loads(resp.read())
            assert isinstance(data, dict)

    def test_delete_dag_node(self):
        body = json.dumps({"entity_id": "nonexistent_var"}).encode()
        req = urllib.request.Request(
            self.base + "/api/dag/nodes",
            data=body,
            headers={"Content-Type": "application/json"},
            method="DELETE",
        )
        try:
            with urllib.request.urlopen(req) as resp:
                assert resp.status in (200, 404)
        except urllib.error.HTTPError:
            pass  # 404 is acceptable for nonexistent entity

    def test_post_not_found(self):
        try:
            req = urllib.request.Request(
                self.base + "/nonexistent",
                data=b"{}",
                headers={"Content-Type": "application/json"},
            )
            urllib.request.urlopen(req)
            assert False, "Should have raised"
        except urllib.error.HTTPError as e:
            assert e.code == 404

    def test_delete_not_found(self):
        try:
            req = urllib.request.Request(
                self.base + "/nonexistent",
                data=b"{}",
                headers={"Content-Type": "application/json"},
                method="DELETE",
            )
            urllib.request.urlopen(req)
            assert False, "Should have raised"
        except urllib.error.HTTPError as e:
            assert e.code == 404

    def test_editor_page(self):
        try:
            req = urllib.request.Request(self.base + "/editor")
            with urllib.request.urlopen(req) as resp:
                assert resp.status == 200
        except urllib.error.HTTPError:
            # editor.html might not exist in static dir
            pass

    def test_cors_headers_on_json(self):
        req = urllib.request.Request(self.base + "/api/status")
        with urllib.request.urlopen(req) as resp:
            assert resp.getheader("Access-Control-Allow-Origin") == "*"

    def test_index_html_alias(self):
        req = urllib.request.Request(self.base + "/index.html")
        with urllib.request.urlopen(req) as resp:
            assert resp.status == 200


class TestServerWebSocket:
    """Tests for WebSocket integration in the server."""

    @classmethod
    def setup_class(cls):
        cls.port = _find_free_port()
        api = OrxaqAPI()
        editor = DAGEditorAPI(api.engine)
        cls.ws_hub = WebSocketHub()
        OrxaqHandler.ws_hub = cls.ws_hub

        def handler(*a, **kw):
            return _make_handler(api, editor, *a, **kw)

        cls.server = HTTPServer(("localhost", cls.port), handler)
        cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
        cls.thread.start()
        time.sleep(0.3)
        cls.base = f"http://localhost:{cls.port}"

    @classmethod
    def teardown_class(cls):
        cls.ws_hub.shutdown()
        cls.server.shutdown()
        OrxaqHandler.ws_hub = None

    def test_ws_status_endpoint(self):
        req = urllib.request.Request(self.base + "/api/ws/status")
        with urllib.request.urlopen(req) as resp:
            assert resp.status == 200
            data = json.loads(resp.read())
            assert data["enabled"] is True
            assert data["clients"] == 0

    def test_ws_upgrade_without_headers_returns_404(self):
        """Normal GET to /ws/events without upgrade headers returns 404."""
        try:
            req = urllib.request.Request(self.base + "/ws/events")
            urllib.request.urlopen(req)
            assert False, "Should have raised"
        except urllib.error.HTTPError as e:
            assert e.code == 404

    def test_ws_handshake(self):
        """WebSocket handshake returns 101 Switching Protocols."""
        import base64

        key = base64.b64encode(b"test-key-12345678").decode()
        sock = socket.create_connection(("localhost", self.port), timeout=5)
        try:
            request = (
                f"GET /ws/events HTTP/1.1\r\n"
                f"Host: localhost:{self.port}\r\n"
                f"Upgrade: websocket\r\n"
                f"Connection: Upgrade\r\n"
                f"Sec-WebSocket-Key: {key}\r\n"
                f"Sec-WebSocket-Version: 13\r\n"
                f"\r\n"
            )
            sock.sendall(request.encode())
            response = b""
            while b"\r\n\r\n" not in response:
                chunk = sock.recv(4096)
                if not chunk:
                    break
                response += chunk
            # Only decode the HTTP headers (before the \r\n\r\n separator)
            header_end = response.index(b"\r\n\r\n") + 4
            response_str = response[:header_end].decode()
            assert "101" in response_str
            assert "Switching Protocols" in response_str
            assert "Sec-WebSocket-Accept" in response_str
        finally:
            sock.close()
        time.sleep(0.2)  # Let server clean up


class TestServerAuth:
    """Tests for auth middleware integration in the server."""

    @classmethod
    def setup_class(cls):
        cls.port = _find_free_port()
        api = OrxaqAPI()
        editor = DAGEditorAPI(api.engine)

        cls.key_store = APIKeyStore()
        cls.token_provider = TokenProvider()
        cls.rate_limiter = RateLimiter(capacity=100, refill_rate=10.0)
        cls.auth = AuthMiddleware(
            key_store=cls.key_store,
            token_provider=cls.token_provider,
            rate_limiter=cls.rate_limiter,
            excluded_paths=frozenset({"/", "/index.html", "/editor", "/health", "/ready"}),
        )
        OrxaqHandler.auth = cls.auth

        cls.api_key, cls.key_record = cls.key_store.generate_key(
            name="test", scopes=frozenset({"admin"})
        )

        def handler(*a, **kw):
            return _make_handler(api, editor, *a, **kw)

        cls.server = HTTPServer(("localhost", cls.port), handler)
        cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
        cls.thread.start()
        time.sleep(0.3)
        cls.base = f"http://localhost:{cls.port}"

    @classmethod
    def teardown_class(cls):
        cls.server.shutdown()
        OrxaqHandler.auth = None

    def test_api_without_key_returns_401(self):
        try:
            req = urllib.request.Request(self.base + "/api/status")
            urllib.request.urlopen(req)
            assert False, "Should have raised"
        except urllib.error.HTTPError as e:
            assert e.code == 401
            data = json.loads(e.read())
            assert "error" in data

    def test_api_with_valid_key_succeeds(self):
        req = urllib.request.Request(self.base + "/api/status")
        req.add_header("X-API-Key", self.api_key)
        with urllib.request.urlopen(req) as resp:
            assert resp.status == 200
            data = json.loads(resp.read())
            assert "dag" in data

    def test_api_with_invalid_key_returns_401(self):
        try:
            req = urllib.request.Request(self.base + "/api/status")
            req.add_header("X-API-Key", "orxaq_invalid_key_12345")
            urllib.request.urlopen(req)
            assert False, "Should have raised"
        except urllib.error.HTTPError as e:
            assert e.code == 401

    def test_api_with_bearer_token_succeeds(self):
        token = self.token_provider.issue(subject="test-user", scopes=frozenset({"admin"}))
        req = urllib.request.Request(self.base + "/api/status")
        req.add_header("Authorization", f"Bearer {token}")
        with urllib.request.urlopen(req) as resp:
            assert resp.status == 200

    def test_root_path_excluded_from_auth(self):
        req = urllib.request.Request(self.base + "/")
        with urllib.request.urlopen(req) as resp:
            assert resp.status == 200

    def test_post_without_key_returns_401(self):
        try:
            req = urllib.request.Request(
                self.base + "/api/dag/propose-edges",
                data=b"{}",
                headers={"Content-Type": "application/json"},
            )
            urllib.request.urlopen(req)
            assert False, "Should have raised"
        except urllib.error.HTTPError as e:
            assert e.code == 401

    def test_post_with_valid_key_succeeds(self):
        req = urllib.request.Request(
            self.base + "/api/dag/propose-edges",
            data=b"{}",
            headers={"Content-Type": "application/json", "X-API-Key": self.api_key},
        )
        with urllib.request.urlopen(req) as resp:
            assert resp.status == 200

    def test_cors_includes_auth_headers(self):
        req = urllib.request.Request(self.base + "/api/status", method="OPTIONS")
        with urllib.request.urlopen(req) as resp:
            allowed = resp.getheader("Access-Control-Allow-Headers")
            assert "X-API-Key" in allowed
            assert "Authorization" in allowed

    def test_delete_without_key_returns_401(self):
        try:
            req = urllib.request.Request(
                self.base + "/api/dag/nodes",
                data=b'{"entity_id":"x"}',
                headers={"Content-Type": "application/json"},
                method="DELETE",
            )
            urllib.request.urlopen(req)
            assert False, "Should have raised"
        except urllib.error.HTTPError as e:
            assert e.code == 401

    def test_delete_with_valid_key_succeeds(self):
        req = urllib.request.Request(
            self.base + "/api/dag/nodes",
            data=b'{"entity_id":"nonexistent_var"}',
            headers={"Content-Type": "application/json", "X-API-Key": self.api_key},
            method="DELETE",
        )
        try:
            with urllib.request.urlopen(req) as resp:
                assert resp.status in (200, 404)
        except urllib.error.HTTPError:
            pass  # 404 is acceptable


class TestServerSkinRendering:
    """Tests for non-gamma skin rendering paths."""

    @classmethod
    def setup_class(cls):
        cls.port = _find_free_port()
        api = OrxaqAPI()
        editor = DAGEditorAPI(api.engine)

        OrxaqHandler.skin_name = "beta"
        OrxaqHandler._skin_html_cache = {}
        OrxaqHandler.auth = None
        OrxaqHandler.ws_hub = None

        def handler(*a, **kw):
            return _make_handler(api, editor, *a, **kw)

        cls.server = HTTPServer(("localhost", cls.port), handler)
        cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
        cls.thread.start()
        time.sleep(0.3)
        cls.base = f"http://localhost:{cls.port}"

    @classmethod
    def teardown_class(cls):
        cls.server.shutdown()
        OrxaqHandler.skin_name = "gamma"
        OrxaqHandler._skin_html_cache = {}

    def test_beta_skin_dashboard(self):
        req = urllib.request.Request(self.base + "/")
        with urllib.request.urlopen(req) as resp:
            assert resp.status == 200
            ct = resp.getheader("Content-Type")
            assert "text/html" in ct

    def test_beta_skin_cached(self):
        """Second request should use cached HTML."""
        req = urllib.request.Request(self.base + "/")
        with urllib.request.urlopen(req) as resp:
            assert resp.status == 200

    def test_ws_not_configured_returns_404(self):
        """GET /ws/events without ws_hub returns 404 (not upgrade)."""
        try:
            req = urllib.request.Request(self.base + "/ws/events")
            urllib.request.urlopen(req)
            assert False, "Should have raised"
        except urllib.error.HTTPError as e:
            assert e.code == 404


class TestServerEdgeCases:
    """Tests for edge cases: invalid JSON, empty body, etc."""

    @classmethod
    def setup_class(cls):
        cls.port = _find_free_port()
        api = OrxaqAPI()
        editor = DAGEditorAPI(api.engine)

        OrxaqHandler.skin_name = "gamma"
        OrxaqHandler.auth = None
        OrxaqHandler.ws_hub = None

        def handler(*a, **kw):
            return _make_handler(api, editor, *a, **kw)

        cls.server = HTTPServer(("localhost", cls.port), handler)
        cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
        cls.thread.start()
        time.sleep(0.3)
        cls.base = f"http://localhost:{cls.port}"

    @classmethod
    def teardown_class(cls):
        cls.server.shutdown()

    def test_propose_with_invalid_json(self):
        """Propose with invalid JSON body should still return 200."""
        req = urllib.request.Request(
            self.base + "/api/dag/propose-edges",
            data=b"not-valid-json",
            headers={"Content-Type": "application/json"},
        )
        with urllib.request.urlopen(req) as resp:
            assert resp.status == 200
            data = json.loads(resp.read())
            assert "proposals" in data

    def test_propose_empty_body(self):
        """Propose with empty body should return proposals."""
        req = urllib.request.Request(
            self.base + "/api/dag/propose-edges",
            data=b"",
            headers={"Content-Type": "application/json", "Content-Length": "0"},
        )
        with urllib.request.urlopen(req) as resp:
            assert resp.status == 200
            data = json.loads(resp.read())
            assert "proposals" in data

    def test_ws_503_when_no_hub(self):
        """WebSocket upgrade request returns 503 when hub is None."""
        sock = socket.create_connection(("localhost", self.port), timeout=5)
        try:
            import base64

            key = base64.b64encode(b"test-key-12345678").decode()
            request = (
                f"GET /ws/events HTTP/1.1\r\n"
                f"Host: localhost:{self.port}\r\n"
                f"Upgrade: websocket\r\n"
                f"Connection: Upgrade\r\n"
                f"Sec-WebSocket-Key: {key}\r\n"
                f"Sec-WebSocket-Version: 13\r\n"
                f"\r\n"
            )
            sock.sendall(request.encode())
            response = b""
            while b"\r\n\r\n" not in response:
                chunk = sock.recv(4096)
                if not chunk:
                    break
                response += chunk
            header_end = response.index(b"\r\n\r\n") + 4
            response_str = response[:header_end].decode()
            assert "503" in response_str
        finally:
            sock.close()


class TestServeFunction:
    """Test the serve() convenience function via mocking."""

    def test_serve_starts_and_stops(self):
        """serve() creates server, prints banner, and responds to shutdown."""
        from unittest.mock import patch

        from orxaq.experiences.server import serve

        port = _find_free_port()
        mock_server = MagicMock()
        # serve_forever returns immediately (no-op), then finally block runs.
        mock_server.serve_forever.return_value = None

        with (
            patch(
                "orxaq.experiences.server.HTTPServer",
                return_value=mock_server,
            ),
            patch("webbrowser.open") as mock_browser,
        ):
            serve(
                port=port,
                skin="gamma",
                open_browser=True,
                auth_enabled=False,
            )

        mock_server.serve_forever.assert_called_once()
        mock_browser.assert_called_once()

    def test_serve_no_browser(self):
        from unittest.mock import patch

        from orxaq.experiences.server import serve

        port = _find_free_port()
        mock_server = MagicMock()
        mock_server.serve_forever.return_value = None

        with (
            patch(
                "orxaq.experiences.server.HTTPServer",
                return_value=mock_server,
            ),
            patch("webbrowser.open") as mock_browser,
        ):
            serve(
                port=port,
                skin="beta",
                open_browser=False,
                auth_enabled=False,
            )

        mock_server.serve_forever.assert_called_once()
        mock_browser.assert_not_called()

    def test_serve_with_auth(self, capsys):
        from unittest.mock import patch

        from orxaq.experiences.server import serve

        port = _find_free_port()
        mock_server = MagicMock()
        mock_server.serve_forever.return_value = None

        with (
            patch(
                "orxaq.experiences.server.HTTPServer",
                return_value=mock_server,
            ),
            patch("webbrowser.open"),
        ):
            serve(
                port=port,
                skin="gamma",
                open_browser=True,
                auth_enabled=True,
            )

        mock_server.serve_forever.assert_called_once()
        captured = capsys.readouterr().out
        assert "Auth enabled" in captured
