"""Tests for Multi-Project Aggregation."""

from __future__ import annotations

import json
from pathlib import Path
from typing import Any

import pytest

from mcpaudit.multi_project import (
    AggregatedReport,
    FleetStats,
    ProjectFinding,
    ProjectResult,
    format_fleet_json,
    format_fleet_text,
    generate_fleet_html,
    scan_projects,
    scan_projects_from_parent,
)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _make_project(
    tmp_path: Path,
    name: str,
    files: dict[str, str] | None = None,
) -> str:
    """Create a test project directory."""
    proj = tmp_path / name
    proj.mkdir(exist_ok=True)
    if files:
        for fname, content in files.items():
            (proj / fname).write_text(content, encoding="utf-8")
    return str(proj)


def _safe_code() -> str:
    return "import json\ndata = json.loads(raw)\nprint(data)\n"


def _vuln_code() -> str:
    return (
        "from mcp.server import Server\n"
        "import subprocess\n"
        "def run(cmd):\n"
        "    subprocess.call(cmd, shell=True)\n"
    )


# ---------------------------------------------------------------------------
# ProjectFinding
# ---------------------------------------------------------------------------

class TestProjectFinding:
    def test_to_dict(self):
        f = ProjectFinding(
            rule_id="CMD-001", severity="HIGH",
            title="Test", description="Desc",
            file="a.py", line=10, project="proj1",
        )
        d = f.to_dict()
        assert d["rule_id"] == "CMD-001"
        assert d["project"] == "proj1"
        assert d["line"] == 10

    def test_dedup_key(self):
        f = ProjectFinding(
            rule_id="CMD-001", severity="HIGH",
            title="Test", description="Desc",
            file="a.py", line=10, project="proj1",
        )
        assert f.dedup_key == ("CMD-001", "a.py", 10)

    def test_with_code_snippet(self):
        f = ProjectFinding(
            rule_id="CMD-001", severity="HIGH",
            title="Test", description="Desc",
            file="a.py", line=10, project="proj1",
            code_snippet="x = 1", cwe="CWE-78",
        )
        d = f.to_dict()
        assert d["code_snippet"] == "x = 1"
        assert d["cwe"] == "CWE-78"


# ---------------------------------------------------------------------------
# ProjectResult
# ---------------------------------------------------------------------------

class TestProjectResult:
    def test_defaults(self):
        r = ProjectResult(name="test", path="/tmp/test")
        assert r.files_scanned == 0
        assert r.total_findings == 0
        assert r.grade == "A+"
        assert r.trust_score == 100.0

    def test_to_dict(self):
        r = ProjectResult(
            name="test", path="/tmp/test",
            files_scanned=5, total_findings=3,
            grade="B", trust_score=75.0,
            scan_time_ms=100.5,
        )
        d = r.to_dict()
        assert d["name"] == "test"
        assert d["files_scanned"] == 5
        assert d["grade"] == "B"
        assert d["trust_score"] == 75.0

    def test_to_dict_round_time(self):
        r = ProjectResult(
            name="test", path="/tmp/test",
            scan_time_ms=1.23456789,
        )
        d = r.to_dict()
        assert d["scan_time_ms"] == 1.23


# ---------------------------------------------------------------------------
# FleetStats
# ---------------------------------------------------------------------------

class TestFleetStats:
    def test_defaults(self):
        s = FleetStats()
        assert s.total_projects == 0
        assert s.avg_trust_score == 0.0

    def test_to_dict(self):
        s = FleetStats(
            total_projects=3, total_files=10,
            total_findings=5, unique_findings=3,
            worst_project="bad", best_project="good",
            avg_trust_score=85.333333,
        )
        d = s.to_dict()
        assert d["total_projects"] == 3
        assert d["avg_trust_score"] == 85.3
        assert d["worst_project"] == "bad"
        assert d["best_project"] == "good"


# ---------------------------------------------------------------------------
# AggregatedReport
# ---------------------------------------------------------------------------

class TestAggregatedReport:
    def test_defaults(self):
        r = AggregatedReport()
        assert r.fleet_grade == "A+"
        assert r.projects == []
        assert r.findings == []

    def test_to_dict(self):
        r = AggregatedReport(
            fleet_grade="B",
            scan_time_ms=500.0,
        )
        d = r.to_dict()
        assert d["fleet_grade"] == "B"
        assert d["scan_time_ms"] == 500.0
        assert "stats" in d
        assert "projects" in d
        assert "findings" in d


# ---------------------------------------------------------------------------
# scan_projects
# ---------------------------------------------------------------------------

