from __future__ import annotations

import json
import re
import shutil
import subprocess
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List

from vibegate.config import (
    ConfigSanityCheck,
    DependencyHygieneCheck,
    GitleaksCheck,
    RuffFormatCheck,
    RuffLintCheck,
    PyrightCheck,
    PytestCheck,
    BanditCheck,
    VulnerabilityCheck,
    RuntimeSmokeCheck,
    VibeGateConfig,
)
from vibegate.findings import Finding, FindingLocation


@dataclass(frozen=True)
class ToolResult:
    argv: List[str]
    stdout: str
    stderr: str
    exit_code: int
    duration_ms: int
    tool_version: str
    artifacts: List[Path]


@dataclass(frozen=True)
class CheckOutcome:
    check_id: str
    findings: List[Finding]
    skipped_reason: str | None
    tool_result: ToolResult | None


def tool_exists(tool: str) -> bool:
    return shutil.which(tool) is not None


def tool_version(tool: str) -> str:
    try:
        output = subprocess.check_output([tool, "--version"], stderr=subprocess.STDOUT)
        return output.decode("utf-8").strip()
    except (subprocess.CalledProcessError, FileNotFoundError):
        return "unknown"


def run_tool(
    tool: str, args: List[str], cwd: Path, timeout: int, env: Dict[str, str]
) -> ToolResult:
    argv = [tool] + args
    started = time.monotonic()
    completed = subprocess.run(
        argv,
        cwd=cwd,
        env=env,
        capture_output=True,
        text=True,
        timeout=timeout,
    )
    duration_ms = int((time.monotonic() - started) * 1000)
    return ToolResult(
        argv=argv,
        stdout=completed.stdout,
        stderr=completed.stderr,
        exit_code=completed.returncode,
        duration_ms=duration_ms,
        tool_version=tool_version(tool),
        artifacts=[],
    )


def _severity_from_pyright(level: str) -> str:
    mapping = {
        "error": "high",
        "warning": "medium",
        "information": "low",
        "hint": "info",
    }
    return mapping.get(level.lower(), "medium")


def run_ruff_format(
    check: RuffFormatCheck, repo_root: Path, env: Dict[str, str]
) -> CheckOutcome:
    if not tool_exists("ruff"):
        return CheckOutcome(check.id, [], "ruff not found", None)

    result = run_tool("ruff", check.args, repo_root, check.timeout_sec, env)
    findings: List[Finding] = []
    if result.exit_code != 0:
        findings.append(
            Finding(
                check_id=check.id,
                finding_type="formatting",
                rule_id="RUFF_FORMAT",
                severity="low",
                message="Ruff formatter reported formatting differences.",
                fingerprint="",
                tool="ruff",
                remediation_hint="Run ruff format to apply formatting.",
            )
        )
    return CheckOutcome(check.id, findings, None, result)


def run_ruff_lint(
    check: RuffLintCheck, repo_root: Path, env: Dict[str, str]
) -> CheckOutcome:
    if not tool_exists("ruff"):
        return CheckOutcome(check.id, [], "ruff not found", None)

    result = run_tool("ruff", check.args, repo_root, check.timeout_sec, env)
    findings: List[Finding] = []
    if result.stdout.strip():
        try:
            payload = json.loads(result.stdout)
        except json.JSONDecodeError:
            payload = []
        for item in payload:
            location = FindingLocation(
                path=item.get("filename"),
                line=item.get("location", {}).get("row"),
                col=item.get("location", {}).get("column"),
                end_line=item.get("end_location", {}).get("row"),
                end_col=item.get("end_location", {}).get("column"),
            )
            findings.append(
                Finding(
                    check_id=check.id,
                    finding_type="lint",
                    rule_id=item.get("code", "RUFF"),
                    severity="medium",
                    message=item.get("message", "Ruff lint finding."),
                    fingerprint="",
                    tool="ruff",
                    remediation_hint=item.get("fix", {}).get("message")
                    if isinstance(item.get("fix"), dict)
                    else None,
                    location=location,
                )
            )
    elif result.exit_code != 0:
        findings.append(
            Finding(
                check_id=check.id,
                finding_type="lint",
                rule_id="RUFF",
                severity="medium",
                message="Ruff reported lint issues.",
                fingerprint="",
                tool="ruff",
            )
        )
    return CheckOutcome(check.id, findings, None, result)


