import json
from types import SimpleNamespace
from unittest import mock
from unittest.mock import patch

import httpx
import pytest

import fbnconfig.resource_abc
from fbnconfig import create_client
from fbnconfig.deploy import Action, Deployment, run
from fbnconfig.resource_abc import Resource, get_resource_class, register_resource

TEST_BASE = "https://foo.lusid.com"


@pytest.mark.respx(base_url=TEST_BASE)
class DescribeWithNoRemoteResources:
    client = create_client(TEST_BASE, "xyz")

    def test_handles_429_on_get(self, respx_mock):
        # given the server returns a 429 before returning the real response
        respx_mock.get("/api/api/customentities/~deployment").mock(
            side_effect=[
                httpx.Response(429, json={}, headers={"retry-after": "0"}),
                httpx.Response(200, json={"values": []}),
            ]
        )
        # when we run an empty deployment
        deployment = Deployment("whatever", [])
        run(self.client, deployment)
        # then the 429 gets retried and we continue
        request = respx_mock.calls.last.request
        assert request.method == "GET"
        assert request.url.path == "/api/api/customentities/~deployment"
        assert request.url.params._dict == {"filter": ["fields[deployment] eq 'whatever'"]}
        # but no other http requests are made

    def test_run_with_empty_deploy_does_nothing(self, respx_mock):
        # given there are no resources in the log
        respx_mock.get("/api/api/customentities/~deployment").mock(
            return_value=httpx.Response(200, json={"values": []})
        )
        # when we run an empty deployment
        deployment = Deployment("whatever", [])
        actions = run(self.client, deployment)
        # then the log is searched
        request = respx_mock.calls.last.request
        assert request.method == "GET"
        assert request.url.path == "/api/api/customentities/~deployment"
        assert request.url.params._dict == {"filter": ["fields[deployment] eq 'whatever'"]}
        # but no other http requests are made
        # and an empty actions list is returned
        assert actions == []

    def test_run_with_one_resource_creates_it(self, respx_mock):
        # given there are no resources in the log
        respx_mock.get("/api/api/customentities/~deployment").mock(
            return_value=httpx.Response(200, json={"values": []})
        )
        respx_mock.post("/api/api/customentities/~deployment").mock(
            return_value=httpx.Response(200, json={})
        )
        # when we run a deployment with one resource
        resource = mock.Mock(spec=Resource)
        resource.deps = mock.Mock(return_value=[])
        resource.id = "23"
        resource.create = mock.Mock(return_value={"some_variable": "x"})
        deployment = Deployment("whatever", [resource])
        actions = run(self.client, deployment)
        # then the create method on the resource is called
        resource.create.assert_called_once_with(self.client)
        # and the create is recorded in the log
        request = respx_mock.calls.last.request
        assert request.method == "POST"
        assert request.url.path == "/api/api/customentities/~deployment"
        assert json.loads(request.content) == {
            "description": "...",
            "displayName": "23",
            "fields": [
                {"name": "deployment", "value": "whatever"},
                {"name": "resourceType", "value": "Mock"},
                {"name": "dependencies", "value": []},
                {"name": "resource", "value": "23"},
                {"name": "state", "value": '{"some_variable": "x"}'},
            ],
            "identifiers": [
                {
                    "identifierScope": "deployment",
                    "identifierType": "resource",
                    "identifierValue": "whatever_23",
                }
            ],
        }
        # and the create is in the actions
        assert actions == [Action(id="23", type="Mock", change="create")]

    def test_run_with_one_resource_empty_state(self, respx_mock):
        # given there are no resources in the log
        respx_mock.get("/api/api/customentities/~deployment").mock(
            return_value=httpx.Response(200, json={"values": []})
        )
        respx_mock.post("/api/api/customentities/~deployment").mock(
            return_value=httpx.Response(200, json={})
        )
        # when we run a deployment with one resource
        resource = mock.Mock(spec=Resource)
        resource.deps = mock.Mock(return_value=[])
        resource.id = "23"
        # and the new state value is an empty dict
        resource.create = mock.Mock(return_value={})
        deployment = Deployment("whatever", [resource])
        actions = run(self.client, deployment)
        # then the create method on the resource is called
        resource.create.assert_called_once_with(self.client)
        # and the create is recorded in the log
        request = respx_mock.calls.last.request
        assert request.method == "POST"
        assert request.url.path == "/api/api/customentities/~deployment"
        assert json.loads(request.content) == {
            "description": "...",
            "displayName": "23",
            "fields": [
                {"name": "deployment", "value": "whatever"},
                {"name": "resourceType", "value": "Mock"},
                {"name": "dependencies", "value": []},
                {"name": "resource", "value": "23"},
                {"name": "state", "value": "{}"},
            ],
            "identifiers": [
                {
                    "identifierScope": "deployment",
                    "identifierType": "resource",
                    "identifierValue": "whatever_23",
                }
            ],
        }
        # and the action is to create
        assert actions == [Action(id="23", type="Mock", change="create")]

    def test_run_with_two_resources(self, respx_mock):
        # given there are no resources in the log
        respx_mock.get("/api/api/customentities/~deployment").mock(
            return_value=httpx.Response(200, json={"values": []})
        )
        respx_mock.post("/api/api/customentities/~deployment").mock(
            return_value=httpx.Response(200, json={})
        )
        # and desired resources one and two are independent
        resource1 = mock.Mock(spec=Resource)
        resource1.deps = mock.Mock(return_value=[])
        resource1.id = "one"
        resource1.create = mock.Mock(return_value={"some_variable": "x"})
        resource2 = mock.Mock(spec=Resource)
        resource2.deps = mock.Mock(return_value=[])
        resource2.id = "two"
        resource2.create = mock.Mock(return_value={"some_variable": "y"})
        # when we run a deployment with one and two
        deployment = Deployment("whatever", [resource1, resource2])
        actions = run(self.client, deployment)
        # then the create methods are called on both
        resource1.create.assert_called_once_with(self.client)
        resource2.create.assert_called_once_with(self.client)
        # and the create is recorded in the log for both in the order they were
        # added to the deployment
        request = respx_mock.calls[-2].request
        assert request.method == "POST"
        assert request.url.path == "/api/api/customentities/~deployment"
        assert json.loads(request.content)["displayName"] == "one"
        request = respx_mock.calls[-1].request
        assert request.method == "POST"
        assert request.url.path == "/api/api/customentities/~deployment"
        assert json.loads(request.content)["displayName"] == "two"
        # and they appear in order in the actions
        assert actions == [
            Action(id="one", type="Mock", change="create"),
            Action(id="two", type="Mock", change="create"),
        ]

    def test_run_logs_unique_dependencies(self, respx_mock):
        # given there are no resources in the log
        respx_mock.get("/api/api/customentities/~deployment").mock(
            return_value=httpx.Response(200, json={"values": []})
        )
        respx_mock.post("/api/api/customentities/~deployment").mock(
            return_value=httpx.Response(200, json={})
        )
        # and resources "one" and "two" where "two" depends on "one"
        # twice
        resource1 = mock.Mock(spec=Resource)
        resource1.deps = mock.Mock(return_value=[])
        resource1.id = "one"
        resource1.create = mock.Mock(return_value={"some_variable": "x"})
        resource2 = mock.Mock(spec=Resource)
        resource2.deps = mock.Mock(return_value=[resource1, resource1])  # double dependency
        resource2.id = "two"
        resource2.create = mock.Mock(return_value={"some_variable": "x"})
        # when we deploy
        deployment = Deployment("whatever", [resource1, resource2])
        run(self.client, deployment)
        # then the create method on each of them is called
        resource1.create.assert_called_once_with(self.client)
        resource2.create.assert_called_once_with(self.client)
        # and the create is recorded in the log for both in dependency order
        request = respx_mock.calls[-2].request
        assert request.method == "POST"
        assert request.url.path == "/api/api/customentities/~deployment"
        assert json.loads(request.content)["displayName"] == "one"
        request = respx_mock.calls[-1].request
        assert request.method == "POST"
        assert request.url.path == "/api/api/customentities/~deployment"
        two_log = json.loads(request.content)
        assert two_log["displayName"] == "two"
        # and resource two has uniquely recorded dependency on resource one
        assert next(
            field["value"] for field in two_log["fields"] if field["name"] == "dependencies"
        ) == ["one"]

    def test_run_with_one_resource_and_dependency(self, respx_mock):
        # given there are no resources in the log
        respx_mock.get("/api/api/customentities/~deployment").mock(
            return_value=httpx.Response(200, json={"values": []})
        )
        respx_mock.post("/api/api/customentities/~deployment").mock(
            return_value=httpx.Response(200, json={})
        )
        # and resources "one" and "two" where "two" depends on "one"
        resource1 = mock.Mock(spec=Resource)
        resource1.deps = mock.Mock(return_value=[])
        resource1.id = "one"
        resource1.create = mock.Mock(return_value={"some_variable": "x"})
        resource2 = mock.Mock(spec=Resource)
        resource2.deps = mock.Mock(return_value=[resource1])
        resource2.id = "two"
        resource2.create = mock.Mock(return_value={"some_variable": "x"})
        # when we deploy "two" only
        deployment = Deployment("whatever", [resource2])
        actions = run(self.client, deployment)
        # then the create method on each of them is called
        resource1.create.assert_called_once_with(self.client)
        resource2.create.assert_called_once_with(self.client)
        # and the create is recorded in the log for both in dependency order
        request = respx_mock.calls[-2].request
        assert request.method == "POST"
        assert request.url.path == "/api/api/customentities/~deployment"
        assert json.loads(request.content)["displayName"] == "one"
        request = respx_mock.calls[-1].request
        assert request.method == "POST"
        assert request.url.path == "/api/api/customentities/~deployment"
        two_log = json.loads(request.content)
        assert two_log["displayName"] == "two"
        # and resource two has recorded a dependency on resource one
        assert next(f["value"] for f in two_log["fields"] if f["name"] == "dependencies") == ["one"]
        # and they appear in order in the actions
        assert actions == [
            Action(id="one", type="Mock", change="create"),
            Action(id="two", type="Mock", change="create"),
        ]

    def test_run_with_deuplicate_resourcei_ids(self, respx_mock):
        # given two resources using the same resource id and a dependency between them
        resource1 = mock.Mock(spec=Resource)
        resource1.deps = mock.Mock(return_value=[])
        resource1.id = "duplicate-id"
        resource1.create = mock.Mock(return_value={"some_variable": "x"})
        resource2 = mock.Mock(spec=Resource)
        resource2.deps = mock.Mock(return_value=[resource1])
        resource2.id = "duplicate-id"
        resource2.create = mock.Mock(return_value={"some_variable": "x"})
        # when we deploy resource2
        deployment = Deployment("whatever", [resource2])
        # then an error is raised because resource2 depends on resource1
        # but they have the same resource id
        with pytest.raises(RuntimeError) as ex:
            run(self.client, deployment)
        assert "duplicate-id has been used on more than one resource" in str(ex.value)