class TestScanProjects:
    def test_empty_paths(self):
        report = scan_projects([])
        assert report.stats.total_projects == 0
        assert report.fleet_grade == "A+"

    def test_single_empty_project(self, tmp_path):
        proj = _make_project(tmp_path, "proj1")
        report = scan_projects([proj])
        assert report.stats.total_projects == 1
        assert report.stats.total_findings == 0

    def test_single_project_with_file(self, tmp_path):
        proj = _make_project(tmp_path, "proj1", {
            "server.py": _safe_code(),
        })
        report = scan_projects([proj])
        assert report.stats.total_projects == 1
        assert report.stats.total_files >= 1

    def test_multiple_projects(self, tmp_path):
        proj1 = _make_project(tmp_path, "proj1", {
            "a.py": _safe_code(),
        })
        proj2 = _make_project(tmp_path, "proj2", {
            "b.py": _safe_code(),
        })
        report = scan_projects([proj1, proj2])
        assert report.stats.total_projects == 2

    def test_nonexistent_path(self, tmp_path):
        report = scan_projects([str(tmp_path / "nonexistent")])
        assert report.stats.total_projects == 1
        assert report.stats.total_files == 0

    def test_project_names(self, tmp_path):
        proj1 = _make_project(tmp_path, "alpha")
        proj2 = _make_project(tmp_path, "beta")
        report = scan_projects([proj1, proj2])
        names = [p.name for p in report.projects]
        assert "alpha" in names
        assert "beta" in names

    def test_fleet_grade_computed(self, tmp_path):
        proj = _make_project(tmp_path, "clean", {
            "safe.py": _safe_code(),
        })
        report = scan_projects([proj])
        assert report.fleet_grade in ["A+", "A", "B", "C", "D", "F"]

    def test_scan_time_positive(self, tmp_path):
        proj = _make_project(tmp_path, "proj", {
            "a.py": _safe_code(),
        })
        report = scan_projects([proj])
        assert report.scan_time_ms >= 0

    def test_deduplication_same_finding(self, tmp_path):
        # Same file in two "projects" should deduplicate
        proj1 = _make_project(tmp_path, "p1", {
            "server.py": _vuln_code(),
        })
        proj2 = _make_project(tmp_path, "p2", {
            "server.py": _vuln_code(),
        })
        report = scan_projects([proj1, proj2])
        # Findings have different file paths so no dedup
        assert report.stats.total_projects == 2

    def test_stats_best_worst_project(self, tmp_path):
        proj1 = _make_project(tmp_path, "clean", {
            "safe.py": _safe_code(),
        })
        proj2 = _make_project(tmp_path, "vuln", {
            "server.py": _vuln_code(),
        })
        report = scan_projects([proj1, proj2])
        assert report.stats.best_project != ""
        assert report.stats.worst_project != ""

    def test_avg_trust_score(self, tmp_path):
        proj1 = _make_project(tmp_path, "p1", {
            "safe.py": _safe_code(),
        })
        proj2 = _make_project(tmp_path, "p2", {
            "safe.py": _safe_code(),
        })
        report = scan_projects([proj1, proj2])
        assert 0 <= report.stats.avg_trust_score <= 100

    def test_severity_counts(self, tmp_path):
        proj = _make_project(tmp_path, "proj", {
            "safe.py": _safe_code(),
        })
        report = scan_projects([proj])
        sc = report.stats.severity_counts
        for sev in ("CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"):
            assert sev in sc

    def test_per_project_severity_counts(self, tmp_path):
        proj = _make_project(tmp_path, "proj", {
            "server.py": _vuln_code(),
        })
        report = scan_projects([proj])
        p = report.projects[0]
        assert isinstance(p.severity_counts, dict)

    def test_single_file_as_project(self, tmp_path):
        f = tmp_path / "single.py"
        f.write_text(_safe_code(), encoding="utf-8")
        report = scan_projects([str(f)])
        assert report.stats.total_projects == 1
        assert report.stats.total_files >= 1


# ---------------------------------------------------------------------------
# scan_projects_from_parent
# ---------------------------------------------------------------------------

class TestScanProjectsFromParent:
    def test_basic(self, tmp_path):
        _make_project(tmp_path, "proj1", {"a.py": _safe_code()})
        _make_project(tmp_path, "proj2", {"b.py": _safe_code()})
        report = scan_projects_from_parent(str(tmp_path))
        assert report.stats.total_projects >= 2

    def test_nonexistent_parent(self, tmp_path):
        report = scan_projects_from_parent(
            str(tmp_path / "nonexistent"),
        )
        assert report.stats.total_projects == 0

    def test_skips_hidden_dirs(self, tmp_path):
        _make_project(tmp_path, ".hidden", {"a.py": _safe_code()})
        _make_project(tmp_path, "visible", {"b.py": _safe_code()})
        report = scan_projects_from_parent(str(tmp_path))
        names = [p.name for p in report.projects]
        assert "visible" in names

    def test_empty_parent(self, tmp_path):
        report = scan_projects_from_parent(str(tmp_path))
        assert report.stats.total_projects == 0


