from __future__ import annotations

import hashlib
import logging
import os
import shutil
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Sequence, Tuple, TypeGuard

import subprocess
import yaml

from vibegate.artifacts import (
    ArtifactRecord,
    build_fixpack,
    sha256_path,
    write_fixpack,
    write_report,
    write_report_html,
)
from vibegate.checks import (
    CheckOutcome,
    ToolResult,
    detect_packaging_tool,
    run_tool,
    run_bandit,
    run_config_sanity,
    run_dependency_hygiene,
    run_error_handling_check,
    run_defensive_programming_check,
    run_complexity_check,
    run_dead_code_check,
    run_gitleaks,
    run_osv,
    run_pyright,
    run_pytest,
    run_ruff_format,
    run_ruff_lint,
    run_runtime_smoke,
)
from vibegate.config import VibeGateConfig
from vibegate.evidence import EvidenceWriter
from vibegate.findings import Finding
from vibegate.findings import FindingLocation
from vibegate.plugins.api import CheckPlugin, EmitterPlugin, PluginContext
from vibegate.plugins.loader import LoadedPlugin, load_plugins
from vibegate.plugins.types import Finding as PluginFinding
from vibegate.plugins.types import FindingLocation as PluginFindingLocation
from vibegate.workspace import collect_workspace_files


@dataclass(frozen=True)
class SuppressionRecord:
    fingerprint: str
    rule_id: str | None
    justification: str
    expires_at: str
    actor: str | None


def _fingerprint(finding: Finding) -> str:
    location_path = ""
    start_line = ""
    end_line = ""
    if finding.location and finding.location.path:
        location_path = finding.location.path
        start_line = str(finding.location.line or "")
        end_line = str(finding.location.end_line or "")
    payload = (
        f"{finding.check_id}|{finding.rule_id}|{location_path}|"
        f"{start_line}|{end_line}|{finding.message}"
    )
    digest = hashlib.sha256(payload.encode("utf-8")).hexdigest()
    return f"sha256:{digest}"


def _apply_fingerprints(findings: List[Finding]) -> List[Finding]:
    updated = []
    for finding in findings:
        updated.append(
            Finding(
                check_id=finding.check_id,
                finding_type=finding.finding_type,
                rule_id=finding.rule_id,
                severity=finding.severity,
                message=finding.message,
                fingerprint=_fingerprint(finding),
                confidence=finding.confidence,
                rule_version=finding.rule_version,
                tool=finding.tool,
                remediation_hint=finding.remediation_hint,
                location=finding.location,
            )
        )
    return updated


def _finding_sort_key(finding: Finding) -> tuple:
    path = finding.location.path if finding.location and finding.location.path else ""
    line = (
        finding.location.line
        if finding.location and finding.location.line is not None
        else 0
    )
    return (finding.check_id, finding.rule_id, path, line, finding.message)


def _dedupe_findings(findings: List[Finding]) -> List[Finding]:
    seen = set()
    deduped = []
    for finding in findings:
        if finding.fingerprint in seen:
            continue
        seen.add(finding.fingerprint)
        deduped.append(finding)
    return deduped


def _load_suppressions(
    config: VibeGateConfig, repo_root: Path
) -> List[SuppressionRecord]:
    path = repo_root / config.suppressions.path
    if not path.exists():
        return []
    payload = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
    if payload.get("schema_version") != "v1alpha1":
        return []
    records = []
    now = datetime.now(timezone.utc)
    for item in payload.get("suppressions") or []:
        justification = item.get("justification")
        expires_at = item.get("expires_at")
        actor = item.get("actor")
        if config.suppressions.policy.require_justification and not justification:
            continue
        if config.suppressions.policy.require_expiry and not expires_at:
            continue
        if config.suppressions.policy.require_actor and not actor:
            continue
        try:
            expires_dt = datetime.fromisoformat(expires_at.replace("Z", "+00:00"))
        except (TypeError, ValueError):
            continue
        if expires_dt <= now:
            continue
        records.append(
            SuppressionRecord(
                fingerprint=item.get("fingerprint", ""),
                rule_id=item.get("rule_id"),
                justification=justification,
                expires_at=expires_at,
                actor=actor,
            )
        )
    return records