def run_pyright(
    check: PyrightCheck, repo_root: Path, env: Dict[str, str]
) -> CheckOutcome:
    if not tool_exists("pyright"):
        return CheckOutcome(check.id, [], "pyright not found", None)

    result = run_tool("pyright", check.args, repo_root, check.timeout_sec, env)
    findings: List[Finding] = []
    if result.stdout.strip():
        try:
            payload = json.loads(result.stdout)
        except json.JSONDecodeError:
            payload = {}
        diagnostics = payload.get("generalDiagnostics", [])
        for item in diagnostics:
            severity = _severity_from_pyright(item.get("severity", "error"))
            location = None
            if "file" in item and "range" in item:
                start = item["range"].get("start", {})
                end = item["range"].get("end", {})
                location = FindingLocation(
                    path=item.get("file"),
                    line=start.get("line", 0) + 1,
                    col=start.get("character", 0) + 1,
                    end_line=end.get("line", 0) + 1,
                    end_col=end.get("character", 0) + 1,
                )
            findings.append(
                Finding(
                    check_id=check.id,
                    finding_type="typecheck",
                    rule_id=item.get("rule") or item.get("ruleId") or "PYRIGHT",
                    severity=severity,
                    message=item.get("message", "Pyright diagnostic."),
                    fingerprint="",
                    tool="pyright",
                    location=location,
                )
            )
    elif result.exit_code != 0:
        findings.append(
            Finding(
                check_id=check.id,
                finding_type="typecheck",
                rule_id="PYRIGHT",
                severity="high",
                message="Pyright reported typecheck errors.",
                fingerprint="",
                tool="pyright",
            )
        )
    return CheckOutcome(check.id, findings, None, result)


def run_pytest(
    check: PytestCheck, repo_root: Path, env: Dict[str, str]
) -> CheckOutcome:
    if not tool_exists("pytest"):
        return CheckOutcome(check.id, [], "pytest not found", None)

    result = run_tool("pytest", check.args, repo_root, check.timeout_sec, env)
    findings: List[Finding] = []
    if result.exit_code != 0:
        message = result.stdout.strip().splitlines()[:1]
        summary = message[0] if message else "Pytest reported failures."
        findings.append(
            Finding(
                check_id=check.id,
                finding_type="tests",
                rule_id="PYTEST_FAILED",
                severity="high",
                message=summary,
                fingerprint="",
                tool="pytest",
            )
        )
    return CheckOutcome(check.id, findings, None, result)


def _default_lockfiles(tool: str) -> List[str]:
    mapping = {
        "uv": ["uv.lock"],
        "poetry": ["poetry.lock"],
        "pdm": ["pdm.lock"],
        "pip-tools": ["requirements.txt"],
        "pip": ["requirements.txt"],
    }
    return mapping.get(tool, [])


def detect_packaging_tool(config: VibeGateConfig) -> str:
    if config.packaging.tool != "auto":
        return config.packaging.tool
    for tool in config.packaging.tool_detection_order:
        if tool_exists(tool):
            return tool
    return "pip"


def run_dependency_hygiene(
    check: DependencyHygieneCheck,
    repo_root: Path,
    env: Dict[str, str],
    config: VibeGateConfig,
) -> CheckOutcome:
    findings: List[Finding] = []
    tool = detect_packaging_tool(config)
    lockfiles = config.packaging.lockfiles or _default_lockfiles(tool)

    if "lockfile_required" in check.rules:
        if lockfiles and not any((repo_root / path).exists() for path in lockfiles):
            findings.append(
                Finding(
                    check_id=check.id,
                    finding_type="dependency_hygiene",
                    rule_id="LOCKFILE_MISSING",
                    severity="high",
                    message=f"Missing lockfile for packaging tool {tool}.",
                    fingerprint="",
                )
            )

    tool_result = None
    if tool == "uv" and "lockfile_fresh" in check.rules:
        if not tool_exists("uv"):
            return CheckOutcome(check.id, findings, "uv not found", None)
        tool_result = run_tool(
            "uv", ["lock", "--check"], repo_root, check.timeout_sec, env
        )
        if tool_result.exit_code != 0:
            findings.append(
                Finding(
                    check_id=check.id,
                    finding_type="dependency_hygiene",
                    rule_id="LOCKFILE_STALE",
                    severity="medium",
                    message="uv lockfile is out of date.",
                    fingerprint="",
                    tool="uv",
                )
            )

    return CheckOutcome(check.id, findings, None, tool_result)


