"""Tests for the POST /api/fbnconfig/deploy/stream endpoint."""

import json
from unittest import mock
from unittest.mock import Mock, patch

import httpx
import pytest
from starlette.testclient import TestClient

from fbnconfig.deploy import Action, Deployment, run, undump_deployment
from fbnconfig.resource_abc import Resource
from server.app import app

AUTH_HEADERS = {
    "Authorization": "Bearer test-token-abc123",
    "X-LUSID-Host": "https://test.lusid.com",
}

VALID_BODY = {
    "deployment": {
        "deploymentId": "test-deploy",
        "resources": [],
    },
}


def parse_stream(response) -> list[dict]:
    lines = response.text.strip().split("\n")
    return [json.loads(line) for line in lines if line.strip()]


def mock_empty_deployment():
    return mock.patch(
        "server.app.undump_deployment",
        return_value=Deployment("test-deploy", []),
    )


class DescribeDeployStream:

    @pytest.fixture()
    def api(self):
        return TestClient(app)

    def test_missing_auth_returns_401(self, api):
        # given a request with no Authorization header
        # when we call the endpoint
        response = api.post(
            "/api/fbnconfig/deploy/stream",
            json=VALID_BODY,
            headers={"X-LUSID-Host": "https://test.lusid.com"},
            )

        # then we get a 401
        assert response.status_code == 401

    def test_missing_lusid_host_returns_401(self, api):
        # given a request with no X-LUSID-Host header
        # when we call the endpoint
        response = api.post(
            "/api/fbnconfig/deploy/stream",
            json=VALID_BODY,
            headers={"Authorization": "Bearer some-token"},
        )

        # then we get a 401
        assert response.status_code == 401

    def test_invalid_json_returns_400(self, api):
        # given a request body that is not valid JSON
        # when we call the endpoint
        response = api.post(
            "/api/fbnconfig/deploy/stream",
            content=b"not json",
            headers={**AUTH_HEADERS, "Content-Type": "application/json"},
            )

        # then we get a 400
        assert response.status_code == 400

    def test_missing_deployment_returns_422(self, api):
        # given a request body with no deployment field
        # when we call the endpoint
        response = api.post("/api/fbnconfig/deploy/stream", json={}, headers=AUTH_HEADERS)

        # then we get a 422
        assert response.status_code == 422

    def test_empty_deployment_streams_full_lifecycle(self, api):
        # given an empty deployment with no resources
        with mock_empty_deployment(), \
                mock.patch("server.app.deploy_run", return_value=[]), \
                mock.patch("server.app.fbnconfig.create_client"):
            # when we call the endpoint
            response = api.post("/api/fbnconfig/deploy/stream", json=VALID_BODY, headers=AUTH_HEADERS)

        # then we get a 200 with NDJSON content type
        assert response.status_code == 200
        assert response.headers["content-type"] == "application/x-ndjson"
        assert response.headers["cache-control"] == "no-store"

        # and the lifecycle events are pending → running → summary → success
        events = parse_stream(response)
        event_types = [(e["eventType"], e["status"]) for e in events]
        assert event_types == [
            ("deployment_status", "pending"),
            ("deployment_status", "running"),
            ("summary", "success"),
            ("deployment_status", "success"),
        ]
        # and sequences start at 1 and increment
        assert [e["sequence"] for e in events] == [1, 2, 3, 4]
        # and all events share the same deploymentId
        assert all(e["deploymentId"] == "test-deploy" for e in events)
        # and correlationId is absent when no Correlationid header is sent
        assert all("correlationId" not in e for e in events)

    def test_correlation_id_included_when_header_present(self, api):
        # given a request with a Correlationid header
        headers = {**AUTH_HEADERS, "Correlationid": "corr-abc-123"}

        with mock_empty_deployment(), \
                mock.patch("server.app.deploy_run", return_value=[]), \
                mock.patch("server.app.fbnconfig.create_client"):
            # when we call the endpoint
            response = api.post("/api/fbnconfig/deploy/stream", json=VALID_BODY, headers=headers)

        # then every event includes the correlation ID
        events = parse_stream(response)
        assert all(e["correlationId"] == "corr-abc-123" for e in events)

    def test_resource_actions_are_streamed(self, api):
        # given a deployment that creates and updates resources
        mock_actions = [
            Action(id="folder-1", type="FolderResource", change="create"),
            Action(id="policy-1", type="PolicyResource", change="update"),
        ]

        def fake_run(client, deployment, event_cb=None):
            if event_cb:
                for a in mock_actions:
                    event_cb({
                        "eventType": "resource_action",
                        "status": "running",
                        "data": {"resourceId": a.id, "resourceType": a.type, "change": a.change},
                    })
            return mock_actions

        with mock_empty_deployment(), \
                mock.patch("server.app.deploy_run", side_effect=fake_run), \
                mock.patch("server.app.fbnconfig.create_client"):
            # when we call the endpoint
            response = api.post("/api/fbnconfig/deploy/stream", json=VALID_BODY, headers=AUTH_HEADERS)

        # then resource_action events appear in the stream
        events = parse_stream(response)
        resource_events = [e for e in events if e["eventType"] == "resource_action"]
        assert len(resource_events) == 2
        assert resource_events[0]["data"]["change"] == "create"
        assert resource_events[1]["data"]["change"] == "update"

    def test_summary_contains_action_counts(self, api):
        # given a deployment with mixed action types
        mock_actions = [
            Action(id="f1", type="FolderResource", change="create"),
            Action(id="f2", type="FolderResource", change="create"),
            Action(id="p1", type="PolicyResource", change="nochange"),
        ]

        with mock_empty_deployment(), \
                mock.patch("server.app.deploy_run", return_value=mock_actions), \
                mock.patch("server.app.fbnconfig.create_client"):
            # when we call the endpoint
            response = api.post("/api/fbnconfig/deploy/stream", json=VALID_BODY, headers=AUTH_HEADERS)

        # then the summary event contains correct counts and duration
        summary = next(e for e in parse_stream(response) if e["eventType"] == "summary")
        assert summary["data"]["actions"] == {"create": 2, "nochange": 1}
        assert "durationMs" in summary["data"]

    def test_exception_emits_error_then_failure(self, api):
        # given a deployment that raises a RuntimeError
        def failing_run(client, deployment, event_cb=None):
            raise RuntimeError("something went wrong")

        with mock_empty_deployment(), \
                mock.patch("server.app.deploy_run", side_effect=failing_run), \
                mock.patch("server.app.fbnconfig.create_client"):
            # when we call the endpoint
            response = api.post("/api/fbnconfig/deploy/stream", json=VALID_BODY, headers=AUTH_HEADERS)

        # then we still get a 200 (errors are in-stream)
        assert response.status_code == 200
        # and the stream contains an error event followed by failure status
        events = parse_stream(response)
        error_event = next(e for e in events if e["eventType"] == "error")
        assert error_event["data"]["errorType"] == "RuntimeError"
        assert events[-1] == {**events[-1], "eventType": "deployment_status", "status": "failure"}

    def test_http_status_error_includes_status_code(self, api):
        # given a deployment that raises an HTTPStatusError with a 401
        mock_response = mock.Mock()
        mock_response.status_code = 401

        def failing_run(client, deployment, event_cb=None):
            raise httpx.HTTPStatusError("401", request=mock.Mock(), response=mock_response)

        with mock_empty_deployment(), \
                mock.patch("server.app.deploy_run", side_effect=failing_run), \
                mock.patch("server.app.fbnconfig.create_client"):
            # when we call the endpoint
            response = api.post("/api/fbnconfig/deploy/stream", json=VALID_BODY, headers=AUTH_HEADERS)

        # then the error event includes the HTTP status code
        error_event = next(e for e in parse_stream(response) if e["eventType"] == "error")
        assert error_event["data"]["httpStatus"] == 401

    def test_unknown_exception_does_not_leak_details(self, api):
        # given a deployment that raises an unexpected exception type
        def failing_run(client, deployment, event_cb=None):
            raise TypeError("internal detail that should not leak")

        with mock_empty_deployment(), \
                mock.patch("server.app.deploy_run", side_effect=failing_run), \
                mock.patch("server.app.fbnconfig.create_client"):
            # when we call the endpoint
            response = api.post("/api/fbnconfig/deploy/stream", json=VALID_BODY, headers=AUTH_HEADERS)

        # then the error type is generic and the internal message is hidden
        error_event = next(e for e in parse_stream(response) if e["eventType"] == "error")
        assert error_event["data"]["errorType"] == "InternalError"
        assert "internal detail" not in error_event["data"]["message"]

    def test_vso_token_never_appears_in_stream(self, api):
        # given a VSO token in the x-fbn-verified-service-origin header
        vso_token = "some-secret-vso-value-12345"
        headers = {
            **AUTH_HEADERS,
            "x-fbn-verified-service-origin": vso_token,
        }

        # and a deployment that leaks the VSO token in an error message
        def failing_run(client, deployment, event_cb=None):
            raise RuntimeError(f"Failed with VSO {vso_token}")

        with mock_empty_deployment(), \
                mock.patch("server.app.deploy_run", side_effect=failing_run), \
                mock.patch("server.app.fbnconfig.create_client"):
            # when we call the endpoint
            response = api.post("/api/fbnconfig/deploy/stream", json=VALID_BODY, headers=headers)

        # then the VSO token is redacted from the response stream
        assert vso_token not in response.text

    def test_unregistered_resource_type_returns_422(self, api):
        # given a deployment payload referencing a resource type that doesn't exist
        body = {
            "deployment": {
                "deploymentId": "bad-resource",
                "resources": [{
                    "resourceType": "NonExistentResource",
                    "resourceId": "r1",
                    "value": {},
                    "dependencies": [],
                }],
            },
        }

        # when we call the endpoint (no mock - let real parsing run)
        response = api.post("/api/fbnconfig/deploy/stream", json=body, headers=AUTH_HEADERS)

        # then we get a 422 and the detail does not leak internal class names or paths
        assert response.status_code == 422
        assert "Failed to parse deployment" in response.json()["detail"]