def _apply_suppressions(
    findings: List[Finding],
    suppressions: List[SuppressionRecord],
    evidence: EvidenceWriter,
) -> Tuple[List[Finding], List[Finding]]:
    suppressed = []
    unsuppressed = []
    suppression_map = {record.fingerprint: record for record in suppressions}
    for finding in findings:
        record = suppression_map.get(finding.fingerprint)
        if record and (record.rule_id is None or record.rule_id == finding.rule_id):
            suppressed.append(finding)
            evidence.suppression_applied(
                {
                    "rule_id": finding.rule_id,
                    "fingerprint": finding.fingerprint,
                    "justification": record.justification,
                    "expires_at": record.expires_at,
                    "actor": record.actor,
                }
            )
        else:
            unsuppressed.append(finding)
    return suppressed, unsuppressed


def _matches_policy_rule(
    finding: Finding, severity: str, confidences: List[str]
) -> bool:
    return (
        finding.severity == severity
        and finding.confidence in confidences
    )


def _apply_policy(
    findings: List[Finding], policy
) -> Tuple[List[Finding], List[Finding]]:
    """Classify findings into blocking (fail_on) vs warnings (warn_on).

    Returns (blocking_findings, warning_findings).
    """
    blocking = []
    warnings = []

    for finding in findings:
        is_blocking = False
        for rule in policy.fail_on:
            if _matches_policy_rule(finding, rule.severity, rule.confidence):
                is_blocking = True
                break

        if is_blocking:
            blocking.append(finding)
        else:
            # Check if it matches warn_on rules
            is_warning = False
            for rule in policy.warn_on:
                if _matches_policy_rule(finding, rule.severity, rule.confidence):
                    is_warning = True
                    break
            if is_warning:
                warnings.append(finding)
            # If it doesn't match any policy rules, treat as warning by default
            else:
                warnings.append(finding)

    return blocking, warnings


def _env_allowlist(config: VibeGateConfig) -> dict[str, str]:
    env = dict(os.environ)
    env.update(config.determinism.env)
    return env


def _plugin_logger() -> logging.Logger:
    return logging.getLogger("vibegate.plugins")


def _is_check_plugin(plugin: object) -> TypeGuard[CheckPlugin]:
    return hasattr(plugin, "run")


def _is_emitter_plugin(plugin: object) -> TypeGuard[EmitterPlugin]:
    return hasattr(plugin, "emit")


def _instantiate_plugin(loaded: LoadedPlugin, logger: logging.Logger) -> object | None:
    plugin = loaded.plugin
    config = loaded.config
    if hasattr(plugin, "run") or hasattr(plugin, "emit"):
        return plugin
    if isinstance(plugin, type):
        try:
            return plugin(config)
        except TypeError:
            try:
                return plugin()
            except TypeError as exc:
                logger.warning(
                    "Failed to initialize plugin '%s': %s",
                    loaded.name,
                    exc,
                )
                return None
    if callable(plugin):
        try:
            return plugin(config)
        except TypeError:
            try:
                return plugin()
            except TypeError:
                return plugin
    return plugin


def _convert_plugin_location(
    location: PluginFindingLocation | None,
) -> FindingLocation | None:
    if not location:
        return None
    return FindingLocation(
        path=location.path,
        line=location.line,
        col=location.col,
        end_line=location.end_line,
        end_col=location.end_col,
    )


def _convert_plugin_finding(finding: PluginFinding) -> Finding:
    return Finding(
        check_id=finding.check_id,
        finding_type=finding.finding_type,
        rule_id=finding.rule_id,
        severity=finding.severity,
        message=finding.message,
        fingerprint=finding.fingerprint,
        confidence=finding.confidence,
        rule_version=finding.rule_version,
        tool=finding.tool,
        remediation_hint=finding.remediation_hint,
        location=_convert_plugin_location(finding.location),
    )


