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


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


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

LEGACY_CREDENTIAL_RUNTIME_CANON_FUNCTION_SHA256 = {
    "build_canonical_engine_backend_rootfs": "7f8fca6380632d717ee38a3d58b5bbc58d34b0e6c81e2764a759ddd63bfd07d0",
    "engine_backend_container_id": "08dc25b6fee37e8c0d942df91fc575d6766b99c306ad06c2c14afded8de3b375",
    "engine_backend_immutable_runtime_is_current": "9c6b9a73ac5004830b1f06f56bfc1f25336d11001a97a5de7a04388e10a20990",
    "engine_backend_node_modules_tree_sha256": "01749704e1624d247bdf41242aa71961f5e5bc092ffa906b69cdb911907b70c4",
    "engine_backend_rootfs_member_allowed": "62342e5891a1ea885f1ff02169a100f9ca94464eb7dfb62a7cbe67750ebcbb91",
    "engine_backend_rootfs_toolchain_link_is_omitted": "83772bc1a8aa654e5c1d0757d43b650ee978dc73aef7ae8fac567dbacc8c7c49",
    "engine_backend_tool_versions": "c21d7c11ee8c40ac5e3e5c6c24c441d1ce52c9dd2c22fc1d8f26e121138fa78a",
    "ensure_engine_backend_release_image": "f65a5f8573b911980f252dc3beed88cd6ee14e0f0c519c333ea90b78c8910953",
    "expected_engine_credential_backend_override": "b7c9db0059a4d262b8fe61dc0745b8ee1a4b75d56d227c39ba08115c7e414486",
    "inspect_engine_backend_derived_image": "2336ce8068bd321b6c8ac6e62b430dbcbf991201cb9b4178589294d1baf7e818",
    "preflight_engine_credential_sink_ready": "3f084ade6346644c86aa9540fbcf70b3a44e32c7add0876935f45177569aad6d",
    "prepare_engine_credential_backend_runtime": "0def464639767dcf85acd7978459b2f5c1ebf963424dd401d4c29aae7bb1aaa5",
    "quarantine_engine_backend_partial_runtime": "57e455f874c681815cf42bf529d0b8620ae8317e82551d784cbd6dae913603d2",
    "read_engine_backend_runtime_metadata": "7ee0ec2c72eb699db4b6a7d3d8d912f1b0c0c724c2a054eee223fba3ec0a1cb2",
    "run_compose": "8425528d4934f82e79ebf3551c4246505e4a3cdf3f450949d7618a4312fee169",
    "run_engine_backend_probe": "f0db7210b64cf3c80c1f0a00165f27dcab6962781a69dc2050d84a5373cedaa9",
    "touches_engine_credential_sink": "5d0e0d680d1a8508fb1ac21d8c8b3aa92466fe8b219d2ddc1fe5411f94fca912",
    "validate_engine_backend_activation_marker": "66c4d5d9b0b608c030c2c9c97e2e46e1f1634b7ba024dc107c15478091765483",
    "validate_engine_backend_dependencies": "ca1383dffd5a6397ecfeedcc8f88ab59e90a33d5089590f0249f789b05ed29e2",
    "validate_engine_backend_mounts": "5614267d1d0f734dd68ed7565292a53f34777b6dc3640137034f6db093ef7431",
    "validate_engine_backend_runtime_override_file": "eae1d32ab75cc59916dbd1d4cd73a072c156abf9014d8ef234fcd2333748139d",
    "validate_engine_credential_sink_slice": "87e6571baf47c30af120da32a327a88164415bf65b198e7190c28df7365bcfd5",
}


