#!/usr/bin/env python3
import hashlib
import importlib.machinery
import importlib.util
import json
import os
import subprocess
import tarfile
import tempfile
import unittest
from pathlib import Path
from unittest import mock


SCRIPT_DIR = Path(__file__).resolve().parent
BUILDER = (
    SCRIPT_DIR
    / "build-device-plane-foundation-network-publication-artifact.mjs"
)
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
COMPOSE = (
    SCRIPT_DIR
    / "fixtures/device-plane-foundation-network-publication-v1.yml"
)
PREDECESSOR_COMPOSE = (
    SCRIPT_DIR
    / "fixtures/device-plane-foundation-internal-only-v1.yml"
)


def load_runner():
    loader = importlib.machinery.SourceFileLoader(
        "nodedc_device_plane_network_publication_runner_under_test",
        str(RUNNER_PATH),
    )
    spec = importlib.util.spec_from_loader(loader.name, loader)
    module = importlib.util.module_from_spec(spec)
    loader.exec_module(module)
    return module


RUNNER = load_runner()


class DevicePlaneFoundationNetworkPublicationArtifactTest(
    unittest.TestCase
):
    def build(self, artifact_dir, patch_id):
        environment = os.environ.copy()
        environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
        result = subprocess.run(
            ["node", str(BUILDER), patch_id],
            check=True,
            capture_output=True,
            text=True,
            env=environment,
        )
        return json.loads(result.stdout)

    def test_artifact_is_exact_deterministic_and_database_free(self):
        with tempfile.TemporaryDirectory(
            prefix="nodedc-device-plane-network-publication-",
        ) as directory:
            artifact_dir = Path(directory)
            patch_id = (
                "device-plane-foundation-network-publication-unit-003"
            )
            first = self.build(artifact_dir, patch_id)
            first_bytes = Path(first["artifact"]).read_bytes()
            second = self.build(artifact_dir, patch_id)
            second_bytes = Path(second["artifact"]).read_bytes()

            self.assertEqual(first_bytes, second_bytes)
            self.assertEqual(
                first["sha256"],
                hashlib.sha256(first_bytes).hexdigest(),
            )
            self.assertEqual(
                first["transition"],
                "failed-foundation-network-publication-correction",
            )
            self.assertEqual(first["build"], [])
            self.assertEqual(
                first["services"],
                ["device-control-core", "device-gateway"],
            )
            self.assertNotIn("device-postgres", first["services"])
            self.assertEqual(
                first["entries"],
                list(
                    RUNNER
                    .DEVICE_PLANE_FOUNDATION_NETWORK_PUBLICATION_ENTRIES
                ),
            )

            with tarfile.open(first["artifact"], "r:gz") as archive:
                files = (
                    archive.extractfile("files.txt")
                    .read()
                    .decode("utf-8")
                    .splitlines()
                )
                compose = (
                    archive.extractfile(
                        "payload/docker-compose.device-plane.yml"
                    )
                    .read()
                )
                descriptor = json.loads(
                    archive.extractfile(
                        "payload/deployment/"
                        "device-plane-foundation-network-publication-v1.json"
                    )
                    .read()
                    .decode("utf-8")
                )

            self.assertEqual(files, first["entries"])
            self.assertEqual(
                hashlib.sha256(compose).hexdigest(),
                RUNNER
                .DEVICE_PLANE_FOUNDATION_NETWORK_PUBLICATION_COMPOSE_SHA256,
            )
            self.assertEqual(
                descriptor,
                RUNNER
                .expected_device_plane_foundation_network_publication_descriptor(),
            )

    def test_compose_preserves_database_and_adds_control_network_only_to_stateless_services(
        self,
    ):
        compose = COMPOSE.read_text(encoding="utf-8")
        predecessor = PREDECESSOR_COMPOSE.read_text(encoding="utf-8")
        self.assertEqual(
            hashlib.sha256(PREDECESSOR_COMPOSE.read_bytes()).hexdigest(),
            RUNNER.DEVICE_PLANE_FOUNDATION_PREDECESSOR_COMPOSE_SHA256,
        )
        self.assertEqual(
            hashlib.sha256(COMPOSE.read_bytes()).hexdigest(),
            RUNNER
            .DEVICE_PLANE_FOUNDATION_NETWORK_PUBLICATION_COMPOSE_SHA256,
        )
        self.assertIn("name: nodedc-device-plane-control", compose)
        self.assertIn(
            'com.docker.network.bridge.enable_ip_masquerade: "false"',
            compose,
        )
        self.assertNotIn("nodedc-device-plane-control", predecessor)
        postgres_section, stateless = compose.split(
            "  device-control-core:",
            1,
        )
        self.assertNotIn("device-plane-control", postgres_section)
        self.assertEqual(stateless.count("      - device-plane-control"), 2)
        self.assertNotIn("9921:9921", compose)

    def test_runner_selects_exact_services_no_build_and_no_deps(self):
        entries = (
            RUNNER.DEVICE_PLANE_FOUNDATION_NETWORK_PUBLICATION_ENTRIES
        )
        services = RUNNER.component_services("device-plane", entries)
        self.assertEqual(
            services,
            ("device-control-core", "device-gateway"),
        )
        self.assertEqual(
            RUNNER.component_builds("device-plane", entries),
            (),
        )

        with (
            mock.patch.object(RUNNER, "run_build") as run_build,
            mock.patch.object(
                RUNNER,
                "prepare_component_runtime",
            ) as prepare,
            mock.patch.object(RUNNER, "run_compose") as run_compose,
        ):
            RUNNER.run_component_runtime(
                "device-plane",
                entries,
                services,
            )
        run_build.assert_not_called()
        prepare.assert_called_once_with("device-plane", entries)
        run_compose.assert_called_once_with(
            "device-plane",
            services,
            entries,
        )

        with mock.patch.object(RUNNER.subprocess, "run") as run:
            RUNNER.run_compose("device-plane", services, entries)
        command = run.call_args_list[0].args[0]
        self.assertIn("--force-recreate", command)
        self.assertIn("--no-deps", command)
        self.assertEqual(
            command[-2:],
            ["device-control-core", "device-gateway"],
        )

    def test_terminal_failed_recovery_is_rejected(self):
        with self.assertRaisesRegex(
            RUNNER.DeployError,
            "recovery 002 is terminal failed",
        ):
            RUNNER.reject_terminal_device_plane_foundation_artifact(
                {
                    "id":
                        RUNNER
                        .DEVICE_PLANE_FOUNDATION_RECOVERY_FAILED_PATCH_ID,
                },
                "0" * 64,
            )
        with self.assertRaisesRegex(
            RUNNER.DeployError,
            "recovery 002 is terminal failed",
        ):
            RUNNER.reject_terminal_device_plane_foundation_artifact(
                {"id": "unrelated"},
                RUNNER
                .DEVICE_PLANE_FOUNDATION_RECOVERY_FAILED_ARTIFACT_SHA256,
            )

    def test_rollback_is_stateless_and_removes_only_control_network(self):
        entries = (
            RUNNER.DEVICE_PLANE_FOUNDATION_NETWORK_PUBLICATION_ENTRIES
        )
        services = ("device-control-core", "device-gateway")
        runtime = {
            "device-control-core": {"containerId": "1" * 64},
            "device-gateway": {"containerId": "2" * 64},
            "device-postgres": {
                "containerId":
                    RUNNER
                    .DEVICE_PLANE_FOUNDATION_PREDECESSOR_CONTAINER_IDS[
                        "device-postgres"
                    ],
            },
        }
        with (
            mock.patch.object(
                RUNNER,
                "stop_and_remove_compose_services",
            ) as stop,
            mock.patch.object(
                RUNNER,
                "restore_platform_overlay",
                return_value=9,
            ) as restore,
            mock.patch.object(
                RUNNER,
                "remove_device_plane_control_network_if_unused",
                return_value="removed",
            ) as remove_network,
            mock.patch.object(RUNNER, "run_compose") as compose,
            mock.patch.object(
                RUNNER,
                "healthcheck_compose_service",
            ) as health,
            mock.patch.object(
                RUNNER,
                "validate_device_plane_foundation_runtime",
                return_value=runtime,
            ) as validate_runtime,
            mock.patch.object(
                RUNNER,
                "assert_loopback_tcp_port_closed",
            ) as closed,
        ):
            result = (
                RUNNER
                .rollback_device_plane_network_publication_apply(
                    Path("/live"),
                    Path("/backup"),
                    entries,
                    "stamp",
                    True,
                    services,
                )
            )

        self.assertEqual(result, "source+internal-runtime-restored:9")
        stop.assert_called_once_with("device-plane", services)
        restore.assert_called_once()
        remove_network.assert_called_once_with()
        compose.assert_called_once_with(
            "device-plane",
            services,
            ("docker-compose.device-plane.yml",),
        )
        self.assertEqual(health.call_count, 3)
        validate_runtime.assert_called_once_with()
        self.assertEqual(
            [call.args[0] for call in closed.call_args_list],
            [18120, 18121, 9921],
        )


if __name__ == "__main__":
    unittest.main()