def _convert_finding_location(
    location: FindingLocation | None,
) -> PluginFindingLocation | None:
    if not location:
        return None
    return PluginFindingLocation(
        path=location.path,
        line=location.line,
        col=location.col,
        end_line=location.end_line,
        end_col=location.end_col,
    )


def _convert_finding_to_plugin(finding: Finding) -> PluginFinding:
    return PluginFinding(
        check_id=finding.check_id,
        finding_type=finding.finding_type,
        rule_id=finding.rule_id,
        severity=finding.severity,
        message=finding.message,
        fingerprint=finding.fingerprint,
        confidence=finding.confidence,
        rule_version=finding.rule_version,
        tool=finding.tool,
        remediation_hint=finding.remediation_hint,
        location=_convert_finding_location(finding.location),
    )


def _plugin_tool_runner(
    config: VibeGateConfig,
    tool: str,
    args: List[str],
    cwd: Path,
    timeout: int,
    env: dict[str, str],
) -> ToolResult:
    combined_env = _env_allowlist(config)
    combined_env.update(env)
    return run_tool(tool, args, cwd, timeout, combined_env)


def _record_tool_exec(outcome: CheckOutcome, evidence: EvidenceWriter) -> None:
    if not outcome.tool_result:
        return
    artifacts = [
        {"path": str(path), "sha256": sha256_path(path)}
        for path in outcome.tool_result.artifacts
        if path.exists()
    ]
    evidence.tool_exec(
        check_id=outcome.check_id,
        tool=outcome.tool_result.argv[0],
        tool_version=outcome.tool_result.tool_version,
        argv=outcome.tool_result.argv,
        cwd=str(evidence.repo_root),
        duration_ms=outcome.tool_result.duration_ms,
        exit_code=outcome.tool_result.exit_code,
        artifacts=artifacts,
    )


def _tooling_findings(config: VibeGateConfig, repo_root: Path) -> List[Finding]:
    requirements: list[tuple[str, str]] = []
    if config.checks.formatting.enabled:
        requirements.append((config.checks.formatting.id, "ruff"))
    if config.checks.lint.enabled:
        requirements.append((config.checks.lint.id, "ruff"))
    if config.checks.typecheck.enabled:
        requirements.append((config.checks.typecheck.id, "pyright"))
    if config.checks.tests.enabled:
        requirements.append((config.checks.tests.id, "pytest"))
    if config.checks.sast.enabled:
        requirements.append((config.checks.sast.id, "bandit"))
    if config.checks.secrets.enabled:
        requirements.append((config.checks.secrets.id, "gitleaks"))
    vulnerability = config.checks.vulnerability
    if (
        vulnerability.enabled
        and vulnerability.mode != "skip"
        and vulnerability.local_db is not None
        and (repo_root / vulnerability.local_db.path).exists()
    ):
        requirements.append((vulnerability.id, "osv-scanner"))

    findings = []
    for check_id, tool in requirements:
        if shutil.which(tool):
            continue
        findings.append(
            Finding(
                check_id=check_id,
                finding_type="tooling",
                rule_id="MISSING_TOOL",
                severity="high",
                message=(
                    f"Required tool '{tool}' not found for enabled check {check_id}."
                ),
                fingerprint="",
                confidence="high",
                tool=tool,
                remediation_hint=(
                    f"Install {tool} (pipx install {tool} or pip install {tool}) "
                    "or disable the check in vibegate.yaml."
                ),
            )
        )
    return findings


