# Copyright © The Debusine Developers
# See the AUTHORS file at the top-level directory of this distribution
#
# This file is part of Debusine. It is subject to the license terms
# in the LICENSE file found in the top-level directory of this
# distribution. No part of Debusine, including this file, may be copied,
# modified, propagated, or distributed except according to the terms
# contained in the LICENSE file.

"""Tests for Hetzner Cloud workers."""

import json
from typing import override
from unittest.mock import call, patch

import responses
from hcloud import APIException
from hcloud.networks.domain import Network
from responses.matchers import header_matcher, query_string_matcher

from debusine.assets import CloudProvidersType, HetznerProviderAccountData
from debusine.db.models import Asset, Worker, WorkerPool
from debusine.server.worker_pools.exceptions import InstanceLaunchException
from debusine.server.worker_pools.hetzner import HetznerCloudWorkerPool
from debusine.test.django import TestCase


class TestHetznerCloudWorkerPool(TestCase):
    """
    Test for HetznerCloudWorkerPool.

    We use responses instead of mocks, as Server() objects don't implement
    equality checking magic methods, yet:
    https://github.com/hetznercloud/hcloud-python/pull/481
    """

    provider_account: Asset
    worker_pool_model: WorkerPool

    @override
    def setUp(self) -> None:
        super().setUp()
        self.provider_account = (
            self.playground.create_cloud_provider_account_asset(
                cloud_provider=CloudProvidersType.HETZNER
            )
        )
        self.worker_pool_model = self.playground.create_worker_pool(
            provider_account=self.provider_account
        )

    def get_worker_pool(self) -> HetznerCloudWorkerPool:
        """Return a HetznerCloudWorkerPool instance."""
        worker_pool = self.worker_pool_model.provider_interface
        assert isinstance(worker_pool, HetznerCloudWorkerPool)
        return worker_pool

    @responses.activate
    def test_client_token(self) -> None:
        worker_pool = self.get_worker_pool()
        client = worker_pool.client
        self.assertIsNotNone(client)
        provider_account_data = self.provider_account.data_model
        assert isinstance(provider_account_data, HetznerProviderAccountData)
        account_token = provider_account_data.credentials.api_token
        responses.add(
            responses.GET,
            "https://api.hetzner.cloud/v1/servers",
            match=[
                header_matcher({"Authorization": f"Bearer {account_token}"})
            ],
        )
        client.request(method="GET", url="/servers")

    @responses.activate
    def test_terminate_existing_instances(self) -> None:
        worker_pool = self.get_worker_pool()
        responses.add(
            responses.DELETE,
            "https://api.hetzner.cloud/v1/servers/42",
            json={"action": {"id": 1}},
        )
        worker_pool._terminate_existing_instances({"instance-id": 42})

    def test_terminate_existing_instances_noop(self) -> None:
        worker_pool = self.get_worker_pool()
        worker_pool._terminate_existing_instances(None)
        worker_pool._terminate_existing_instances({})

    def test_terminate_existing_instances_missing(self) -> None:
        worker_pool = self.get_worker_pool()
        with patch.object(
            worker_pool.client.servers,
            "delete",
            side_effect=APIException(
                code="not_found",
                message="server with ID '42' not found",
                details=None,
            ),
        ):
            worker_pool._terminate_existing_instances({"instance-id": 42})

    def test_terminate_existing_instances_unexpected_failure(self) -> None:
        worker_pool = self.get_worker_pool()
        with (
            patch.object(
                worker_pool.client.servers,
                "delete",
                side_effect=APIException(
                    code="something_unexpected",
                    message="Server said no",
                    details=None,
                ),
            ),
            self.assertRaisesRegex(APIException, r"Server said no"),
        ):
            worker_pool._terminate_existing_instances({"instance-id": 42})

    def test_cloud_init_config(self) -> None:
        token = self.playground.create_worker_activation_token()
        config = self.get_worker_pool()._cloud_init_config("worker", token)
        self.assertIsNotNone(config)

    @responses.activate
    def test_get_network_by_name(self) -> None:
        worker_pool = self.get_worker_pool()
        responses.add(
            responses.GET,
            "https://api.hetzner.cloud/v1/networks",
            match=[query_string_matcher("name=my-network")],
            json={
                "networks": [{"id": 7, "name": "my-network"}],
            },
        )
        network = worker_pool._get_network_by_name(name="my-network")
        self.assertEqual(network.id, 7)

    @responses.activate
    def test_get_network_by_name_not_found(self) -> None:
        worker_pool = self.get_worker_pool()
        responses.add(
            responses.GET,
            "https://api.hetzner.cloud/v1/networks",
            match=[query_string_matcher("name=my-network")],
            json={
                "networks": [],
            },
        )
        with self.assertRaisesRegex(
            InstanceLaunchException, r"Unable to locate network 'my-network'"
        ):
            worker_pool._get_network_by_name(name="my-network")

    @responses.activate
    def test_create_instance_complete(self) -> None:
        token = self.playground.create_worker_activation_token()
        worker_pool = self.get_worker_pool()
        worker_pool.specifications.image_name = "debian-13"
        worker_pool.specifications.ssh_keys = ["testkey"]
        worker_pool.specifications.location = "nbg1"
        worker_pool.specifications.networks = ["my-net"]
        worker_pool.specifications.labels = {"role": "debusine-worker"}
        worker_pool.specifications.enable_ipv4 = True
        worker_pool.specifications.enable_ipv6 = True
        response = responses.add(
            responses.POST,
            "https://api.hetzner.cloud/v1/servers",
            json={
                "action": {"id": 1},
                "server": {"id": 42},
                "next_actions": [],
                "root_password": "secret-password",
            },
        )
        with patch.object(
            worker_pool, "_get_network_by_name", return_value=Network(id=7)
        ):
            server_id = worker_pool._create_instance(
                worker_name="hetzner-001", activation_token=token
            )
        self.assertEqual(server_id, 42)

        assert response.calls[0].request.body
        body = json.loads(response.calls[0].request.body)
        self.assertEqual(body["name"], "hetzner-001")
        self.assertEqual(body["image"], worker_pool.specifications.image_name)
        self.assertEqual(body["ssh_keys"], worker_pool.specifications.ssh_keys)
        self.assertEqual(body["location"], worker_pool.specifications.location)
        self.assertEqual(body["networks"], [7])
        self.assertTrue(body["user_data"].startswith("#cloud-config"))
        self.assertEqual(body["labels"], worker_pool.specifications.labels)
        self.assertEqual(
            body["public_net"]["enable_ipv4"],
            worker_pool.specifications.enable_ipv4,
        )
        self.assertEqual(
            body["public_net"]["enable_ipv6"],
            worker_pool.specifications.enable_ipv6,
        )

    @responses.activate
    def test_create_instance_simple(self) -> None:
        token = self.playground.create_worker_activation_token()
        worker_pool = self.get_worker_pool()
        worker_pool.specifications.image_name = "debian-13"
        worker_pool.specifications.ssh_keys = []
        worker_pool.specifications.location = None
        worker_pool.specifications.networks = []
        worker_pool.specifications.labels = {}
        worker_pool.specifications.enable_ipv4 = False
        worker_pool.specifications.enable_ipv6 = False
        response = responses.add(
            responses.POST,
            "https://api.hetzner.cloud/v1/servers",
            json={
                "action": {"id": 1},
                "server": {"id": 42},
                "next_actions": [],
                "root_password": "secret-password",
            },
        )
        server_id = worker_pool._create_instance(
            worker_name="hetzner-001", activation_token=token
        )
        self.assertEqual(server_id, 42)

        assert response.calls[0].request.body
        body = json.loads(response.calls[0].request.body)
        self.assertEqual(body["name"], "hetzner-001")
        self.assertEqual(body["image"], worker_pool.specifications.image_name)
        self.assertEqual(body["ssh_keys"], worker_pool.specifications.ssh_keys)
        self.assertNotIn("location", body)
        self.assertEqual(body["networks"], [])
        self.assertTrue(body["user_data"].startswith("#cloud-config"))
        self.assertEqual(body["labels"], worker_pool.specifications.labels)
        self.assertEqual(
            body["public_net"]["enable_ipv4"],
            worker_pool.specifications.enable_ipv4,
        )
        self.assertEqual(
            body["public_net"]["enable_ipv6"],
            worker_pool.specifications.enable_ipv6,
        )

    def test_launch_worker(self) -> None:
        worker_pool = self.get_worker_pool()
        worker = self.playground.create_worker(
            worker_pool=worker_pool.worker_pool
        )
        worker.worker_pool_data = {"random-data": "foo"}
        worker.instance_created_at = None
        worker.save()

        with (
            patch.object(
                worker_pool, "_create_instance", return_value=42
            ) as create_instance,
            patch.object(
                worker_pool, "_terminate_existing_instances"
            ) as terminate_existing_instances,
        ):
            worker_pool.launch_worker(worker)

        create_instance.assert_called_once_with(
            worker_name=worker.name,
            activation_token=worker.activation_token,
        )
        terminate_existing_instances.assert_called_once_with(
            {"random-data": "foo"}
        )
        self.assertIsNotNone(worker.instance_created_at)
        self.assertEqual(worker.worker_pool_data, {"instance-id": 42})
        assert worker.activation_token
        self.assertTrue(worker.activation_token.enabled)

    def test_terminate_worker(self) -> None:
        worker_pool = self.get_worker_pool()
        worker = self.playground.create_worker(
            worker_pool=worker_pool.worker_pool
        )
        worker.worker_pool_data = {"instance-id": 42}
        worker.save()

        with patch.object(
            worker_pool, "_terminate_existing_instances"
        ) as terminate_existing_instances:
            worker_pool.terminate_worker(worker)

        terminate_existing_instances.assert_called_once_with(
            {"instance-id": 42}
        )
        self.assertIsNone(worker.instance_created_at)
        self.assertIsNone(worker.worker_pool_data)

    @responses.activate
    def test_terminate_missing(self) -> None:
        worker_pool = self.get_worker_pool()
        workers: dict[int, Worker] = {}
        for instance_id in (11, 12, 13):
            worker = self.playground.create_worker(
                worker_pool=worker_pool.worker_pool
            )
            worker.worker_pool_data = {"instance-id": instance_id}
            worker.save()
            workers[instance_id] = worker

        task = self.playground.create_worker_task(
            mark_running=True, worker=workers[13]
        )

        # Inactive pool member
        worker = self.playground.create_worker(
            worker_pool=worker_pool.worker_pool
        )
        worker.instance_created_at = None
        worker.save()

        responses.add(
            responses.GET,
            "https://api.hetzner.cloud/v1/servers",
            json={
                "servers": [
                    {
                        "id": 11,
                        "status": "running",
                    },
                    {
                        "id": 12,
                        "status": "off",
                    },
                ]
            },
        )
        with (
            patch.object(
                worker_pool, "_terminate_existing_instances"
            ) as terminate_existing_instances,
        ):
            worker_pool.terminate_missing()

        self.assertEqual(terminate_existing_instances.call_count, 2)
        terminate_existing_instances.assert_has_calls(
            [
                call({"instance-id": 12}),
                call({"instance-id": 13}),
            ],
            any_order=True,
        )
        task.refresh_from_db()
        self.assertIsNone(task.worker)

    @responses.activate
    def test_terminate_missing_empty(self) -> None:
        worker_pool = self.get_worker_pool()
        worker_pool.terminate_missing()
        self.assertEqual(len(responses.calls), 0)
