from __future__ import annotations

import json
import re
import shutil
import subprocess
import time
import tomllib
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
from vibegate.workspace import filter_workspace_files


@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 _script_mentions_uv(value: object) -> bool:
    if isinstance(value, str):
        return "uv" in value.lower()
    if isinstance(value, list):
        return any(_script_mentions_uv(item) for item in value)
    if isinstance(value, dict):
        return any(_script_mentions_uv(item) for item in value.values())
    return False


def _uv_detected(repo_root: Path, workspace_files: List[Path]) -> bool:
    if (repo_root / "uv.lock").exists():
        return True
    pyproject_path = repo_root / "pyproject.toml"
    if not pyproject_path.exists():
        return False
    if pyproject_path not in workspace_files:
        return False
    try:
        payload = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))
    except (OSError, tomllib.TOMLDecodeError):
        return False
    tool_payload = payload.get("tool", {})
    if isinstance(tool_payload, dict) and "uv" in tool_payload:
        return True
    project_scripts = payload.get("project", {}).get("scripts", {})
    poetry_scripts = tool_payload.get("poetry", {}).get("scripts", {})
    pdm_scripts = tool_payload.get("pdm", {}).get("scripts", {})
    return any(
        _script_mentions_uv(scripts)
        for scripts in (project_scripts, poetry_scripts, pdm_scripts)
    )


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

    if tool == "uv" and not _uv_detected(repo_root, workspace_files):
        return CheckOutcome(check.id, [], "uv not detected in project", None)

    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 run_config_sanity(
    check: ConfigSanityCheck, repo_root: Path, workspace_files: List[Path]
) -> CheckOutcome:
    findings: List[Finding] = []
    files = filter_workspace_files(
        workspace_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)