def _run_check_plugins(
    config: VibeGateConfig,
    repo_root: Path,
    workspace_files: Sequence[Path],
    evidence: EvidenceWriter,
) -> List[Finding]:
    logger = _plugin_logger()
    loaded_plugins = load_plugins("vibegate.checks", config.plugins.checks, logger)
    findings: List[Finding] = []
    context = PluginContext(
        repo_root=repo_root,
        config=config,
        workspace_files=workspace_files,
        tool_runner=lambda tool, args, cwd, timeout, env: _plugin_tool_runner(
            config, tool, list(args), cwd, timeout, env
        ),
        logger=logger,
        evidence=evidence,
    )

    for loaded in loaded_plugins:
        plugin = _instantiate_plugin(loaded, logger)
        if plugin is None:
            continue
        if not _is_check_plugin(plugin):
            logger.warning(
                "Plugin '%s' does not implement run() and will be skipped.",
                loaded.name,
            )
            continue
        try:
            plugin_findings = plugin.run(context)
        except Exception as exc:  # noqa: BLE001
            logger.warning("Plugin '%s' failed during run(): %s", loaded.name, exc)
            continue
        if not plugin_findings:
            continue
        for finding in plugin_findings:
            if isinstance(finding, Finding):
                findings.append(finding)
                continue
            try:
                findings.append(_convert_plugin_finding(finding))
            except AttributeError:
                logger.warning(
                    "Plugin '%s' produced an invalid finding and it was ignored.",
                    loaded.name,
                )
    return findings


def _ensure_emitter_artifact_path(
    plugin_name: str, repo_root: Path, path: Path, logger: logging.Logger
) -> Path | None:
    resolved = path if path.is_absolute() else repo_root / path
    if not resolved.exists():
        logger.warning("Emitter '%s' returned missing artifact %s.", plugin_name, path)
        return None
    artifacts_root = repo_root / "artifacts"
    artifacts_root.mkdir(parents=True, exist_ok=True)
    try:
        resolved.relative_to(artifacts_root)
        return resolved
    except ValueError:
        target_name = f"{plugin_name}-{resolved.name}" if resolved.name else plugin_name
        target = artifacts_root / target_name
        if resolved.is_dir():
            logger.warning(
                "Emitter '%s' produced a directory artifact which is unsupported.",
                plugin_name,
            )
            return None
        shutil.copy2(resolved, target)
        return target


def _run_emitter_plugins(
    config: VibeGateConfig,
    repo_root: Path,
    workspace_files: Sequence[Path],
    findings: List[Finding],
    evidence: EvidenceWriter,
) -> List[ArtifactRecord]:
    logger = _plugin_logger()
    loaded_plugins = load_plugins("vibegate.emitters", config.plugins.emitters, logger)
    if not loaded_plugins:
        return []
    plugin_findings = [_convert_finding_to_plugin(finding) for finding in findings]
    context = PluginContext(
        repo_root=repo_root,
        config=config,
        workspace_files=workspace_files,
        tool_runner=lambda tool, args, cwd, timeout, env: _plugin_tool_runner(
            config, tool, list(args), cwd, timeout, env
        ),
        logger=logger,
        evidence=evidence,
    )
    artifacts: List[ArtifactRecord] = []
    for loaded in loaded_plugins:
        plugin = _instantiate_plugin(loaded, logger)
        if plugin is None:
            continue
        if not _is_emitter_plugin(plugin):
            logger.warning(
                "Plugin '%s' does not implement emit() and will be skipped.",
                loaded.name,
            )
            continue
        try:
            output = plugin.emit(context, plugin_findings)
        except Exception as exc:  # noqa: BLE001
            logger.warning("Emitter '%s' failed during emit(): %s", loaded.name, exc)
            continue
        if not output:
            continue
        artifact_path = _ensure_emitter_artifact_path(
            loaded.name, repo_root, Path(output), logger
        )
        if not artifact_path:
            continue
        if not artifact_path.is_file():
            logger.warning(
                "Emitter '%s' produced a non-file artifact and it was ignored.",
                loaded.name,
            )
            continue
        artifacts.append(
            ArtifactRecord(
                name=f"emitter_{loaded.name}",
                path=artifact_path,
                sha256=sha256_path(artifact_path),
            )
        )
    return artifacts


