import json
import tempfile
from pathlib import Path
from unittest.mock import patch

from threatsmith.frameworks import get_framework
from threatsmith.utils.metadata import (
    ThreatSmithMetadata,
    generate_metadata,
    write_metadata,
)

_FRAMEWORK = get_framework("stride-4q")
_PASTA_FRAMEWORK = get_framework("pasta")


def test_generate_metadata_returns_all_required_fields():
    """Test that generate_metadata returns all required fields with correct types."""
    with patch("subprocess.run") as mock_run:
        # Mock git rev-parse HEAD
        mock_run.side_effect = [
            type("obj", (object,), {"stdout": "abc123def456\n", "returncode": 0})(),
            type("obj", (object,), {"stdout": "main\n", "returncode": 0})(),
        ]

        metadata = generate_metadata(
            engine_name="claude-code",
            framework=_FRAMEWORK,
            scanners_available=["semgrep", "trivy"],
            scanners_unavailable=["gitleaks"],
            user_objectives={"business": "Assess web app security"},
        )

        assert isinstance(metadata, ThreatSmithMetadata)

        # Check all required fields exist
        assert metadata.threatsmith_version
        assert metadata.engine
        assert metadata.commit_hash
        assert metadata.branch
        assert metadata.timestamp
        assert metadata.scanners_available is not None
        assert metadata.scanners_unavailable is not None
        assert metadata.user_objectives is not None

        # Check types
        assert isinstance(metadata.threatsmith_version, str)
        assert isinstance(metadata.engine, str)
        assert isinstance(metadata.commit_hash, str)
        assert isinstance(metadata.branch, str)
        assert isinstance(metadata.timestamp, str)
        assert isinstance(metadata.scanners_available, list)
        assert isinstance(metadata.scanners_unavailable, list)
        assert isinstance(metadata.user_objectives, dict)


def test_generate_metadata_captures_engine_name():
    """Test that generate_metadata correctly captures the engine name."""
    with patch("subprocess.run") as mock_run:
        mock_run.side_effect = [
            type("obj", (object,), {"stdout": "commit123\n", "returncode": 0})(),
            type("obj", (object,), {"stdout": "branch1\n", "returncode": 0})(),
        ]

        metadata = generate_metadata(
            engine_name="codex",
            framework=_PASTA_FRAMEWORK,
            scanners_available=[],
            scanners_unavailable=[],
        )

        assert metadata.engine == "codex"


def test_generate_metadata_captures_scanners():
    """Test that generate_metadata correctly captures available and unavailable scanners."""
    with patch("subprocess.run") as mock_run:
        mock_run.side_effect = [
            type("obj", (object,), {"stdout": "hash\n", "returncode": 0})(),
            type("obj", (object,), {"stdout": "br\n", "returncode": 0})(),
        ]

        metadata = generate_metadata(
            engine_name="claude-code",
            framework=_FRAMEWORK,
            scanners_available=["trivy", "semgrep"],
            scanners_unavailable=["gitleaks"],
        )

        assert metadata.scanners_available == ["trivy", "semgrep"]
        assert metadata.scanners_unavailable == ["gitleaks"]


def test_generate_metadata_captures_user_objectives():
    """Test that generate_metadata correctly captures user objectives."""
    with patch("subprocess.run") as mock_run:
        mock_run.side_effect = [
            type("obj", (object,), {"stdout": "hash\n", "returncode": 0})(),
            type("obj", (object,), {"stdout": "br\n", "returncode": 0})(),
        ]

        objectives = {
            "business": "Find all authentication flaws",
            "security": "PCI-DSS",
        }
        metadata = generate_metadata(
            engine_name="claude-code",
            framework=_FRAMEWORK,
            scanners_available=[],
            scanners_unavailable=[],
            user_objectives=objectives,
        )

        assert metadata.user_objectives == objectives


def test_generate_metadata_handles_git_failures():
    """Test that generate_metadata handles git command failures gracefully."""
    with patch("subprocess.run") as mock_run:
        # Both git commands fail with CalledProcessError
        mock_run.side_effect = [
            Exception("git not found"),
            Exception("git not found"),
        ]

        metadata = generate_metadata(
            engine_name="claude-code",
            framework=_FRAMEWORK,
            scanners_available=[],
            scanners_unavailable=[],
        )

        assert metadata.commit_hash == "unknown"
        assert metadata.branch == "unknown"


def test_generate_metadata_timestamp_is_iso8601():
    """Test that the timestamp is in ISO 8601 format."""
    with patch("subprocess.run") as mock_run:
        mock_run.side_effect = [
            type("obj", (object,), {"stdout": "hash\n", "returncode": 0})(),
            type("obj", (object,), {"stdout": "br\n", "returncode": 0})(),
        ]

        metadata = generate_metadata(
            engine_name="claude-code",
            framework=_FRAMEWORK,
            scanners_available=[],
            scanners_unavailable=[],
        )

        timestamp = metadata.timestamp
        # Should contain 'T' (ISO 8601 format) and 'Z' or '+' (timezone indicator)
        assert "T" in timestamp
        assert "Z" in timestamp or "+" in timestamp or "-" in timestamp


def test_write_metadata_creates_json_file():
    """Test that write_metadata creates a valid JSON file."""
    with tempfile.TemporaryDirectory() as tmpdir:
        metadata = ThreatSmithMetadata(
            threatsmith_version="0.2.0",
            engine="claude-code",
            framework="stride-4q",
            framework_display_name="4QF + STRIDE",
            stages_completed=5,
            commit_hash="abc123",
            branch="main",
            timestamp="2026-03-13T12:00:00+00:00",
            scanners_available=["trivy"],
            scanners_unavailable=["semgrep", "gitleaks"],
            user_objectives={"business": "Test objectives"},
        )

        write_metadata(tmpdir, metadata)

        # Verify file exists
        metadata_file = Path(tmpdir) / "metadata.json"
        assert metadata_file.exists()

        # Verify file contains valid JSON matching the dataclass
        with open(metadata_file) as f:
            loaded = json.load(f)

        assert loaded == metadata.to_dict()


