# Copyright 2026 Cisco Systems, Inc. and its affiliates
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0

"""End-to-end install + activate test for policies generated by the
docs-site policy creator.

This test simulates the operator workflow:

  1. The policy creator emits a YAML document covering the full
     wizard surface — basics, skill_actions, guardrail,
     scanner_overrides, firewall, cisco_ai_defense, webhooks,
     first-party allow list, watch/audit/enforcement.
  2. The operator drops the YAML into ``~/.defenseclaw/policies/``.
  3. The operator runs ``defenseclaw policy activate <name>``.
  4. ``app.cfg`` is mutated in-place AND persisted to config.yaml.
  5. ``policies/rego/data.json`` is rewritten with the
     overlapping fields.

The fields we exercise here are the ones that were silently dropped
prior to the C1 parity test (cisco_ai_defense + webhooks) plus the
existing surface (skill_actions, guardrail, firewall, watch). If any
of those drop silently again, this test fails.
"""

from __future__ import annotations

import json
import os
import sys
import unittest

import yaml

sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))

from click.testing import CliRunner

from defenseclaw.commands.cmd_policy import policy
from tests.helpers import make_app_context, cleanup_app


# A reasonably "rich" policy document — one we'd expect the docs-site
# policy creator to produce for an operator who toggled multiple panels
# and downloaded the install script. We keep it inline (rather than
# importing from a fixture file) so the structure is reviewable in one
# place; the integration value is in the activate path, not the file
# format.
WIZARD_EMIT_FIXTURE: dict = {
    "name": "wizard-end-to-end",
    "description": "Generated by docs-site policy creator end-to-end test",
    "admission": {
        "scan_on_install": True,
        "allow_list_bypass_scan": False,
    },
    "skill_actions": {
        "critical": {"runtime": "disable", "file": "quarantine", "install": "block"},
        "high":     {"runtime": "disable", "file": "quarantine", "install": "block"},
        "medium":   {"runtime": "enable",  "file": "none",       "install": "none"},
        "low":      {"runtime": "enable",  "file": "none",       "install": "none"},
        "info":     {"runtime": "enable",  "file": "none",       "install": "none"},
    },
    "scanner_overrides": {
        # The wizard emits scanner_overrides using the same
        # ``enable|disable`` runtime convention as skill_actions (the
        # activator maps ``disable`` → OPA ``block``). Mirroring that
        # convention here keeps the fixture honest about what an
        # operator would actually paste from docs-site.
        "mcp": {
            "MEDIUM": {"runtime": "disable", "file": "quarantine", "install": "block"},
        }
    },
    "first_party_allow_list": [
        {
            "target_type": "plugin",
            "target_name": "defenseclaw",
            "reason": "first-party DefenseClaw plugin",
            "source_path_contains": [".defenseclaw"],
        },
    ],
    "guardrail": {
        "block_threshold": 4,
        "alert_threshold": 2,
        "cisco_trust_level": "advisory",
        "hilt": {"enabled": True, "min_severity": "MEDIUM"},
        "patterns": {"secrets": ["sk-", "bearer ", "token:"]},
        "severity_mappings": {"PII-EMAIL": "MEDIUM"},
    },
    "firewall": {
        "default_action": "deny",
        "blocked_destinations": ["c2.evil.example"],
        "allowed_domains": ["api.openai.com"],
        "allowed_ports": [443, 80],
    },
    "watch": {"rescan_enabled": True, "rescan_interval_min": 30},
    "enforcement": {"max_enforcement_delay_seconds": 2},
    "audit": {"log_all_actions": True, "log_scan_results": True, "retention_days": 90},
    # NEW SURFACES — these fields were silently dropped before C1
    # caught them in the parity test. Activating must wire them
    # through to app.cfg.
    "cisco_ai_defense": {
        "endpoint": "https://aid.example.com",
        "api_key_env": "TEST_AID_KEY_ENV",
    },
    "webhooks": [
        {
            "name": "soc-channel",
            "url": "https://hooks.example.com/soc",
            "secret_env": "SOC_HOOK_SECRET",
            "enabled": True,
        },
    ],
}