def _run_all_checks(
    config: VibeGateConfig,
    repo_root: Path,
    workspace_files: Sequence[Path],
    evidence: EvidenceWriter,
) -> Tuple[List[Finding], List[dict[str, str]]]:
    env = _env_allowlist(config)
    findings: List[Finding] = _tooling_findings(config, repo_root)
    skipped_checks: List[dict[str, str]] = []
    checks = [
        ("formatting", config.checks.formatting, run_ruff_format),
        ("lint", config.checks.lint, run_ruff_lint),
        ("typecheck", config.checks.typecheck, run_pyright),
        ("tests", config.checks.tests, run_pytest),
        (
            "dependency_hygiene",
            config.checks.dependency_hygiene,
            run_dependency_hygiene,
        ),
        ("sast", config.checks.sast, run_bandit),
        ("secrets", config.checks.secrets, run_gitleaks),
        ("vulnerability", config.checks.vulnerability, run_osv),
        ("config_sanity", config.checks.config_sanity, run_config_sanity),
        ("runtime_smoke", config.checks.runtime_smoke, run_runtime_smoke),
        ("error_handling", config.checks.error_handling, run_error_handling_check),
        (
            "defensive_coding",
            config.checks.defensive_coding,
            run_defensive_programming_check,
        ),
        ("complexity", config.checks.complexity, run_complexity_check),
        ("dead_code", config.checks.dead_code, run_dead_code_check),
    ]

    for _, check, runner in checks:
        if not check.enabled:
            skipped_checks.append({"check_id": check.id, "reason": "disabled"})
            continue
        if runner is run_dependency_hygiene:
            outcome = runner(check, repo_root, env, config, workspace_files)
        elif runner is run_config_sanity:
            outcome = runner(check, repo_root, workspace_files)
        elif runner is run_error_handling_check:
            outcome = runner(check, repo_root, workspace_files)
        elif runner is run_defensive_programming_check:
            outcome = runner(check, repo_root, workspace_files)
        elif runner is run_complexity_check:
            outcome = runner(check, repo_root, workspace_files)
        elif runner is run_dead_code_check:
            outcome = runner(check, repo_root, workspace_files, env)
        else:
            outcome = runner(check, repo_root, env)
        if outcome.skipped_reason:
            skipped_checks.append(
                {
                    "check_id": outcome.check_id,
                    "reason": f"SKIPPED: {outcome.skipped_reason}",
                }
            )
        if outcome.tool_result:
            _record_tool_exec(outcome, evidence)
        findings.extend(outcome.findings)

    findings.extend(_run_check_plugins(config, repo_root, workspace_files, evidence))

    return findings, skipped_checks


def _selected_packaging(config: VibeGateConfig) -> dict[str, object]:
    tool = detect_packaging_tool(config)
    lockfiles = config.packaging.lockfiles
    if not lockfiles:
        from vibegate.checks import _default_lockfiles

        lockfiles = _default_lockfiles(tool)
    return {"tool": tool, "lockfiles": lockfiles}


def _toolchain_versions() -> List[dict[str, str]]:
    tools = ["ruff", "pyright", "pytest", "bandit", "gitleaks", "osv-scanner", "uv"]
    versions = []
    for tool in tools:
        if not shutil.which(tool):
            continue
        try:
            result = subprocess.run(
                [tool, "--version"],
                capture_output=True,
                text=True,
                timeout=2,
                check=False,
            )
        except (OSError, subprocess.SubprocessError):
            continue
        if result.returncode != 0:
            continue
        output = (result.stdout or result.stderr or "").strip()
        if not output:
            continue
        versions.append({"tool": tool, "version": output.splitlines()[0].strip()})
    return versions