@pytest.mark.respx(base_url=TEST_BASE)
class DescribeWithExistingResources:
    client = httpx.Client(base_url=TEST_BASE)

    @pytest.fixture
    def existing_resource(self):
        return {
            "description": "...",
            "displayName": "one",
            "fields": [
                {"name": "deployment", "value": "whatever"},
                {"name": "resourceType", "value": "FileResource"},
                {"name": "dependencies", "value": []},
                {"name": "resource", "value": "one"},
                {"name": "state", "value": '{"some_variable": "x"}'},
            ],
            "identifiers": [
                {
                    "identifierScope": "deployment",
                    "identifierType": "resource",
                    "identifierValue": "whatever_one",
                }
            ],
            "version": {"asAtModified": "2024-02-20T15:39:58.1008350+00:00"},
        }

    @pytest.fixture
    def dependent_resource(self):
        # this resource "two" depends on resource "one"
        return {
            "description": "...",
            "displayName": "two",
            "fields": [
                {"name": "deployment", "value": "whatever"},
                {"name": "resourceType", "value": "FileResource"},
                {"name": "dependencies", "value": ["one"]},
                {"name": "resource", "value": "two"},
                {"name": "state", "value": '{"other_variable": "x"}'},
            ],
            "identifiers": [
                {
                    "identifierScope": "deployment",
                    "identifierType": "resource",
                    "identifierValue": "whatever_two",
                }
            ],
            "version": {"asAtModified": "2024-02-20T15:39:58.1008350+00:00"},
        }

    def test_run_existing_resource_calls_update_no_change(self, respx_mock, existing_resource):
        # given that resource "one" already exists in the log
        respx_mock.get("/api/api/customentities/~deployment").mock(
            return_value=httpx.Response(200, json={"values": [existing_resource]})
        )
        # and the desired resource one
        resource1 = mock.Mock(spec=Resource)
        resource1.deps = mock.Mock(return_value=[])
        resource1.id = "one"
        resource1.__name__ = "FileResource"
        # and it is the same as the remote
        resource1.update = mock.Mock(return_value=None)
        # when we deploy "one"
        deployment = Deployment("whatever", [resource1])
        actions = run(self.client, deployment)
        # then the update method is called with the log state
        resource1.update.assert_called_once_with(self.client, SimpleNamespace(some_variable="x"))
        # and the log is not updated
        # but nochange is in the actions
        assert actions == [Action(id="one", type="Mock", change="nochange")]

    def test_run_existing_resource_calls_update_new_state(self, respx_mock, existing_resource):
        # given that resource "one" already exists in the log
        respx_mock.get("/api/api/customentities/~deployment").mock(
            return_value=httpx.Response(200, json={"values": [existing_resource]})
        )
        respx_mock.post("/api/api/customentities/~deployment").mock(
            return_value=httpx.Response(200, json={})
        )
        # and a desired resource "one"
        resource1 = mock.Mock(spec=Resource)
        resource1.deps = mock.Mock(return_value=[])
        resource1.id = "one"
        resource1.__name__ = "FileResource"
        # and it is different to the remote state (it returns not None)
        resource1.update = mock.Mock(return_value={"some_variable": "abcdefg"})
        # when we deploy "one"
        deployment = Deployment("whatever", [resource1])
        actions = run(self.client, deployment)
        # then the update method is called with the remote log state
        resource1.update.assert_called_once_with(self.client, SimpleNamespace(some_variable="x"))
        # and the state returned by update is recorded in the log
        request = respx_mock.calls[-1].request
        assert request.method == "POST"
        assert request.url.path == "/api/api/customentities/~deployment"
        content = json.loads(request.content)
        assert content["displayName"] == "one"
        new_state = next(field["value"] for field in content["fields"] if field["name"] == "state")
        assert json.loads(new_state) == {"some_variable": "abcdefg"}
        # and the action is update
        assert actions == [Action(id="one", type="Mock", change="update")]

    def test_run_existing_resource_gets_deleted_when_not_desired(self, respx_mock, existing_resource):
        # given that resource "one" already exists in the log
        respx_mock.get("/api/api/customentities/~deployment").mock(
            return_value=httpx.Response(200, json={"values": [existing_resource]})
        )
        respx_mock.delete(
            "/api/api/customentities/~deployment/resource/whatever_one?identifierScope=deployment"
        ).mock(return_value=httpx.Response(200, json={}))
        # and "one" is not in the deployment
        deployment = Deployment("whatever", [])
        from fbnconfig import drive

        mock_resource = mock.Mock(spec=drive.FileResource)
        mock_resource.delete = mock.Mock(return_value=None)
        with mock.patch(
            "fbnconfig.resource_abc.RESOURCE_REGISTRY", {"FileResource": {"class": mock_resource}}
        ):
            actions = run(self.client, deployment)
            # then the FileResource.delete function is called (the patched one for this test)
            mock_resource.delete.assert_called_once_with(self.client, SimpleNamespace(some_variable="x"))
            # and the log record is deleted
            request = respx_mock.calls[-1].request
            assert request.method == "DELETE"
            # and the url matches the one mocked above
            # and the action is update
            assert actions == [Action(id="one", type="FileResource", change="remove")]

    def test_run_implicit_resource_does_not_get_deleted(
        self, respx_mock, existing_resource, dependent_resource
    ):
        # given that resource "one" and "two already exists in the log and "two" depends on "one"
        respx_mock.get("/api/api/customentities/~deployment").mock(
            return_value=httpx.Response(200, json={"values": [existing_resource, dependent_resource]})
        )
        # and desired resources "one" and "two" match what has been deployed
        #    where "two" depends on "one"
        resource1 = mock.Mock(spec=Resource)
        resource1.deps = mock.Mock(return_value=[])
        resource1.id = "one"
        resource1.update = mock.Mock(return_value=None)
        resource2 = mock.Mock(spec=Resource)
        resource2.deps = mock.Mock(return_value=[resource1])  # two depends on one
        resource2.id = "two"
        resource2.update = mock.Mock(return_value=None)
        # and "two" is explicitly in the deployment but "one" is only implicity
        # because two depends on "one"
        deployment = Deployment("whatever", [resource2])
        from fbnconfig import drive

        mock_resource = mock.Mock(spec=drive.FileResource)
        mock_resource.delete = mock.Mock(return_value=None)
        with mock.patch(
            "fbnconfig.resource_abc.RESOURCE_REGISTRY", {"FileResource": {"class": mock_resource}}
        ):
            run(self.client, deployment)
            # then no delete calls are made because we should keep both resources
            assert mock_resource.delete.mock_calls == []

    def test_run_dependent_resources_get_deleted_in_order(
        self, respx_mock, existing_resource, dependent_resource
    ):
        # given resources "one" and "two" where "two"depends on "one"
        respx_mock.get("/api/api/customentities/~deployment").mock(
            return_value=httpx.Response(200, json={"values": [dependent_resource, existing_resource]})
        )
        respx_mock.delete(
            "/api/api/customentities/~deployment/resource/whatever_one?identifierScope=deployment",
            name="delete_one",
        ).mock(return_value=httpx.Response(200, json={}))
        respx_mock.delete(
            "/api/api/customentities/~deployment/resource/whatever_two?identifierScope=deployment",
            name="delete_two",
        ).mock(return_value=httpx.Response(200, json={}))
        # and the deployment is empty
        deployment = Deployment("whatever", [])
        from fbnconfig import drive

        mock_resource = mock.Mock(spec=drive.FileResource)
        mock_resource.delete = mock.Mock(return_value=None)
        with mock.patch(
            "fbnconfig.resource_abc.RESOURCE_REGISTRY", {"FileResource": {"class": mock_resource}}
        ):
            run(self.client, deployment)
            # then the FileResource.delete function is called for both, dependent resource first
            assert mock_resource.delete.mock_calls == [
                mock.call(self.client, SimpleNamespace(other_variable="x")),
                mock.call(self.client, SimpleNamespace(some_variable="x")),
            ]
            # and the log record for two is deleted
            request = respx_mock.calls[-2].request
            assert request.method == "DELETE"
            assert request.url.path == "/api/api/customentities/~deployment/resource/whatever_two"
            # before the log record for one is deleted last
            request = respx_mock.calls[-1].request
            assert request.method == "DELETE"
            assert request.url.path == "/api/api/customentities/~deployment/resource/whatever_one"
            # and the url matches the one mocked above


