"""Core rule engine — runs scanners and collects normalized findings."""

from __future__ import annotations

import logging
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Optional

from ..scanners.detector import Platform, DetectionResult
from .config import ScanConfig

logger = logging.getLogger("oncecheck")


@dataclass
class Finding:
    """A single compliance finding produced by a scanner."""
    rule_id: str
    severity: str          # "FAIL", "WARN", "INFO"
    message: str
    fix: str
    reference: str = ""
    file_path: str = ""    # optional: specific file where issue was found
    line: int = 0          # optional: line number
    engine: str = ""       # heuristic | codeql | semgrep
    confidence: float = 0.0
    trace: List[str] = field(default_factory=list)
    policy_clause: str = ""


@dataclass
class ScanResult:
    """Aggregated results from a full project scan."""
    platform: Platform
    project_type: str
    project_root: Path
    findings: List[Finding] = field(default_factory=list)
    scan_time_seconds: float = 0.0
    suppressed_count: int = 0
    gated_count: int = 0

    @property
    def fail_count(self) -> int:
        return sum(1 for f in self.findings if f.severity == "FAIL")

    @property
    def warn_count(self) -> int:
        return sum(1 for f in self.findings if f.severity == "WARN")

    @property
    def info_count(self) -> int:
        return sum(1 for f in self.findings if f.severity == "INFO")

    @property
    def has_failures(self) -> bool:
        return self.fail_count > 0

    @property
    def has_warnings(self) -> bool:
        return self.warn_count > 0

    def to_dict(self) -> dict:
        result = {
            "platform": self.platform.value,
            "project_type": self.project_type,
            "project_root": str(self.project_root),
            "scan_time_seconds": round(self.scan_time_seconds, 2),
            "summary": {
                "fail": self.fail_count,
                "warn": self.warn_count,
                "info": self.info_count,
            },
            "findings": [
                {
                    "id": f.rule_id,
                    "severity": f.severity,
                    "message": f.message,
                    "fix": f.fix,
                    "reference": f.reference,
                    "file_path": f.file_path,
                    "line": f.line,
                    "engine": f.engine,
                    "confidence": round(float(f.confidence or 0.0), 4),
                    "trace": list(f.trace),
                    "policy_clause": f.policy_clause,
                }
                for f in self.findings
            ],
        }
        if self.suppressed_count:
            result["summary"]["suppressed"] = self.suppressed_count
        if self.gated_count:
            result["summary"]["gated"] = self.gated_count
        return result


def _resolve_analysis_mode(requested_mode: str, allowed_engines: Optional[List[str]]) -> str:
    """Downgrade analysis mode if the plan doesn't permit the requested engines."""
    if allowed_engines is None:
        return requested_mode

    engine_set = set(allowed_engines)
    has_advanced = "codeql" in engine_set or "semgrep" in engine_set

    if requested_mode in ("advanced", "hybrid") and not has_advanced:
        logger.info(
            "Plan restricts engines to %s — downgrading analysis mode from '%s' to 'heuristic'",
            allowed_engines, requested_mode,
        )
        return "heuristic"
    return requested_mode