def run_check(
    config: VibeGateConfig, repo_root: Path
) -> tuple[List[ArtifactRecord], str]:
    evidence = EvidenceWriter(config, repo_root)
    evidence.start_run(_selected_packaging(config), _toolchain_versions())

    started = time.monotonic()
    workspace_files = collect_workspace_files(repo_root)
    findings, skipped_checks = _run_all_checks(
        config, repo_root, workspace_files, evidence
    )
    findings = _apply_fingerprints(findings)
    findings = _dedupe_findings(findings)
    findings = sorted(findings, key=_finding_sort_key)

    for finding in findings:
        evidence.finding(finding.event_payload())

    suppressions = _load_suppressions(config, repo_root)
    suppressed, unsuppressed = _apply_suppressions(findings, suppressions, evidence)

    # Apply policy to determine blocking vs warning findings
    blocking, warnings = _apply_policy(unsuppressed, config.policy)

    status = "FAIL" if blocking else "PASS"
    report_artifacts = [
        write_report(config.outputs, status, unsuppressed, skipped_checks)
    ]
    if config.outputs.emit_html:
        report_artifacts.append(write_report_html(config.outputs))

    duration_ms = int((time.monotonic() - started) * 1000)
    counts = {
        "findings_total": len(findings),
        "findings_unsuppressed": len(unsuppressed),
        "findings_blocking": len(blocking),
        "findings_warning": len(warnings),
        "suppressed_total": len(suppressed),
    }
    evidence.run_summary(status, duration_ms, counts, skipped_checks)

    evidence_sha = (
        sha256_path(config.outputs.evidence_jsonl)
        if config.outputs.evidence_jsonl.exists()
        else ""
    )
    contract_sha = (
        sha256_path(config.contract_path)
        if config.contract_path and config.contract_path.exists()
        else ""
    )
    fixpack_payload = build_fixpack(
        evidence.run_id, unsuppressed, contract_sha, evidence_sha
    )
    fixpack_artifacts = write_fixpack(config.outputs, fixpack_payload, status)
    emitter_artifacts = _run_emitter_plugins(
        config, repo_root, workspace_files, unsuppressed, evidence
    )

    return report_artifacts + fixpack_artifacts + emitter_artifacts, status


def run_fixpack(
    config: VibeGateConfig, repo_root: Path
) -> tuple[List[ArtifactRecord], str]:
    evidence = EvidenceWriter(config, repo_root)
    evidence.start_run(_selected_packaging(config), _toolchain_versions())

    started = time.monotonic()
    workspace_files = collect_workspace_files(repo_root)
    findings, skipped_checks = _run_all_checks(
        config, repo_root, workspace_files, evidence
    )
    findings = _apply_fingerprints(findings)
    findings = _dedupe_findings(findings)
    findings = sorted(findings, key=_finding_sort_key)

    for finding in findings:
        evidence.finding(finding.event_payload())

    suppressions = _load_suppressions(config, repo_root)
    suppressed, unsuppressed = _apply_suppressions(findings, suppressions, evidence)

    # Apply policy to determine blocking vs warning findings
    blocking, warnings = _apply_policy(unsuppressed, config.policy)

    status = "FAIL" if blocking else "PASS"
    duration_ms = int((time.monotonic() - started) * 1000)
    counts = {
        "findings_total": len(findings),
        "findings_unsuppressed": len(unsuppressed),
        "findings_blocking": len(blocking),
        "findings_warning": len(warnings),
        "suppressed_total": len(suppressed),
    }
    evidence.run_summary(status, duration_ms, counts, skipped_checks)

    evidence_sha = (
        sha256_path(config.outputs.evidence_jsonl)
        if config.outputs.evidence_jsonl.exists()
        else ""
    )
    contract_sha = (
        sha256_path(config.contract_path)
        if config.contract_path and config.contract_path.exists()
        else ""
    )
    fixpack_payload = build_fixpack(
        evidence.run_id, unsuppressed, contract_sha, evidence_sha
    )
    fixpack_artifacts = write_fixpack(config.outputs, fixpack_payload, status)
    emitter_artifacts = _run_emitter_plugins(
        config, repo_root, workspace_files, unsuppressed, evidence
    )

    return fixpack_artifacts + emitter_artifacts, status