@pytest.mark.respx(base_url=TEST_BASE)
class DescribeResourceRegistry:
    client = create_client(TEST_BASE, "xyz")

    @pytest.fixture()
    def stub_resource_class(self):
        class StubResource(Resource):
            id: str

            def read(self, client, old_state):
                return None

            def create(self, client):
                return None

            def update(self, client, old_state):
                return None

            @staticmethod
            def delete(client, old_state):
                pass

            def deps(self):
                return []

        return StubResource

    @staticmethod
    def test_resource_registry_returns_expected_registrations(stub_resource_class):
        with patch.dict("fbnconfig.resource_abc.RESOURCE_REGISTRY", {}, clear=True) as registry:
            # GIVEN Two resources are registered
            @register_resource()
            class KlassResource(stub_resource_class):
                pass

            @register_resource(type_name="DifferentName")
            class MyOtherKlassResource(stub_resource_class):
                pass

            # THEN the resource registry is populated with the expected resources
            assert len(registry) == 2
            assert registry == {
                "KlassResource": {"class": KlassResource},
                "DifferentName": {"class": MyOtherKlassResource},
            }

    @staticmethod
    def test_get_resource_class_when_unregistered_resource_fails():
        with pytest.raises(RuntimeError) as ex:
            get_resource_class("UnregisteredResource")

        assert "Resource type 'UnregisteredResource' not found in registry" in str(ex.value)

    @staticmethod
    def test_get_resource_type_defaults_to_name():
        resource1 = mock.Mock(spec=Resource)
        resource1.__name__ = "something"
        type = fbnconfig.resource_abc.get_resource_type(resource1)
        assert type == "Mock"
