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


SCRIPT_DIR = Path(__file__).resolve().parent
PLATFORM_ROOT = SCRIPT_DIR.parent.parent
ENGINE_ROOT = PLATFORM_ROOT.parent / "NODEDC_ENGINE_INFRA"
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"


def load_runner():
    loader = importlib.machinery.SourceFileLoader(
        "nodedc_engine_node_intelligence_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()


def sha256(path):
    return hashlib.sha256(path.read_bytes()).hexdigest()


class EngineNodeIntelligenceTest(unittest.TestCase):
    def make_image_archive(self, root, runtime_env=None):
        runtime_env = list(runtime_env or ["PATH=/usr/local/bin"])
        config = {
            "architecture": "amd64",
            "os": "linux",
            "config": {
                "Labels": {
                    "org.opencontainers.image.revision": RUNNER.ENGINE_NODE_INTELLIGENCE_UPSTREAM_COMMIT,
                },
                "Entrypoint": ["/usr/local/bin/docker-entrypoint.sh"],
                "Cmd": ["node", "dist/mcp/index.js"],
                "Env": runtime_env,
            },
        }
        config_raw = json.dumps(config, separators=(",", ":")).encode()
        config_sha = hashlib.sha256(config_raw).hexdigest()
        layer_raw = b"nodedc-node-intelligence-test-layer\n" * 64
        layer_sha = hashlib.sha256(layer_raw).hexdigest()
        manifest = [{
            "Config": f"blobs/sha256/{config_sha}",
            "RepoTags": [RUNNER.ENGINE_NODE_INTELLIGENCE_IMAGE],
            "Layers": [f"blobs/sha256/{layer_sha}"],
        }]
        archive = root / "engine-node-intelligence.tar"
        with tarfile.open(archive, "w") as output:
            for name, value in (
                ("manifest.json", json.dumps(manifest, separators=(",", ":")).encode()),
                (f"blobs/sha256/{config_sha}", config_raw),
                (f"blobs/sha256/{layer_sha}", layer_raw),
            ):
                member = tarfile.TarInfo(name)
                member.size = len(value)
                member.mode = 0o644
                output.addfile(member, BytesIO(value))
        return archive, config_sha

    def make_activation_payload(self, root, runtime_env=None):
        payload = root / "payload"
        source = payload / RUNNER.ENGINE_NODE_INTELLIGENCE_SOURCE_REL
        service = payload / RUNNER.ENGINE_NODE_INTELLIGENCE_SERVICE_ROOT_REL
        gateway = payload / RUNNER.ENGINE_NODE_INTELLIGENCE_GATEWAY_REL
        source.mkdir(parents=True)
        service.joinpath("image").mkdir(parents=True)
        gateway.parent.mkdir(parents=True)
        for name in ("catalog.js", "upstreamMcpClient.js", "upstreamProjection.js"):
            shutil.copy2(
                ENGINE_ROOT / RUNNER.ENGINE_NODE_INTELLIGENCE_SOURCE_REL / name,
                source / name,
            )
        shutil.copy2(
            ENGINE_ROOT / RUNNER.ENGINE_NODE_INTELLIGENCE_GATEWAY_REL,
            gateway,
        )
        readme = service / "README.md"
        readme.write_text("# exact node intelligence test\n", encoding="utf-8")
        override = service / "docker-compose.immutable-runtime.yml"
        override.write_text(
            RUNNER.expected_engine_node_intelligence_compose_override(),
            encoding="utf-8",
        )
        archive, config_sha = self.make_image_archive(root, runtime_env)
        target_archive = payload / RUNNER.ENGINE_NODE_INTELLIGENCE_IMAGE_ARCHIVE_REL
        shutil.copy2(archive, target_archive)
        descriptor = {
            "schemaVersion": "nodedc.engine-node-intelligence-transition/v1",
            "action": "activate",
            "releaseId": RUNNER.ENGINE_NODE_INTELLIGENCE_RELEASE_ID,
            "expectedCurrent": "inactive",
            "upstream": {
                "package": "n8n-mcp",
                "version": "2.33.2",
                "commit": RUNNER.ENGINE_NODE_INTELLIGENCE_UPSTREAM_COMMIT,
            },
            "image": {
                "tag": RUNNER.ENGINE_NODE_INTELLIGENCE_IMAGE,
                "archiveRelativePath": RUNNER.ENGINE_NODE_INTELLIGENCE_IMAGE_ARCHIVE_REL,
                "archiveSha256": sha256(target_archive),
                "configSha256": config_sha,
                "architecture": "amd64",
                "os": "linux",
            },
            "source": {
                "gatewaySha256": sha256(gateway),
                "catalogSha256": sha256(source / "catalog.js"),
                "upstreamClientSha256": sha256(source / "upstreamMcpClient.js"),
                "upstreamProjectionSha256": sha256(source / "upstreamProjection.js"),
                "composeOverrideSha256": sha256(override),
                "readmeSha256": sha256(readme),
            },
            "predecessor": {
                "gatewaySha256": RUNNER.ENGINE_NODE_INTELLIGENCE_PREDECESSOR_GATEWAY_SHA256,
                "composeSha256": RUNNER.ENGINE_NODE_INTELLIGENCE_PREDECESSOR_COMPOSE_SHA256,
                "backendRuntime": "verified-derived-retry",
            },
        }
        (payload / RUNNER.ENGINE_NODE_INTELLIGENCE_DESCRIPTOR_REL).write_text(
            json.dumps(descriptor, indent=2) + "\n",
            encoding="utf-8",
        )
        return payload, descriptor

    def test_activation_payload_and_offline_image_are_exact(self):
        with tempfile.TemporaryDirectory() as directory:
            payload, descriptor = self.make_activation_payload(Path(directory))
            actual = RUNNER.validate_engine_node_intelligence_transition(
                payload,
                RUNNER.ENGINE_NODE_INTELLIGENCE_ARTIFACT_ENTRIES,
            )
            self.assertEqual(actual, descriptor)

    def test_gateway_accepts_registered_0_12_and_rejects_unknown_successors(self):
        with tempfile.TemporaryDirectory() as directory:
            payload, descriptor = self.make_activation_payload(Path(directory))
            gateway = payload / RUNNER.ENGINE_NODE_INTELLIGENCE_GATEWAY_REL
            gateway_text = gateway.read_text(encoding="utf-8")
            self.assertIn("const ENGINE_AGENT_MCP_VERSION = '0.12.0'", gateway_text)
            gateway.write_text(
                gateway_text.replace(
                    "const ENGINE_AGENT_MCP_VERSION = '0.12.0'",
                    "const ENGINE_AGENT_MCP_VERSION = '9.9.9'",
                ),
                encoding="utf-8",
            )
            descriptor["source"]["gatewaySha256"] = sha256(gateway)
            (
                payload / RUNNER.ENGINE_NODE_INTELLIGENCE_DESCRIPTOR_REL
            ).write_text(
                json.dumps(descriptor, indent=2) + "\n",
                encoding="utf-8",
            )
            with self.assertRaisesRegex(
                RUNNER.DeployError,
                "gateway MCP version is not registered",
            ):
                RUNNER.validate_engine_node_intelligence_transition(
                    payload,
                    RUNNER.ENGINE_NODE_INTELLIGENCE_ARTIFACT_ENTRIES,
                )

    def test_archive_tamper_and_embedded_authority_fail_closed(self):
        with tempfile.TemporaryDirectory() as directory:
            root = Path(directory)
            payload, descriptor = self.make_activation_payload(root)
            archive = payload / RUNNER.ENGINE_NODE_INTELLIGENCE_IMAGE_ARCHIVE_REL
            archive.write_bytes(archive.read_bytes() + b"tamper")
            with self.assertRaisesRegex(RUNNER.DeployError, "archive sha256 mismatch"):
                RUNNER.validate_engine_node_intelligence_transition(
                    payload,
                    RUNNER.ENGINE_NODE_INTELLIGENCE_ARTIFACT_ENTRIES,
                )

        with tempfile.TemporaryDirectory() as directory:
            payload, _descriptor = self.make_activation_payload(
                Path(directory),
                runtime_env=["AUTH_TOKEN=forbidden"],
            )
            with self.assertRaisesRegex(RUNNER.DeployError, "contains runtime authority"):
                RUNNER.validate_engine_node_intelligence_transition(
                    payload,
                    RUNNER.ENGINE_NODE_INTELLIGENCE_ARTIFACT_ENTRIES,
                )

    def test_payload_extra_file_and_wrong_entry_order_are_rejected(self):
        with tempfile.TemporaryDirectory() as directory:
            payload, _descriptor = self.make_activation_payload(Path(directory))
            service = payload / RUNNER.ENGINE_NODE_INTELLIGENCE_SERVICE_ROOT_REL
            (service / "unexpected.txt").write_text("no\n", encoding="utf-8")
            with self.assertRaisesRegex(RUNNER.DeployError, "service payload file set mismatch"):
                RUNNER.validate_engine_node_intelligence_transition(
                    payload,
                    RUNNER.ENGINE_NODE_INTELLIGENCE_ARTIFACT_ENTRIES,
                )
        with tempfile.TemporaryDirectory() as directory:
            payload, _descriptor = self.make_activation_payload(Path(directory))
            with self.assertRaisesRegex(RUNNER.DeployError, "files.txt exact set/order mismatch"):
                RUNNER.validate_engine_node_intelligence_transition(
                    payload,
                    tuple(reversed(RUNNER.ENGINE_NODE_INTELLIGENCE_ARTIFACT_ENTRIES)),
                )

    def test_predecessor_drift_is_rejected_before_apply(self):
        with tempfile.TemporaryDirectory() as payload_dir, tempfile.TemporaryDirectory() as live_dir:
            payload, _descriptor = self.make_activation_payload(Path(payload_dir))
            live = Path(live_dir)
            (live / "nodedc-source/server/routes").mkdir(parents=True)
            (live / "docker-compose.yml").write_bytes(
                (ENGINE_ROOT / "docker-compose.yml").read_bytes()
            )
            baseline = bytes.fromhex("00")
            gateway = live / RUNNER.ENGINE_NODE_INTELLIGENCE_GATEWAY_REL
            gateway.write_bytes(baseline)
            original_root = RUNNER.COMPONENTS["engine"]["payload_root"]
            RUNNER.COMPONENTS["engine"]["payload_root"] = live
            try:
                with mock.patch.object(
                    RUNNER,
                    "preflight_engine_credential_backend_runtime",
                    return_value={"mode": "verified-derived-retry"},
                ):
                    with self.assertRaisesRegex(RUNNER.DeployError, "gateway drift detected"):
                        RUNNER.preflight_engine_node_intelligence_predecessor(payload)
            finally:
                RUNNER.COMPONENTS["engine"]["payload_root"] = original_root

    def test_service_selection_and_compose_dispatch_are_narrow(self):
        self.assertEqual(
            RUNNER.component_services(
                "engine",
                RUNNER.ENGINE_NODE_INTELLIGENCE_ARTIFACT_ENTRIES,
            ),
            (RUNNER.ENGINE_NODE_INTELLIGENCE_SERVICE, "nodedc-backend"),
        )
        self.assertEqual(
            RUNNER.component_services(
                "engine",
                RUNNER.ENGINE_NODE_INTELLIGENCE_ROLLBACK_ENTRIES,
            ),
            ("nodedc-backend",),
        )
        with (
            mock.patch.object(RUNNER, "run_build") as build,
            mock.patch.object(RUNNER, "prepare_component_runtime") as prepare,
            mock.patch.object(RUNNER, "run_engine_node_intelligence_compose") as exact_compose,
            mock.patch.object(RUNNER, "run_compose") as legacy_compose,
        ):
            RUNNER.run_component_runtime(
                "engine",
                RUNNER.ENGINE_NODE_INTELLIGENCE_ARTIFACT_ENTRIES,
                (RUNNER.ENGINE_NODE_INTELLIGENCE_SERVICE, "nodedc-backend"),
            )
        build.assert_called_once()
        prepare.assert_called_once()
        exact_compose.assert_called_once()
        legacy_compose.assert_not_called()

    def test_exact_compose_never_recreates_dependencies(self):
        services = (RUNNER.ENGINE_NODE_INTELLIGENCE_SERVICE, "nodedc-backend")
        with (
            mock.patch.object(
                RUNNER,
                "compose_base_cmd",
                return_value=["docker", "compose", "-f", "exact.yml"],
            ),
            mock.patch.object(
                RUNNER,
                "component_compose_root",
                return_value=Path("/exact-engine-root"),
            ),
            mock.patch.object(RUNNER.subprocess, "run") as execute,
        ):
            RUNNER.run_engine_node_intelligence_compose(
                services,
                RUNNER.ENGINE_NODE_INTELLIGENCE_ARTIFACT_ENTRIES,
            )
        command = execute.call_args_list[0].args[0]
        self.assertIn("--pull", command)
        self.assertIn("never", command)
        self.assertIn("--no-deps", command)
        self.assertEqual(command[-2:], list(services))

    def test_backend_additive_mount_projects_into_legacy_guard(self):
        descriptor = {"action": "activate"}
        baseline_mounts = [
            {
                "Type": "bind",
                "Source": "/baseline/app",
                "Destination": "/app",
                "RW": True,
            },
        ]
        node_mount = {
            "Type": "bind",
            "Source": str(RUNNER.ENGINE_NODE_INTELLIGENCE_SECRET_FILE),
            "Destination": RUNNER.ENGINE_NODE_INTELLIGENCE_SECRET_CONTAINER_PATH,
            "RW": False,
        }
        container = {"Mounts": [*baseline_mounts, node_mount]}
        with (
            mock.patch.object(
                RUNNER,
                "current_engine_node_intelligence_descriptor",
                return_value=descriptor,
            ),
            mock.patch.object(
                RUNNER,
                "validate_installed_engine_node_intelligence_source",
            ) as source_guard,
            mock.patch.object(
                RUNNER,
                "validate_engine_backend_mounts_for_installed_runtime",
            ) as legacy_guard,
        ):
            RUNNER.validate_engine_backend_node_intelligence_mounts_for_installed_runtime(
                container,
                node_modules_read_only=True,
            )
        source_guard.assert_called_once_with(descriptor)
        projected = legacy_guard.call_args.args[0]
        self.assertEqual(projected["Mounts"], baseline_mounts)
        self.assertTrue(legacy_guard.call_args.args[1])

    def test_backend_additive_mount_rejects_source_or_mode_drift(self):
        descriptor = {"action": "activate"}
        for source, read_write in (("/wrong/source", False), (str(RUNNER.ENGINE_NODE_INTELLIGENCE_SECRET_FILE), True)):
            container = {
                "Mounts": [{
                    "Type": "bind",
                    "Source": source,
                    "Destination": RUNNER.ENGINE_NODE_INTELLIGENCE_SECRET_CONTAINER_PATH,
                    "RW": read_write,
                }],
            }
            with (
                mock.patch.object(
                    RUNNER,
                    "current_engine_node_intelligence_descriptor",
                    return_value=descriptor,
                ),
                mock.patch.object(
                    RUNNER,
                    "validate_installed_engine_node_intelligence_source",
                ),
            ):
                with self.assertRaisesRegex(
                    RUNNER.DeployError,
                    "node-intelligence mount barrier mismatch",
                ):
                    RUNNER.validate_engine_backend_node_intelligence_mounts_for_installed_runtime(
                        container,
                        node_modules_read_only=True,
                    )


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