class DescribeEventCallback:

    def test_event_cb_fires_for_each_action(self):
        # given two resources that will be created
        resource1 = Mock(spec=Resource)
        resource1.deps = Mock(return_value=[])
        resource1.id = "res-1"
        resource1.create = Mock(return_value={"state": "created"})

        resource2 = Mock(spec=Resource)
        resource2.deps = Mock(return_value=[])
        resource2.id = "res-2"
        resource2.create = Mock(return_value={"state": "created"})

        callback = Mock()

        # when we run the deployment with an event callback
        with mock.patch("fbnconfig.deploy.log.list_resources_for_deployment", return_value=[]), \
                mock.patch("fbnconfig.deploy.log.record"):
            run(mock.Mock(), Deployment("test", [resource1, resource2]), event_cb=callback)

        # then the callback fires once per resource with the correct IDs
        assert callback.call_count == 2
        assert callback.call_args_list[0][0][0]["data"]["resourceId"] == "res-1"
        assert callback.call_args_list[1][0][0]["data"]["resourceId"] == "res-2"

    def test_no_event_cb_still_works(self):
        # given an empty deployment with no callback
        # when we run it
        with mock.patch("fbnconfig.deploy.log.list_resources_for_deployment", return_value=[]):
            actions = run(mock.Mock(), Deployment("test", []))

        # then it completes with no actions
        assert actions == []


class DescribeUndumpDeployment:

    def test_constructs_deployment_from_dict(self):
        # given a deployment data dict with one resource
        from tests.unit.test_deploy import StubResource

        data = {
            "deploymentId": "round-trip",
            "resources": [{
                "resourceType": "StubResource",
                "resourceId": "r1",
                "value": {"data": "hello"},
                "dependencies": [],
            }],
        }

        registry = {"StubResource": {"class": StubResource}}

        # when we construct a deployment from the dict
        with patch.dict("fbnconfig.resource_abc.RESOURCE_REGISTRY", registry):
            result = undump_deployment(data)

        # then the deployment is built with the correct ID and resource
        assert result.id == "round-trip"
        assert len(result.resources) == 1
        assert isinstance(result.resources[0], StubResource)
        assert result.resources[0].data == "hello"