def run_bandit(
    check: BanditCheck, repo_root: Path, env: Dict[str, str]
) -> CheckOutcome:
    if not tool_exists("bandit"):
        return CheckOutcome(check.id, [], "bandit not found", None)

    result = run_tool("bandit", check.args, repo_root, check.timeout_sec, env)
    artifacts = [
        repo_root / Path(path) for path in check.args if path.endswith(".json")
    ]
    result = ToolResult(
        argv=result.argv,
        stdout=result.stdout,
        stderr=result.stderr,
        exit_code=result.exit_code,
        duration_ms=result.duration_ms,
        tool_version=result.tool_version,
        artifacts=artifacts,
    )
    findings: List[Finding] = []
    for artifact in artifacts:
        if not artifact.exists():
            continue
        payload = json.loads(artifact.read_text(encoding="utf-8"))
        for item in payload.get("results", []):
            severity = str(item.get("issue_severity", "medium")).lower()
            findings.append(
                Finding(
                    check_id=check.id,
                    finding_type="sast",
                    rule_id=item.get("test_id", "BANDIT"),
                    severity=severity,
                    message=item.get("issue_text", "Bandit finding."),
                    fingerprint="",
                    tool="bandit",
                    location=FindingLocation(
                        path=item.get("filename"), line=item.get("line_number")
                    ),
                )
            )
    return CheckOutcome(check.id, findings, None, result)


def run_gitleaks(
    check: GitleaksCheck, repo_root: Path, env: Dict[str, str]
) -> CheckOutcome:
    if not tool_exists("gitleaks"):
        return CheckOutcome(check.id, [], "gitleaks not found", None)

    result = run_tool("gitleaks", check.args, repo_root, check.timeout_sec, env)
    artifacts = [
        repo_root / Path(path) for path in check.args if path.endswith(".json")
    ]
    result = ToolResult(
        argv=result.argv,
        stdout=result.stdout,
        stderr=result.stderr,
        exit_code=result.exit_code,
        duration_ms=result.duration_ms,
        tool_version=result.tool_version,
        artifacts=artifacts,
    )
    findings: List[Finding] = []
    for artifact in artifacts:
        if not artifact.exists():
            continue
        payload = json.loads(artifact.read_text(encoding="utf-8"))
        for item in payload:
            findings.append(
                Finding(
                    check_id=check.id,
                    finding_type="secrets",
                    rule_id=item.get("RuleID", "GITLEAKS"),
                    severity="high",
                    message=item.get("Description", "Secret detected."),
                    fingerprint="",
                    tool="gitleaks",
                    location=FindingLocation(
                        path=item.get("File"), line=item.get("StartLine")
                    ),
                )
            )
    return CheckOutcome(check.id, findings, None, result)


def run_osv(
    check: VulnerabilityCheck, repo_root: Path, env: Dict[str, str]
) -> CheckOutcome:
    if check.mode == "skip":
        return CheckOutcome(check.id, [], "vulnerability check disabled", None)
    if check.local_db is None or not (repo_root / check.local_db.path).exists():
        return CheckOutcome(check.id, [], "offline DB snapshot not configured", None)
    if not tool_exists("osv-scanner"):
        return CheckOutcome(check.id, [], "osv-scanner not found", None)

    result = run_tool("osv-scanner", check.args, repo_root, check.timeout_sec, env)
    findings: List[Finding] = []
    if result.exit_code != 0:
        message = result.stdout.strip().splitlines()[:1]
        summary = message[0] if message else "osv-scanner reported vulnerabilities."
        findings.append(
            Finding(
                check_id=check.id,
                finding_type="vulnerability",
                rule_id="OSV",
                severity="high",
                message=summary,
                fingerprint="",
                tool="osv-scanner",
            )
        )
    return CheckOutcome(check.id, findings, None, result)