class CanonicalPlatformRegistryTest(unittest.TestCase):
    def test_legacy_credential_runtime_functions_match_last_successful_canon(self):
        actual = {
            name: hashlib.sha256(
                inspect.getsource(getattr(RUNNER, name)).encode("utf-8"),
            ).hexdigest()
            for name in LEGACY_CREDENTIAL_RUNTIME_CANON_FUNCTION_SHA256
        }
        self.assertEqual(actual, LEGACY_CREDENTIAL_RUNTIME_CANON_FUNCTION_SHA256)

    def test_publish_mount_extension_delegates_baseline_inventory_to_legacy_guard(self):
        baseline_mounts = [
            {
                "Type": "bind",
                "Source": "/baseline/app",
                "Destination": "/app",
                "RW": True,
            },
        ]
        publish_mounts = [
            {
                "Type": "bind",
                "Source": str(RUNNER.ENGINE_PUBLISH_GRANT_STATE_PATH),
                "Destination": RUNNER.ENGINE_PUBLISH_GRANT_CONTAINER_PATH,
                "RW": True,
            },
            {
                "Type": "bind",
                "Source": str(RUNNER.ENGINE_EDP_MANAGED_PROVISIONER_PRIVATE_KEY_FILE),
                "Destination": RUNNER.ENGINE_EDP_PRIVATE_KEY_CONTAINER_PATH,
                "RW": False,
            },
        ]
        container = {"Mounts": [*baseline_mounts, *publish_mounts]}
        with tempfile.TemporaryDirectory(prefix="nodedc-publish-mounts-") as directory:
            root = Path(directory)
            override = root / RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_OVERRIDE_REL
            override.parent.mkdir(parents=True)
            override.write_text(
                RUNNER.expected_engine_data_product_publish_grant_override(),
                encoding="utf-8",
            )
            original_root = RUNNER.COMPONENTS["engine"]["payload_root"]
            RUNNER.COMPONENTS["engine"]["payload_root"] = root
            try:
                with mock.patch.object(
                    RUNNER,
                    "validate_engine_backend_mounts",
                ) as legacy_guard:
                    RUNNER.validate_engine_backend_mounts_for_installed_runtime(
                        container,
                        True,
                    )
                legacy_guard.assert_called_once_with(
                    {"Mounts": baseline_mounts},
                    True,
                )

                wrong_source = {
                    "Mounts": [
                        *baseline_mounts,
                        {**publish_mounts[0], "Source": "/wrong"},
                        publish_mounts[1],
                    ],
                }
                with self.assertRaisesRegex(
                    RUNNER.DeployError,
                    "Publish mount barrier mismatch",
                ):
                    RUNNER.validate_engine_backend_mounts_for_installed_runtime(
                        wrong_source,
                        True,
                    )
            finally:
                RUNNER.COMPONENTS["engine"]["payload_root"] = original_root

    def test_publish_mount_extension_accepts_exact_complete_runtime_inventory(self):
        baseline = {
            "/app": (True, "/engine/source"),
            "/app/deploy/docker-compose.yml": (False, "/engine/docker-compose.yml"),
            "/seed-api": (False, "/engine/seed-api"),
            "/seed-data": (False, "/engine/seed-data"),
            "/app/node_modules": (False, str(RUNNER.ENGINE_CREDENTIAL_BACKEND_NODE_MODULES_DIR)),
            "/app/server/data": (True, "/engine/data"),
            "/app/server/storage": (True, "/engine/storage"),
            "/app/server/logs": (True, "/engine/logs"),
            "/var/run/docker.sock": (True, "/var/run/docker.sock"),
        }
        mounts = [
            {
                "Type": "bind",
                "Source": source,
                "Destination": destination,
                "RW": writable,
            }
            for destination, (writable, source) in baseline.items()
        ]
        mounts.extend((
            {
                "Type": "bind",
                "Source": str(RUNNER.ENGINE_PUBLISH_GRANT_STATE_PATH),
                "Destination": RUNNER.ENGINE_PUBLISH_GRANT_CONTAINER_PATH,
                "RW": True,
            },
            {
                "Type": "bind",
                "Source": str(RUNNER.ENGINE_EDP_MANAGED_PROVISIONER_PRIVATE_KEY_FILE),
                "Destination": RUNNER.ENGINE_EDP_PRIVATE_KEY_CONTAINER_PATH,
                "RW": False,
            },
        ))
        with tempfile.TemporaryDirectory(prefix="nodedc-publish-full-mounts-") as directory:
            root = Path(directory)
            override = root / RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_OVERRIDE_REL
            override.parent.mkdir(parents=True)
            override.write_text(
                RUNNER.expected_engine_data_product_publish_grant_override(),
                encoding="utf-8",
            )
            original_root = RUNNER.COMPONENTS["engine"]["payload_root"]
            RUNNER.COMPONENTS["engine"]["payload_root"] = root
            try:
                RUNNER.validate_engine_backend_mounts_for_installed_runtime(
                    {"Mounts": mounts},
                    True,
                )
            finally:
                RUNNER.COMPONENTS["engine"]["payload_root"] = original_root

    def test_absent_publish_overlay_uses_legacy_mount_guard_unchanged(self):
        container = {"Mounts": [{"Destination": "/app"}]}
        with tempfile.TemporaryDirectory(prefix="nodedc-no-publish-mounts-") as directory:
            original_root = RUNNER.COMPONENTS["engine"]["payload_root"]
            RUNNER.COMPONENTS["engine"]["payload_root"] = Path(directory)
            try:
                with mock.patch.object(
                    RUNNER,
                    "validate_engine_backend_mounts",
                ) as legacy_guard:
                    RUNNER.validate_engine_backend_mounts_for_installed_runtime(
                        container,
                        False,
                    )
                legacy_guard.assert_called_once_with(container, False)
            finally:
                RUNNER.COMPONENTS["engine"]["payload_root"] = original_root

    def test_runner_does_not_reinterpret_compose_schema(self):
        self.assertFalse(hasattr(RUNNER, "render_compose_model"))
        self.assertFalse(hasattr(RUNNER, "validate_platform_compose_model"))
        self.assertFalse(hasattr(RUNNER, "validate_engine_compose_model"))
        self.assertFalse(hasattr(RUNNER, "preflight_platform_compose_candidate"))
        self.assertFalse(hasattr(RUNNER, "preflight_engine_compose_candidate"))

    def test_platform_edp_change_recreates_only_application(self):
        entries = (
            "platform/docker-compose.external-data-plane.yml",
            "platform/services/external-data-plane",
        )
        services = RUNNER.component_services("platform", entries)
        self.assertEqual(services, ("external-data-plane",))
        self.assertNotIn("external-data-plane-postgres", services)

        builds = RUNNER.component_builds("platform", entries)
        self.assertEqual(len(builds), 1)
        build_root, build_args = builds[0]
        self.assertEqual(
            build_root,
            Path("/volume1/docker/nodedc-platform/platform"),
        )
        self.assertEqual(
            build_args,
            (
                "build",
                "--no-cache",
                "-f",
                "services/external-data-plane/Dockerfile",
                "-t",
                RUNNER.EXTERNAL_DATA_PLANE_IMAGE,
                ".",
            ),
        )

    def test_provider_catalog_is_metadata_only(self):
        entries = (
            "platform/packages/external-provider-contract/providers/gelios/v1/provider.json",
        )
        self.assertFalse(RUNNER.touches_external_data_plane_files(entries))
        self.assertEqual(RUNNER.component_services("platform", entries), ())
        self.assertEqual(RUNNER.component_builds("platform", entries), ())
        self.assertEqual(RUNNER.component_healthchecks("platform", entries), ())

        with (
            mock.patch.object(RUNNER, "run_build") as build,
            mock.patch.object(RUNNER, "prepare_component_runtime") as prepare,
            mock.patch.object(RUNNER, "run_compose") as compose,
            mock.patch.object(RUNNER, "healthcheck_url") as healthcheck,
        ):
            RUNNER.run_component_runtime("platform", entries, ())
            RUNNER.run_healthchecks("platform", entries, ())

        build.assert_not_called()
        prepare.assert_not_called()
        compose.assert_not_called()
        healthcheck.assert_not_called()

    def test_generic_engine_compose_change_keeps_legacy_service_selection(self):
        services = RUNNER.component_services(
            "engine",
            (
                "docker-compose.yml",
                "nodedc-source/server/index.js",
                "nodedc-source/server/dataProductPublishGrant/store.js",
            ),
        )
        self.assertEqual(services, ("nodedc-backend", "app"))
        self.assertNotIn("n8n-postgres", services)
        self.assertNotIn("postgresql", services)
        self.assertNotIn("n8n", services)

    def test_engine_runtime_prepares_managed_private_state(self):
        with (
            mock.patch.object(
                RUNNER,
                "ensure_engine_edp_managed_provisioner_keypair",
            ) as keypair,
            mock.patch.object(
                RUNNER,
                "ensure_engine_publish_grant_private_state",
            ) as private_state,
        ):
            RUNNER.prepare_component_runtime(
                "engine",
                RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_ARTIFACT_ENTRIES,
            )

        keypair.assert_called_once_with()
        private_state.assert_called_once_with()

    def test_module_foundry_runtime_prepares_separate_edp_identity_and_grant_roots(self):
        with (
            mock.patch.object(RUNNER, "ensure_map_gateway_admin_secret") as map_secret,
            mock.patch.object(RUNNER, "ensure_foundry_edp_managed_provisioner_keypair") as foundry_keypair,
            mock.patch.object(RUNNER, "ensure_root_owned_grant_directory") as grant_directory,
        ):
            RUNNER.prepare_component_runtime("module-foundry", ("server/catalog-server.mjs",))

        map_secret.assert_called_once_with()
        foundry_keypair.assert_called_once_with()
        self.assertEqual(
            grant_directory.call_args_list,
            [
                mock.call(
                    RUNNER.EXTERNAL_DATA_PLANE_READER_GRANTS_DIR,
                    "external data plane reader grants",
                ),
                mock.call(
                    RUNNER.FOUNDRY_BINDING_GRANTS_DIR,
                    "foundry binding grants",
                ),
            ],
        )

    def test_edp_runtime_prepares_engine_and_foundry_public_trust_independently(self):
        entries = ("platform/services/external-data-plane/src/server.mjs",)
        with (
            mock.patch.object(RUNNER, "ensure_external_data_plane_provisioner_secret") as legacy_secret,
            mock.patch.object(RUNNER, "ensure_engine_edp_managed_provisioner_keypair") as engine_keypair,
            mock.patch.object(RUNNER, "ensure_foundry_edp_managed_provisioner_keypair") as foundry_keypair,
            mock.patch.object(RUNNER, "ensure_root_owned_grant_directory") as grant_directory,
        ):
            RUNNER.prepare_component_runtime("platform", entries)

        legacy_secret.assert_called_once_with()
        engine_keypair.assert_called_once_with()
        foundry_keypair.assert_called_once_with()
        grant_directory.assert_called_once_with(
            RUNNER.EXTERNAL_DATA_PLANE_READER_GRANTS_DIR,
            "external data plane reader grants",
        )

    def test_partial_publish_path_has_no_publish_runtime_side_effects(self):
        entries = ("nodedc-source/server/dataProductPublishGrant/store.js",)
        self.assertTrue(RUNNER.touches_engine_data_product_publish_grant(entries))
        self.assertFalse(RUNNER.is_engine_data_product_publish_grant_slice(
            "engine",
            entries,
        ))
        with (
            mock.patch.object(
                RUNNER,
                "ensure_engine_edp_managed_provisioner_keypair",
            ) as keypair,
            mock.patch.object(
                RUNNER,
                "ensure_engine_publish_grant_private_state",
            ) as private_state,
        ):
            RUNNER.prepare_component_runtime("engine", entries)
        keypair.assert_not_called()
        private_state.assert_not_called()

    def test_publish_predecessor_guard_checks_every_pinned_file_before_mutation(self):
        with tempfile.TemporaryDirectory(prefix="nodedc-publish-predecessor-") as directory:
            root = Path(directory)
            files = {
                "docker-compose.yml": b"services: {}\n",
                "nodedc-source/server/routes/n8n.js": b"export const baseline = true\n",
            }
            expected = {}
            for relative_path, value in files.items():
                path = root / relative_path
                path.parent.mkdir(parents=True, exist_ok=True)
                path.write_bytes(value)
                expected[relative_path] = hashlib.sha256(value).hexdigest()

            original_root = RUNNER.COMPONENTS["engine"]["payload_root"]
            original_hashes = RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256
            original_installed_hashes = RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_INSTALLED_FOUNDATION_SHA256
            RUNNER.COMPONENTS["engine"]["payload_root"] = root
            RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256 = expected
            RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_INSTALLED_FOUNDATION_SHA256 = expected
            try:
                self.assertEqual(
                    RUNNER.preflight_engine_data_product_publish_grant_predecessor(),
                    {
                        "mode": "credential-sink-initial-transition",
                        "compose_sha256": expected["docker-compose.yml"],
                        "changed_paths": (),
                    },
                )
                (root / "nodedc-source/server/routes/n8n.js").write_bytes(b"drift\n")
                with self.assertRaisesRegex(
                    RUNNER.DeployError,
                    "path=nodedc-source/server/routes/n8n.js",
                ):
                    RUNNER.preflight_engine_data_product_publish_grant_predecessor()
            finally:
                RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256 = original_hashes
                RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_INSTALLED_FOUNDATION_SHA256 = original_installed_hashes
                RUNNER.COMPONENTS["engine"]["payload_root"] = original_root

    def test_installed_publish_update_preserves_foundation_and_allows_only_grant_source_changes(self):
        with tempfile.TemporaryDirectory(prefix="nodedc-publish-update-") as directory:
            base = Path(directory)
            root = base / "installed"
            payload = base / "payload"
            root.mkdir()
            payload.mkdir()

            compose_bytes = b"services: {}\n"
            compose = root / "docker-compose.yml"
            compose.write_bytes(compose_bytes)
            expected = {
                "docker-compose.yml": hashlib.sha256(compose_bytes).hexdigest(),
            }
            shared_files = {
                "nodedc-source/server/assets/provider-packages/v1/catalog.json": "{}\n",
                "nodedc-source/server/engineAgents/store.js": "export const profile = 'full-developer'\n",
                "nodedc-source/server/routes/engineAgentGateway.js": "\n".join((
                    "engine_plan_data_product_publish_grant",
                    "engine_apply_data_product_publish_grant",
                    "engine_accept_data_product_publish_grant",
                    "engine_rollback_data_product_publish_grant",
                )),
                "nodedc-source/server/routes/n8n.js": (
                    "engineCredentialSinkN8nAdapter\n"
                    "engineDataProductPublishGrantN8nAdapter\n"
                ),
                RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_OVERRIDE_REL: (
                    RUNNER.expected_engine_data_product_publish_grant_override()
                ),
            }
            for name in RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_SOURCE_FILES:
                shared_files[f"nodedc-source/server/dataProductPublishGrant/{name}"] = (
                    f"export const source = '{name}'\n"
                )
            for relative_path, value in shared_files.items():
                for destination in (root, payload):
                    path = destination / relative_path
                    path.parent.mkdir(parents=True, exist_ok=True)
                    path.write_text(value, encoding="utf-8")
            acceptance_rel = "nodedc-source/server/dataProductPublishGrant/acceptance.js"
            (payload / acceptance_rel).write_text(
                "export const source = 'acceptance-fixed'\n",
                encoding="utf-8",
            )

            original_root = RUNNER.COMPONENTS["engine"]["payload_root"]
            original_hashes = RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256
            original_installed_hashes = RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_INSTALLED_FOUNDATION_SHA256
            RUNNER.COMPONENTS["engine"]["payload_root"] = root
            RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256 = expected
            RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_INSTALLED_FOUNDATION_SHA256 = expected
            try:
                self.assertEqual(
                    RUNNER.preflight_engine_data_product_publish_grant_predecessor(payload),
                    {
                        "mode": "installed-source-update",
                        "compose_sha256": expected["docker-compose.yml"],
                        "changed_paths": (acceptance_rel,),
                    },
                )

                gateway_rel = "nodedc-source/server/routes/engineAgentGateway.js"
                (payload / gateway_rel).write_text("cross-boundary change\n", encoding="utf-8")
                with self.assertRaisesRegex(
                    RUNNER.DeployError,
                    "crosses its source boundary",
                ):
                    RUNNER.preflight_engine_data_product_publish_grant_predecessor(payload)

                (payload / gateway_rel).write_text(shared_files[gateway_rel], encoding="utf-8")
                compose.write_text("drift\n", encoding="utf-8")
                with self.assertRaisesRegex(
                    RUNNER.DeployError,
                    "foundation drift",
                ):
                    RUNNER.preflight_engine_data_product_publish_grant_predecessor(payload)
            finally:
                RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256 = original_hashes
                RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_INSTALLED_FOUNDATION_SHA256 = original_installed_hashes
                RUNNER.COMPONENTS["engine"]["payload_root"] = original_root

    def test_composite_provider_update_requires_exact_v3_to_v4_catalog_and_four_paths(self):
        engine_source = SCRIPT_DIR.parent.parent.parent / "NODEDC_ENGINE_INFRA"
        current_target_sha256 = {
            rel: hashlib.sha256((engine_source / rel).read_bytes()).hexdigest()
            for rel in RUNNER.ENGINE_COMPOSITE_PROVIDER_V4_ARTIFACT_ENTRIES
        }
        if current_target_sha256 != RUNNER.ENGINE_COMPOSITE_PROVIDER_V4_TARGET_SHA256:
            self.skipTest("historical v3-to-v4 fixture source has advanced to its exact successor")
        with tempfile.TemporaryDirectory(prefix="nodedc-composite-provider-update-") as directory:
            base = Path(directory)
            root = base / "installed"
            payload = base / "payload"
            root.mkdir()
            payload.mkdir()

            v3_catalog = {
                "schemaVersion": "nodedc.engine.provider-security-catalog/v1",
                "packages": [{
                    "id": "gelios.provider.v3",
                    "version": "3.0.0",
                    "providerId": "gelios",
                    "providerCredential": {
                        "authModeId": "gelios.rest-rotating-bearer.v3",
                        "credentialType": "httpBearerAuth",
                    },
                    "capabilities": [{
                        "id": "gelios.units.current.read",
                        "classification": "read",
                        "status": "implemented",
                        "request": {
                            "method": "GET",
                            "url": "https://api.geliospro.com/api/v1/units",
                        },
                        "dataProductIds": ["fleet.positions.current.v2"],
                    }],
                    "publisher": {
                        "nodeType": "n8n-nodes-ndc.ndcDataProductPublish",
                        "credentialType": "ndcDataProductWriterApi",
                    },
                }],
            }
            installed_catalog = f"{json.dumps(v3_catalog, indent=2)}\n"
            self.assertEqual(
                hashlib.sha256(installed_catalog.encode()).hexdigest(),
                RUNNER.ENGINE_PUBLISH_GRANT_COMPOSITE_PROVIDER_CATALOG_PREDECESSOR_SHA256,
            )

            for rel in RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_ARTIFACT_ENTRIES:
                if rel == "nodedc-source/server/dataProductPublishGrant":
                    continue
                for destination in (root, payload):
                    path = destination / rel
                    path.parent.mkdir(parents=True, exist_ok=True)
                    path.write_text(f"shared:{rel}\n", encoding="utf-8")
            for name in RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_SOURCE_FILES:
                rel = f"nodedc-source/server/dataProductPublishGrant/{name}"
                for destination in (root, payload):
                    path = destination / rel
                    path.parent.mkdir(parents=True, exist_ok=True)
                    path.write_text(f"installed:{name}\n", encoding="utf-8")

            catalog_rel = RUNNER.ENGINE_PROVIDER_SECURITY_CATALOG_REL
            (root / catalog_rel).write_text(installed_catalog, encoding="utf-8")
            for rel in RUNNER.ENGINE_PUBLISH_GRANT_COMPOSITE_PROVIDER_CHANGED_PATHS:
                source = engine_source / rel
                (payload / rel).write_bytes(source.read_bytes())

            original_root = RUNNER.COMPONENTS["engine"]["payload_root"]
            RUNNER.COMPONENTS["engine"]["payload_root"] = root
            try:
                with mock.patch.object(
                    RUNNER,
                    "validate_installed_engine_data_product_publish_grant_foundation",
                    return_value="compose-sha256",
                ):
                    self.assertEqual(
                        RUNNER.preflight_engine_data_product_publish_grant_predecessor(payload),
                        {
                            "mode": "installed-composite-provider-update",
                            "compose_sha256": "compose-sha256",
                            "changed_paths": RUNNER.ENGINE_PUBLISH_GRANT_COMPOSITE_PROVIDER_CHANGED_PATHS,
                        },
                    )
                    (root / catalog_rel).write_text("{}\n", encoding="utf-8")
                    with self.assertRaisesRegex(
                        RUNNER.DeployError,
                        "composite provider catalog transition mismatch",
                    ):
                        RUNNER.preflight_engine_data_product_publish_grant_predecessor(payload)
            finally:
                RUNNER.COMPONENTS["engine"]["payload_root"] = original_root

    def test_edp_healthcheck_requires_database_and_managed_provisioning(self):
        checks = RUNNER.component_healthchecks(
            "platform",
            ("platform/services/external-data-plane/src/server.mjs",),
            ("external-data-plane",),
        )
        self.assertEqual(
            checks,
            (RUNNER.external_data_plane_healthcheck(),),
        )

    def test_edp_healthcheck_requires_foundry_provisioner_for_the_new_source(self):
        with tempfile.TemporaryDirectory(prefix="nodedc-edp-foundry-health-") as directory:
            root = Path(directory)
            source = root / "platform/services/external-data-plane/src/server.mjs"
            source.parent.mkdir(parents=True)
            source.write_text(
                'foundryReaderBindingProvisioning: config.foundryProvisionerApiEnabled ? "digest+server-resolved-source" : "disabled"\n',
                encoding="utf-8",
            )
            original_root = RUNNER.COMPONENTS["platform"]["payload_root"]
            RUNNER.COMPONENTS["platform"]["payload_root"] = root
            try:
                expected = RUNNER.external_data_plane_healthcheck()["expected_json"]
            finally:
                RUNNER.COMPONENTS["platform"]["payload_root"] = original_root

        self.assertEqual(
            expected["foundryReaderBindingProvisioning"],
            "digest+server-resolved-source",
        )
        self.assertEqual(expected["foundryReaderBindingLifetime"], "explicit-revoke")

    def test_module_foundry_healthcheck_tracks_candidate_and_rollback_source(self):
        with tempfile.TemporaryDirectory(prefix="nodedc-foundry-health-") as directory:
            root = Path(directory)
            source = root / "server/catalog-server.mjs"
            source.parent.mkdir(parents=True)
            original_root = RUNNER.COMPONENTS["module-foundry"]["payload_root"]
            RUNNER.COMPONENTS["module-foundry"]["payload_root"] = root
            try:
                source.write_text("const dataProductConsumerProvisioner = true;\n", encoding="utf-8")
                candidate = RUNNER.module_foundry_healthcheck()["expected_json"]
                source.write_text("const previous = true;\n", encoding="utf-8")
                rollback = RUNNER.module_foundry_healthcheck()["expected_json"]
            finally:
                RUNNER.COMPONENTS["module-foundry"]["payload_root"] = original_root

        self.assertEqual(candidate["dataProductConsumerProvisioner"]["configured"], True)
        self.assertNotIn("dataProductConsumerProvisioner", rollback)

    def test_edp_runtime_never_probes_unselected_platform_services(self):
        entries = ("platform/services/external-data-plane/src/server.mjs",)
        services = ("external-data-plane",)
        with mock.patch.object(RUNNER, "healthcheck_url") as healthcheck:
            RUNNER.run_healthchecks("platform", entries, services)

        healthcheck.assert_called_once_with(
            RUNNER.external_data_plane_healthcheck(),
        )

    def test_json_healthcheck_accepts_expected_contract(self):
        class Response:
            status = 200

            def __enter__(self):
                return self

            def __exit__(self, *_args):
                return False

            def read(self, _limit):
                return json.dumps({
                    "ok": True,
                    "database": "ready",
                }).encode("utf-8")

        with mock.patch.object(
            RUNNER.NO_REDIRECT_OPENER,
            "open",
            return_value=Response(),
        ):
            RUNNER.healthcheck_url({
                "url": "http://127.0.0.1:18106/healthz",
                "expected_json": {
                    "ok": True,
                    "database": "ready",
                },
            })

    def test_json_healthcheck_accepts_restored_edp_baseline_contract(self):
        class Response:
            status = 200

            def __enter__(self):
                return self

            def __exit__(self, *_args):
                return False

            def read(self, _limit):
                return json.dumps({
                    "ok": True,
                    "service": "nodedc-external-data-plane",
                    "database": "ready",
                }).encode("utf-8")

        with mock.patch.object(
            RUNNER.NO_REDIRECT_OPENER,
            "open",
            return_value=Response(),
        ):
            RUNNER.healthcheck_url(
                RUNNER.external_data_plane_healthcheck(require_managed=False),
            )

    def test_json_healthcheck_does_not_accept_redirect(self):
        class Response:
            status = 200

            def __enter__(self):
                return self

            def __exit__(self, *_args):
                return False

            def read(self, _limit):
                return json.dumps({
                    "ok": True,
                    "database": "ready",
                }).encode("utf-8")

        url = "http://127.0.0.1:18106/healthz"
        redirect = RUNNER.urllib.error.HTTPError(
            url,
            302,
            "Found",
            {},
            None,
        )
        with (
            mock.patch.object(
                RUNNER.NO_REDIRECT_OPENER,
                "open",
                side_effect=(redirect, Response()),
            ) as opener,
            mock.patch.object(RUNNER.time, "sleep"),
        ):
            RUNNER.healthcheck_url({
                "url": url,
                "expected_json": {
                    "ok": True,
                    "database": "ready",
                },
            })

        self.assertEqual(opener.call_count, 2)

    def test_publish_grant_healthcheck_does_not_enter_n8n_transition(self):
        services = ("nodedc-backend",)
        with (
            mock.patch.object(RUNNER, "accept_engine_n8n_runtime") as accept,
            mock.patch.object(RUNNER, "healthcheck_compose_service") as compose_health,
            mock.patch.object(RUNNER, "healthcheck_url") as healthcheck,
            mock.patch.object(
                RUNNER,
                "preflight_engine_credential_backend_runtime",
                return_value={"mode": "verified-derived-retry"},
            ) as backend_preflight,
        ):
            RUNNER.run_healthchecks(
                "engine",
                RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_ARTIFACT_ENTRIES,
                services,
            )

        accept.assert_not_called()
        compose_health.assert_called_once_with("engine", "nodedc-backend")
        healthcheck.assert_called_once_with(
            "http://127.0.0.1:3001/health",
        )
        backend_preflight.assert_called_once_with()

    def test_engine_ui_healthcheck_requires_selected_app(self):
        grant_entries = RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_ARTIFACT_ENTRIES
        self.assertEqual(
            RUNNER.component_healthchecks(
                "engine",
                grant_entries,
                ("nodedc-backend",),
            ),
            ("http://127.0.0.1:3001/health",),
        )
        self.assertEqual(
            RUNNER.component_healthchecks(
                "engine",
                ("docker-compose.yml",),
                ("nodedc-backend", "app"),
            ),
            (
                "http://127.0.0.1:8080/",
                "http://127.0.0.1:3001/health",
            ),
        )

    def test_optional_edp_overlay_is_omitted_until_installed(self):
        with tempfile.TemporaryDirectory(
            prefix="nodedc-compose-files-",
        ) as directory:
            temporary = Path(directory)
            base = temporary / "docker-compose.platform-http.yml"
            edp = temporary / "docker-compose.external-data-plane.yml"
            base.write_text(
                "name: nodedc-platform\n",
                encoding="utf-8",
            )
            original = RUNNER.COMPONENTS["platform"]["compose_files"]
            RUNNER.COMPONENTS["platform"]["compose_files"] = (base, edp)
            try:
                self.assertEqual(
                    RUNNER.component_compose_files("platform"),
                    (base,),
                )
            finally:
                RUNNER.COMPONENTS["platform"]["compose_files"] = original