# ---------------------------------------------------------------------------
# Formatting
# ---------------------------------------------------------------------------

class TestFormatFleetText:
    def test_empty_report(self):
        report = AggregatedReport()
        text = format_fleet_text(report)
        assert "Fleet Report" in text

    def test_with_projects(self, tmp_path):
        proj = _make_project(tmp_path, "proj", {
            "a.py": _safe_code(),
        })
        report = scan_projects([proj])
        text = format_fleet_text(report)
        assert "proj" in text
        assert "Fleet Grade" in text

    def test_contains_stats(self, tmp_path):
        proj = _make_project(tmp_path, "proj", {
            "a.py": _safe_code(),
        })
        report = scan_projects([proj])
        text = format_fleet_text(report)
        assert "Projects" in text
        assert "trust score" in text.lower()


class TestFormatFleetJson:
    def test_empty_report(self):
        report = AggregatedReport()
        j = format_fleet_json(report)
        data = json.loads(j)
        assert data["fleet_grade"] == "A+"
        assert data["stats"]["total_projects"] == 0

    def test_with_projects(self, tmp_path):
        proj = _make_project(tmp_path, "proj", {
            "a.py": _safe_code(),
        })
        report = scan_projects([proj])
        j = format_fleet_json(report)
        data = json.loads(j)
        assert data["stats"]["total_projects"] == 1
        assert len(data["projects"]) == 1

    def test_valid_json_structure(self, tmp_path):
        proj = _make_project(tmp_path, "proj", {
            "a.py": _safe_code(),
        })
        report = scan_projects([proj])
        j = format_fleet_json(report)
        data = json.loads(j)
        assert "fleet_grade" in data
        assert "scan_time_ms" in data
        assert "stats" in data
        assert "projects" in data
        assert "findings" in data


class TestGenerateFleetHtml:
    def test_empty_report(self):
        report = AggregatedReport()
        html = generate_fleet_html(report)
        assert "<!DOCTYPE html>" in html
        assert "Fleet Report" in html

    def test_with_projects(self, tmp_path):
        proj = _make_project(tmp_path, "proj", {
            "a.py": _safe_code(),
        })
        report = scan_projects([proj])
        html = generate_fleet_html(report)
        assert "proj" in html
        assert "Fleet Grade" in html

    def test_html_contains_css(self):
        report = AggregatedReport()
        html = generate_fleet_html(report)
        assert "<style>" in html

    def test_html_has_table(self, tmp_path):
        proj = _make_project(tmp_path, "proj", {
            "a.py": _safe_code(),
        })
        report = scan_projects([proj])
        html = generate_fleet_html(report)
        assert "<table>" in html
        assert "<th>" in html

    def test_html_escapes_special_chars(self, tmp_path):
        proj = _make_project(tmp_path, "proj<>&", {
            "a.py": _safe_code(),
        })
        report = scan_projects([proj])
        html = generate_fleet_html(report)
        # The project name should be escaped
        assert "proj&lt;&gt;&amp;" in html

    def test_html_severity_bars(self, tmp_path):
        proj = _make_project(tmp_path, "proj", {
            "a.py": _safe_code(),
        })
        report = scan_projects([proj])
        html = generate_fleet_html(report)
        assert "Severity Distribution" in html

    def test_version_in_footer(self):
        report = AggregatedReport()
        html = generate_fleet_html(report)
        assert "v2.2.0" in html


# ---------------------------------------------------------------------------
# Edge cases
# ---------------------------------------------------------------------------

class TestEdgeCases:
    def test_project_with_only_non_scannable_files(self, tmp_path):
        proj = _make_project(tmp_path, "proj", {
            "readme.txt": "Just a readme",
            "data.csv": "a,b,c",
        })
        report = scan_projects([proj])
        assert report.stats.total_files == 0

    def test_nested_project_files(self, tmp_path):
        proj_dir = tmp_path / "proj"
        proj_dir.mkdir()
        sub = proj_dir / "subdir"
        sub.mkdir()
        (sub / "nested.py").write_text(_safe_code(), encoding="utf-8")
        report = scan_projects([str(proj_dir)])
        assert report.stats.total_files >= 1

    def test_many_projects(self, tmp_path):
        paths = []
        for i in range(10):
            p = _make_project(tmp_path, f"proj{i}", {
                "a.py": _safe_code(),
            })
            paths.append(p)
        report = scan_projects(paths)
        assert report.stats.total_projects == 10

    def test_report_to_dict_serializable(self, tmp_path):
        proj = _make_project(tmp_path, "proj", {
            "a.py": _safe_code(),
        })
        report = scan_projects([proj])
        # Must be JSON-serializable
        j = json.dumps(report.to_dict())
        data = json.loads(j)
        assert "fleet_grade" in data