def _collect_files(
    repo_root: Path, include_globs: List[str], exclude_globs: List[str]
) -> List[Path]:
    paths: set[Path] = set()
    for pattern in include_globs:
        for path in repo_root.rglob(pattern):
            if path.is_file():
                paths.add(path)
    excluded = set()
    for path in paths:
        rel = path.relative_to(repo_root).as_posix()
        for pattern in exclude_globs:
            if Path(rel).match(pattern):
                excluded.add(path)
                break
    return sorted(paths - excluded, key=lambda p: p.as_posix())


def run_config_sanity(check: ConfigSanityCheck, repo_root: Path) -> CheckOutcome:
    findings: List[Finding] = []
    files = _collect_files(repo_root, check.include_globs, check.exclude_globs)

    debug_pattern = re.compile(r"debug=True")
    debug_env_pattern = re.compile(r"DEBUG\s*=\s*True")
    app_debug_pattern = re.compile(r"app\.debug\s*=\s*True")
    reload_pattern = re.compile(r"--reload|reload=True")
    cors_pattern = re.compile(
        r"allow_origins\s*=\s*\[\"\*\"\]|allow_origin_regex\s*=\s*\".*\*"
    )
    secret_pattern = re.compile(r"(SECRET|TOKEN|API_KEY|PASSWORD)\s*[:=]\s*['\"]")

    for path in files:
        try:
            content = path.read_text(encoding="utf-8", errors="ignore")
        except FileNotFoundError:
            continue
        for idx, line in enumerate(content.splitlines(), start=1):
            if "no_debug_true" in check.rules and path.suffix == ".py":
                if (
                    debug_pattern.search(line)
                    or debug_env_pattern.search(line)
                    or app_debug_pattern.search(line)
                ):
                    findings.append(
                        Finding(
                            check_id=check.id,
                            finding_type="config_sanity",
                            rule_id="no_debug_true",
                            severity="high",
                            message="Debug mode appears to be enabled.",
                            fingerprint="",
                            location=FindingLocation(
                                path=str(path.relative_to(repo_root)), line=idx
                            ),
                        )
                    )
            if "no_uvicorn_reload_in_prod" in check.rules:
                if reload_pattern.search(line):
                    findings.append(
                        Finding(
                            check_id=check.id,
                            finding_type="config_sanity",
                            rule_id="no_uvicorn_reload_in_prod",
                            severity="medium",
                            message="Uvicorn reload appears enabled.",
                            fingerprint="",
                            location=FindingLocation(
                                path=str(path.relative_to(repo_root)), line=idx
                            ),
                        )
                    )
            if "no_allow_all_cors" in check.rules:
                if cors_pattern.search(line):
                    findings.append(
                        Finding(
                            check_id=check.id,
                            finding_type="config_sanity",
                            rule_id="no_allow_all_cors",
                            severity="medium",
                            message="CORS allow-all configuration detected.",
                            fingerprint="",
                            location=FindingLocation(
                                path=str(path.relative_to(repo_root)), line=idx
                            ),
                        )
                    )
            if "no_hardcoded_secrets_like_patterns" in check.rules:
                if secret_pattern.search(line):
                    findings.append(
                        Finding(
                            check_id=check.id,
                            finding_type="config_sanity",
                            rule_id="no_hardcoded_secrets_like_patterns",
                            severity="high",
                            message="Potential hardcoded secret detected.",
                            fingerprint="",
                            location=FindingLocation(
                                path=str(path.relative_to(repo_root)), line=idx
                            ),
                        )
                    )
    return CheckOutcome(check.id, findings, None, None)


def run_runtime_smoke(
    check: RuntimeSmokeCheck, repo_root: Path, env: Dict[str, str]
) -> CheckOutcome:
    return CheckOutcome(check.id, [], "runtime smoke check not implemented", None)