class TestPlaygroundEndToEnd(unittest.TestCase):
    """B3: walk the full operator path from wizard output → activate
    → assert config.yaml + data.json reflect every field the wizard
    sent."""

    def setUp(self) -> None:
        self.app, self.tmp_dir, self.db_path = make_app_context()
        os.makedirs(self.app.cfg.policy_dir, exist_ok=True)
        # The activator updates ``policies/rego/data.json``; seed it
        # with the minimum required shape so _sync_opa_data has
        # something to mutate.
        rego_dir = os.path.join(self.app.cfg.policy_dir, "rego")
        os.makedirs(rego_dir, exist_ok=True)
        self.data_json_path = os.path.join(rego_dir, "data.json")
        with open(self.data_json_path, "w") as f:
            json.dump(
                {
                    "config": {},
                    "actions": {},
                    "severity_ranking": ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"],
                },
                f,
            )
        # Drop the synthetic wizard emit into the policy dir as if the
        # operator pasted it from the docs-site download.
        self.policy_path = os.path.join(
            self.app.cfg.policy_dir, "wizard-end-to-end.yaml"
        )
        with open(self.policy_path, "w") as f:
            yaml.safe_dump(WIZARD_EMIT_FIXTURE, f, sort_keys=False)
        self.runner = CliRunner()

    def tearDown(self) -> None:
        cleanup_app(self.app, self.db_path, self.tmp_dir)

    def _invoke(self, args: list[str]):
        return self.runner.invoke(policy, args, obj=self.app, catch_exceptions=False)

    def test_activate_propagates_every_field_through_to_config_and_data_json(self) -> None:
        result = self._invoke(["activate", "wizard-end-to-end"])
        self.assertEqual(result.exit_code, 0, msg=result.output)

        # --- config.yaml mutations -------------------------------------
        # Skill-action matrix.
        self.assertEqual(
            self.app.cfg.skill_actions.critical.install,
            "block",
            "skill_actions.critical.install was not applied",
        )
        # Watch.
        self.assertTrue(self.app.cfg.watch.rescan_enabled)
        self.assertEqual(self.app.cfg.watch.rescan_interval_min, 30)

        # Cisco AI Defense (the field that the C1 parity test exposed
        # as silently dropped — must now round-trip into config.yaml).
        self.assertEqual(
            self.app.cfg.cisco_ai_defense.endpoint,
            "https://aid.example.com",
        )
        self.assertEqual(
            self.app.cfg.cisco_ai_defense.api_key_env,
            "TEST_AID_KEY_ENV",
        )

        # Webhooks — wholesale list replacement on activate.
        self.assertEqual(len(self.app.cfg.webhooks), 1)
        wh = self.app.cfg.webhooks[0]
        self.assertEqual(getattr(wh, "name", None), "soc-channel")
        self.assertEqual(getattr(wh, "url", None), "https://hooks.example.com/soc")
        self.assertEqual(getattr(wh, "secret_env", None), "SOC_HOOK_SECRET")
        self.assertTrue(getattr(wh, "enabled", False))

        # The cfg must also have been *persisted* — the activator
        # calls app.cfg.save() so a subsequent gateway start picks up
        # the new state from disk. Re-read config.yaml and verify.
        with open(os.path.join(self.tmp_dir, "config.yaml")) as f:
            persisted = yaml.safe_load(f)
        self.assertEqual(
            persisted["cisco_ai_defense"]["endpoint"],
            "https://aid.example.com",
        )

        # --- data.json mutations ---------------------------------------
        with open(self.data_json_path) as f:
            data = json.load(f)

        # Policy name + admission.
        self.assertEqual(data["config"]["policy_name"], "wizard-end-to-end")
        self.assertTrue(data["config"]["scan_on_install"])
        self.assertFalse(data["config"]["allow_list_bypass_scan"])
        self.assertEqual(data["config"]["max_enforcement_delay_seconds"], 2)

        # OPA action matrix is uppercased on the way through.
        self.assertEqual(
            data["actions"]["CRITICAL"]["runtime"],
            "block",
            "runtime=disable must map to opa_runtime=block",
        )
        self.assertEqual(data["actions"]["LOW"]["runtime"], "allow")

        # Scanner overrides preserved with severity case-folded.
        self.assertIn("mcp", data["scanner_overrides"])
        self.assertEqual(
            data["scanner_overrides"]["mcp"]["MEDIUM"]["runtime"],
            "block",
        )

        # Guardrail thresholds + HILT.
        self.assertEqual(data["guardrail"]["block_threshold"], 4)
        self.assertTrue(data["guardrail"]["hilt"]["enabled"])
        self.assertEqual(data["guardrail"]["hilt"]["min_severity"], "MEDIUM")
        # Custom patterns propagate.
        self.assertIn("token:", data["guardrail"]["patterns"]["secrets"])

        # Firewall.
        self.assertEqual(data["firewall"]["default_action"], "deny")
        self.assertIn("c2.evil.example", data["firewall"]["blocked_destinations"])

        # First-party allow-list merge preserved source_path_contains.
        fp = data["first_party_allow_list"]
        plugin_entry = next(
            (e for e in fp if e.get("target_name") == "defenseclaw"), None
        )
        self.assertIsNotNone(plugin_entry)
        self.assertIn(".defenseclaw", plugin_entry["source_path_contains"])


if __name__ == "__main__":  # pragma: no cover — unittest discovery
    unittest.main()