def run_scan(
    detection: DetectionResult,
    config: Optional[ScanConfig] = None,
    allowed_engines: Optional[List[str]] = None,
    allowed_rules: Optional[List[str]] = None,
    analysis_mode: str = "heuristic",
    require_compiler_engine: bool = False,
    advanced_profile: str = "balanced",
    progress_callback=None,
) -> ScanResult:
    """Execute the appropriate scanner for the detected platform.

    Engine restrictions are enforced via allowed_engines from the server.
    Rule filtering is enforced both here (pre-filter) and server-side
    (via /api/scan-process) for defense-in-depth. When allowed_rules is
    provided (e.g. starter plan), only matching findings are kept — the
    rest never reach CLI memory or the network.
    """
    start = time.monotonic()

    # Enforce server engine restrictions
    normalized_mode = _resolve_analysis_mode(
        (analysis_mode or "heuristic").strip().lower(),
        allowed_engines,
    )
    valid_modes = {"heuristic", "advanced", "hybrid"}
    if normalized_mode not in valid_modes:
        raise ValueError(
            f"Invalid analysis_mode='{analysis_mode}'. Expected one of: heuristic, advanced, hybrid."
        )

    logger.info("Scanning %s (%s) at %s [mode=%s, engines=%s]",
                detection.platform.value, detection.project_type,
                detection.project_root, normalized_mode, allowed_engines)

    findings: List[Finding] = []

    if normalized_mode in ("heuristic", "hybrid"):
        if progress_callback:
            progress_callback(f"Running {detection.platform.value} heuristic checks...")
        if detection.platform == Platform.IOS:
            from ..scanners.ios import scan
        elif detection.platform == Platform.ANDROID:
            from ..scanners.android import scan
        elif detection.platform == Platform.WEB:
            from ..scanners.web import scan
        else:
            raise ValueError(f"Unsupported platform: {detection.platform}")

        findings.extend(scan(detection.project_root))

        # Run cross-platform / supply chain checks for all platforms
        if progress_callback:
            progress_callback("Running cross-platform checks...")
        from ..scanners.common import scan as common_scan
        findings.extend(common_scan(detection.project_root))

    if normalized_mode in ("advanced", "hybrid"):
        from .advanced_analyzers import run_advanced_analysis
        findings.extend(
            run_advanced_analysis(
                detection.project_root,
                strict=require_compiler_engine,
                profile=advanced_profile,
                progress_callback=progress_callback,
            )
        )

    # Pre-filter: drop findings for rules the plan doesn't cover.
    # This prevents disallowed findings from existing in CLI memory
    # or being sent over the network. Server still re-validates.
    if allowed_rules is not None:
        allowed_set = set(allowed_rules)
        pre_filter_count = len(findings)
        findings = [f for f in findings if f.rule_id in allowed_set]
        dropped = pre_filter_count - len(findings)
        if dropped:
            logger.info("Pre-filtered %d findings not in plan's allowed rules", dropped)

    if progress_callback:
        progress_callback("Normalizing findings against rule catalogs...")

    from ..rules.loader import apply_catalog_to_findings, get_rule_index

    # Normalize finding metadata from YAML catalogs so scanner code and catalogs cannot drift.
    platform_rules = get_rule_index(detection.platform.value)
    common_rules = get_rule_index("common")
    catalog_rule_ids = set(platform_rules) | set(common_rules)

    platform_catalog_findings = [f for f in findings if f.rule_id in platform_rules]
    common_catalog_findings = [f for f in findings if f.rule_id in common_rules]
    out_of_catalog = [f for f in findings if f.rule_id not in catalog_rule_ids]

    platform_unknown = apply_catalog_to_findings(detection.platform.value, platform_catalog_findings)
    common_unknown = apply_catalog_to_findings("common", common_catalog_findings)
    unknown_rule_ids = sorted(set(platform_unknown + common_unknown))
    unknown_rule_ids.extend(
        sorted(
            {
                f.rule_id
                for f in out_of_catalog
                if not (f.rule_id.startswith("SG-") or f.rule_id.startswith("CQ-"))
            }
        )
    )
    unknown_rule_ids = sorted(set(unknown_rule_ids))
    if unknown_rule_ids:
        logger.warning("Rules emitted without catalog entries: %s", ", ".join(unknown_rule_ids))

    # Normalize confidence/engine metadata and attach policy clauses.
    if progress_callback:
        progress_callback("Applying policy clauses...")
    from ..rules.policy_map import apply_policy_metadata
    severity_default_conf = {"FAIL": 0.9, "WARN": 0.75, "INFO": 0.6}
    for finding in findings:
        if not finding.engine:
            if finding.rule_id.startswith("CQ-"):
                finding.engine = "codeql"
            elif finding.rule_id.startswith("SG-"):
                finding.engine = "semgrep"
            else:
                finding.engine = "heuristic"
        if finding.confidence <= 0:
            finding.confidence = severity_default_conf.get(finding.severity, 0.6)
        apply_policy_metadata(finding)

    logger.info("Found %d raw findings", len(findings))

    # Apply config: suppression + severity overrides
    suppressed_count = 0
    if config:
        original_count = len(findings)
        findings = config.filter_findings(findings)
        suppressed_count = original_count - len(findings)

    # Rule filtering: pre-filtered above (allowed_rules) + server re-validates
    # via /api/scan-process. Local suppressions (config-based) applied above.

    elapsed = time.monotonic() - start
    logger.info("Scan completed in %.2fs (%d findings, %d suppressed)", elapsed, len(findings), suppressed_count)

    return ScanResult(
        platform=detection.platform,
        project_type=detection.project_type,
        project_root=detection.project_root,
        findings=findings,
        scan_time_seconds=elapsed,
        suppressed_count=suppressed_count,
    )