class PlatformOverlayRollbackTest(unittest.TestCase):
    def test_engine_runtime_failure_restores_source_and_recreates_exact_services(self):
        with tempfile.TemporaryDirectory(prefix="nodedc-engine-runtime-rollback-") as directory:
            temporary = Path(directory)
            root = temporary / "engine"
            backup = temporary / "backup"
            runner_tmp = temporary / "tmp"
            compose = root / "docker-compose.yml"
            backend = root / "nodedc-source/server/index.js"
            compose.parent.mkdir(parents=True)
            backend.parent.mkdir(parents=True)
            backup.mkdir()
            runner_tmp.mkdir()
            compose.write_text("name: baseline\n", encoding="utf-8")
            backend.write_text("export const version = 1;\n", encoding="utf-8")
            entries = ["docker-compose.yml", "nodedc-source/server/index.js"]
            services = ("n8n", "nodedc-backend", "app")
            RUNNER.create_backup(root, backup, entries, include_nginx_html=False)
            compose.write_text("name: candidate\n", encoding="utf-8")
            backend.write_text("export const version = 2;\n", encoding="utf-8")

            def assert_restored(component, rollback_entries, rollback_services):
                self.assertEqual(component, "engine")
                self.assertEqual(rollback_entries, entries)
                self.assertEqual(rollback_services, services)
                self.assertEqual(compose.read_text(encoding="utf-8"), "name: baseline\n")
                self.assertEqual(
                    backend.read_text(encoding="utf-8"),
                    "export const version = 1;\n",
                )

            with (
                mock.patch.object(RUNNER, "TMP_DIR", runner_tmp),
                mock.patch.object(
                    RUNNER,
                    "run_component_runtime",
                    side_effect=assert_restored,
                ) as runtime,
                mock.patch.object(RUNNER, "run_healthchecks") as healthchecks,
            ):
                status = RUNNER.rollback_engine_apply(
                    root,
                    backup,
                    entries,
                    "test-stamp",
                    runtime_started=True,
                    applied_services=services,
                )

            self.assertEqual(status, "source+runtime-restored:2")
            runtime.assert_called_once()
            healthchecks.assert_called_once_with("engine", entries, services)

    def test_candidate_only_service_cleanup_never_requests_volume_removal(self):
        with (
            mock.patch.object(RUNNER, "component_compose_root", return_value=Path("/live/platform")),
            mock.patch.object(RUNNER, "compose_base_cmd", return_value=["docker", "compose"]),
            mock.patch.object(RUNNER.subprocess, "run") as run,
        ):
            RUNNER.stop_and_remove_compose_services("platform", ("external-data-plane",))

        command = run.call_args.args[0]
        self.assertEqual(
            command,
            ["docker", "compose", "rm", "--stop", "--force", "external-data-plane"],
        )
        self.assertNotIn("--volumes", command)

    def test_failed_first_edp_activation_removes_containers_without_deleting_volume(self):
        with tempfile.TemporaryDirectory(prefix="nodedc-platform-edp-rollback-") as directory:
            temporary = Path(directory)
            root = temporary / "root"
            backup = temporary / "backup"
            runner_tmp = temporary / "tmp"
            overlay = root / RUNNER.PLATFORM_EXTERNAL_DATA_PLANE_COMPOSE_REL
            overlay.parent.mkdir(parents=True)
            backup.mkdir()
            runner_tmp.mkdir()
            entries = [RUNNER.PLATFORM_EXTERNAL_DATA_PLANE_COMPOSE_REL]
            RUNNER.create_backup(root, backup, entries, include_nginx_html=False)
            overlay.write_text("name: nodedc-platform\n", encoding="utf-8")

            with (
                mock.patch.object(RUNNER, "TMP_DIR", runner_tmp),
                mock.patch.object(RUNNER, "stop_and_remove_compose_services") as remove,
                mock.patch.object(RUNNER, "run_component_runtime") as runtime,
                mock.patch.object(RUNNER, "run_healthchecks") as healthchecks,
            ):
                status = RUNNER.rollback_platform_apply(
                    root,
                    backup,
                    entries,
                    "test-stamp",
                    runtime_started=True,
                    applied_services=("external-data-plane",),
                )

            remove.assert_called_once_with(
                "platform",
                ("external-data-plane",),
            )
            runtime.assert_not_called()
            healthchecks.assert_not_called()
            self.assertFalse(overlay.exists())
            self.assertEqual(status, "source+runtime-restored:1")

    def test_candidate_cleanup_failure_still_restores_source_before_reporting_failure(self):
        with tempfile.TemporaryDirectory(prefix="nodedc-platform-edp-rollback-") as directory:
            temporary = Path(directory)
            root = temporary / "root"
            backup = temporary / "backup"
            runner_tmp = temporary / "tmp"
            overlay = root / RUNNER.PLATFORM_EXTERNAL_DATA_PLANE_COMPOSE_REL
            overlay.parent.mkdir(parents=True)
            backup.mkdir()
            runner_tmp.mkdir()
            entries = [RUNNER.PLATFORM_EXTERNAL_DATA_PLANE_COMPOSE_REL]
            RUNNER.create_backup(root, backup, entries, include_nginx_html=False)
            overlay.write_text("name: rejected\n", encoding="utf-8")

            with (
                mock.patch.object(RUNNER, "TMP_DIR", runner_tmp),
                mock.patch.object(
                    RUNNER,
                    "stop_and_remove_compose_services",
                    side_effect=RuntimeError("simulated cleanup failure"),
                ),
            ):
                with self.assertRaisesRegex(RUNNER.DeployError, "cleanup failed after source restore"):
                    RUNNER.rollback_platform_apply(
                        root,
                        backup,
                        entries,
                        "test-stamp",
                        runtime_started=True,
                        applied_services=("external-data-plane",),
                    )
            self.assertFalse(overlay.exists())

    def test_runtime_failure_rebuilds_recreates_and_accepts_restored_overlay(self):
        with tempfile.TemporaryDirectory(prefix="nodedc-platform-runtime-rollback-") as directory:
            temporary = Path(directory)
            root = temporary / "root"
            backup = temporary / "backup"
            runner_tmp = temporary / "tmp"
            gateway = root / "platform/gelios-gateway"
            gateway.mkdir(parents=True)
            backup.mkdir()
            runner_tmp.mkdir()
            source = gateway / "server.mjs"
            source.write_text("export const version = 1;\n", encoding="utf-8")
            entries = ["platform/gelios-gateway"]
            RUNNER.create_backup(root, backup, entries, include_nginx_html=False)
            source.write_text("export const version = 2;\n", encoding="utf-8")

            def assert_restored_runtime(component, rollback_entries, services):
                self.assertEqual(component, "platform")
                self.assertEqual(rollback_entries, entries)
                self.assertEqual(services, ("gelios-postgres", "gelios-gateway"))
                self.assertEqual(source.read_text(encoding="utf-8"), "export const version = 1;\n")

            with (
                mock.patch.object(RUNNER, "TMP_DIR", runner_tmp),
                mock.patch.object(
                    RUNNER,
                    "run_component_runtime",
                    side_effect=assert_restored_runtime,
                ) as runtime,
                mock.patch.object(RUNNER, "run_healthchecks") as healthchecks,
            ):
                status = RUNNER.rollback_platform_apply(
                    root,
                    backup,
                    entries,
                    "test-stamp",
                    runtime_started=True,
                    applied_services=("gelios-postgres", "gelios-gateway"),
                )

            self.assertEqual(status, "source+runtime-restored:1")
            runtime.assert_called_once()
            healthchecks.assert_called_once_with(
                "platform",
                entries,
                ("gelios-postgres", "gelios-gateway"),
            )

    def test_existing_edp_rollback_accepts_only_restored_edp(self):
        with tempfile.TemporaryDirectory(prefix="nodedc-platform-edp-runtime-rollback-") as directory:
            temporary = Path(directory)
            root = temporary / "root"
            backup = temporary / "backup"
            runner_tmp = temporary / "tmp"
            service = root / "platform/services/external-data-plane"
            service.mkdir(parents=True)
            backup.mkdir()
            runner_tmp.mkdir()
            source = service / "server.mjs"
            source.write_text("export const version = 1;\n", encoding="utf-8")
            entries = ["platform/services/external-data-plane"]
            RUNNER.create_backup(root, backup, entries, include_nginx_html=False)
            source.write_text("export const version = 2;\n", encoding="utf-8")

            with (
                mock.patch.object(RUNNER, "TMP_DIR", runner_tmp),
                mock.patch.object(RUNNER, "run_component_runtime") as runtime,
                mock.patch.object(RUNNER, "healthcheck_url") as healthcheck,
            ):
                status = RUNNER.rollback_platform_apply(
                    root,
                    backup,
                    entries,
                    "test-stamp",
                    runtime_started=True,
                    applied_services=("external-data-plane",),
                )

            self.assertEqual(status, "source+runtime-restored:1")
            self.assertEqual(
                source.read_text(encoding="utf-8"),
                "export const version = 1;\n",
            )
            runtime.assert_called_once_with(
                "platform",
                entries,
                ("external-data-plane",),
            )
            healthcheck.assert_called_once_with(
                RUNNER.external_data_plane_healthcheck(require_managed=False),
            )

    def test_restores_existing_overlay_and_removes_new_paths(self):
        with tempfile.TemporaryDirectory(prefix="nodedc-platform-rollback-test-") as directory:
            temporary = Path(directory)
            root = temporary / "root"
            backup = temporary / "backup"
            runner_tmp = temporary / "tmp"
            root.mkdir()
            backup.mkdir()
            runner_tmp.mkdir()

            compose = root / "platform/docker-compose.platform-http.yml"
            gateway = root / "platform/gelios-gateway"
            new_service = root / "platform/services/new-service"
            unrelated = root / "platform/unrelated.txt"
            compose.parent.mkdir(parents=True)
            gateway.mkdir()
            compose.write_text("name: nodedc-platform\n", encoding="utf-8")
            executable = gateway / "run.sh"
            executable.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
            executable.chmod(0o755)
            (gateway / "server.mjs").write_text("export const version = 1;\n", encoding="utf-8")
            unrelated.write_text("untouched\n", encoding="utf-8")

            entries = [
                "platform/docker-compose.platform-http.yml",
                "platform/gelios-gateway",
                "platform/services/new-service",
            ]
            RUNNER.create_backup(root, backup, entries, include_nginx_html=False)

            compose.write_text("name: broken\n", encoding="utf-8")
            (gateway / "server.mjs").write_text("export const version = 2;\n", encoding="utf-8")
            executable.chmod(0o644)
            new_service.mkdir(parents=True)
            (new_service / "bad.mjs").write_text("broken\n", encoding="utf-8")

            with mock.patch.object(RUNNER, "TMP_DIR", runner_tmp):
                restored = RUNNER.restore_platform_overlay(root, backup, entries, "test-stamp")

            self.assertEqual(restored, len(entries))
            self.assertEqual(compose.read_text(encoding="utf-8"), "name: nodedc-platform\n")
            self.assertEqual(
                (gateway / "server.mjs").read_text(encoding="utf-8"),
                "export const version = 1;\n",
            )
            self.assertTrue(stat.S_IMODE((gateway / "run.sh").stat().st_mode) & stat.S_IXUSR)
            self.assertFalse(new_service.exists())
            self.assertEqual(unrelated.read_text(encoding="utf-8"), "untouched\n")

    def test_rejects_inconsistent_backup_partition_before_restore(self):
        with tempfile.TemporaryDirectory(prefix="nodedc-platform-rollback-test-") as directory:
            temporary = Path(directory)
            root = temporary / "root"
            backup = temporary / "backup"
            runner_tmp = temporary / "tmp"
            target = root / "platform/docker-compose.platform-http.yml"
            target.parent.mkdir(parents=True)
            target.write_text("original\n", encoding="utf-8")
            backup.mkdir()
            runner_tmp.mkdir()
            entries = ["platform/docker-compose.platform-http.yml"]
            RUNNER.create_backup(root, backup, entries, include_nginx_html=False)
            (backup / "missing-files.txt").write_text(
                "platform/docker-compose.platform-http.yml\n",
                encoding="utf-8",
            )
            target.write_text("candidate\n", encoding="utf-8")

            with mock.patch.object(RUNNER, "TMP_DIR", runner_tmp):
                with self.assertRaisesRegex(RUNNER.DeployError, "backup entry set mismatch"):
                    RUNNER.restore_platform_overlay(root, backup, entries, "test-stamp")
            self.assertEqual(target.read_text(encoding="utf-8"), "candidate\n")


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