def test_write_metadata_creates_directory_if_needed():
    """Test that write_metadata works with nested directory paths."""
    with tempfile.TemporaryDirectory() as tmpdir:
        nested_dir = Path(tmpdir) / "subdir" / "nested"
        nested_dir.mkdir(parents=True)

        metadata = ThreatSmithMetadata(
            threatsmith_version="0.2.0",
            engine="claude-code",
            framework="stride-4q",
            framework_display_name="4QF + STRIDE",
            stages_completed=0,
            commit_hash="hash",
            branch="branch",
            timestamp="2026-03-13T12:00:00+00:00",
            scanners_available=[],
            scanners_unavailable=[],
            user_objectives={},
        )

        write_metadata(str(nested_dir), metadata)

        metadata_file = nested_dir / "metadata.json"
        assert metadata_file.exists()


def test_generate_metadata_json_serializable():
    """Test that generate_metadata output is JSON serializable."""
    with patch("subprocess.run") as mock_run:
        mock_run.side_effect = [
            type("obj", (object,), {"stdout": "hash\n", "returncode": 0})(),
            type("obj", (object,), {"stdout": "br\n", "returncode": 0})(),
        ]

        metadata = generate_metadata(
            engine_name="claude-code",
            framework=_FRAMEWORK,
            scanners_available=["trivy"],
            scanners_unavailable=["semgrep"],
            user_objectives={"business": "objectives"},
        )

        # Should not raise an exception
        json_str = json.dumps(metadata.to_dict())
        assert isinstance(json_str, str)


def test_generate_metadata_no_objectives_defaults_to_empty_dict():
    """Test that omitting user_objectives defaults to an empty dict."""
    with patch("subprocess.run") as mock_run:
        mock_run.side_effect = [
            type("obj", (object,), {"stdout": "hash\n", "returncode": 0})(),
            type("obj", (object,), {"stdout": "br\n", "returncode": 0})(),
        ]

        metadata = generate_metadata(
            engine_name="claude-code",
            framework=_FRAMEWORK,
            scanners_available=[],
            scanners_unavailable=[],
        )

        assert metadata.user_objectives == {}


def test_generate_metadata_framework_name_from_pack():
    """Test that generate_metadata records the framework pack name."""
    with patch("subprocess.run") as mock_run:
        mock_run.side_effect = [
            type("obj", (object,), {"stdout": "hash\n", "returncode": 0})(),
            type("obj", (object,), {"stdout": "br\n", "returncode": 0})(),
        ]

        metadata = generate_metadata(
            engine_name="claude-code",
            framework=_FRAMEWORK,
            scanners_available=[],
            scanners_unavailable=[],
        )

        assert metadata.framework == "stride-4q"


def test_generate_metadata_framework_display_name_from_pack():
    """Test that generate_metadata records the framework display name."""
    with patch("subprocess.run") as mock_run:
        mock_run.side_effect = [
            type("obj", (object,), {"stdout": "hash\n", "returncode": 0})(),
            type("obj", (object,), {"stdout": "br\n", "returncode": 0})(),
        ]

        metadata = generate_metadata(
            engine_name="claude-code",
            framework=_FRAMEWORK,
            scanners_available=[],
            scanners_unavailable=[],
        )

        assert metadata.framework_display_name == "4QF + STRIDE"


def test_generate_metadata_stages_completed_default_zero():
    """Test that stages_completed defaults to 0."""
    with patch("subprocess.run") as mock_run:
        mock_run.side_effect = [
            type("obj", (object,), {"stdout": "hash\n", "returncode": 0})(),
            type("obj", (object,), {"stdout": "br\n", "returncode": 0})(),
        ]

        metadata = generate_metadata(
            engine_name="claude-code",
            framework=_FRAMEWORK,
            scanners_available=[],
            scanners_unavailable=[],
        )

        assert metadata.stages_completed == 0


def test_generate_metadata_stages_completed_explicit():
    """Test that stages_completed is recorded correctly when passed explicitly."""
    with patch("subprocess.run") as mock_run:
        mock_run.side_effect = [
            type("obj", (object,), {"stdout": "hash\n", "returncode": 0})(),
            type("obj", (object,), {"stdout": "br\n", "returncode": 0})(),
        ]

        metadata = generate_metadata(
            engine_name="claude-code",
            framework=_FRAMEWORK,
            scanners_available=[],
            scanners_unavailable=[],
            stages_completed=5,
        )

        assert metadata.stages_completed == 5


def test_generate_metadata_framework_and_display_name_in_json():
    """Test that framework and framework_display_name appear in serialized JSON."""
    with patch("subprocess.run") as mock_run:
        mock_run.side_effect = [
            type("obj", (object,), {"stdout": "hash\n", "returncode": 0})(),
            type("obj", (object,), {"stdout": "br\n", "returncode": 0})(),
        ]

        metadata = generate_metadata(
            engine_name="claude-code",
            framework=_PASTA_FRAMEWORK,
            scanners_available=[],
            scanners_unavailable=[],
            stages_completed=8,
        )

        d = metadata.to_dict()
        assert d["framework"] == "pasta"
        assert d["framework_display_name"] == "PASTA"
        assert d["stages_completed"] == 8
