import datetime as _dt
import re
from dataclasses import asdict, dataclass, field
from typing import Any, Dict, List, Optional


@dataclass(frozen=True)
class ScanRequest:
    domain: str
    http_port: int = 80
    https_port: int = 443
    timeout: float = 5.0
    user_agent: str = "WebSentinel/1.0"
    allow_invasive: bool = False
    # Optional static analysis (SAST) parameters
    source_path: Optional[str] = None
    source_files: tuple[str, ...] = ()
    source_languages: tuple[str, ...] = ()
    source_exclude: tuple[str, ...] = ()
    source_recursive: bool = True
    source_detailed_report: bool = False
    source_min_severity: str = "medium"
    source_max_files: int = -1
    source_max_size_mb: int = -1
    source_advanced_rules: bool = False

    def __post_init__(self):
        normalized_path = None
        if self.source_path is not None:
            path_str = str(self.source_path).strip()
            normalized_path = path_str or None
        object.__setattr__(self, "source_path", normalized_path)
        object.__setattr__(self, "source_files", self._normalize_sequence(self.source_files))
        object.__setattr__(self, "source_languages", self._normalize_sequence(self.source_languages, lower=True, default_if_empty=("auto",)))
        object.__setattr__(self, "source_exclude", self._normalize_sequence(self.source_exclude))

    @staticmethod
    def _normalize_sequence(value, *, lower: bool = False, default_if_empty: Optional[tuple[str, ...]] = None) -> tuple[str, ...]:
        if not value:
            return default_if_empty or ()

        if isinstance(value, str):
            tokens = [token.strip() for token in re.split(r"[;,]\s*|\s+", value) if token.strip()]
        else:
            tokens: List[str] = []
            for item in value:
                if item is None:
                    continue
                if isinstance(item, str):
                    tokens.extend(
                        token.strip()
                        for token in re.split(r"[;,]\s*|\s+", item)
                        if token.strip()
                    )
                else:
                    tokens.append(str(item).strip())
            tokens = [token for token in tokens if token]
        tokens = tokens if isinstance(value, str) else tokens
        if lower:
            tokens = [token.lower() for token in tokens]
        unique_tokens = []
        seen = set()
        for token in tokens:
            lowered = token.lower() if lower else token
            if lowered in seen:
                continue
            seen.add(lowered)
            unique_tokens.append(token)
        if not unique_tokens and default_if_empty:
            return default_if_empty
        return tuple(unique_tokens)

    def to_dict(self) -> Dict[str, Any]:
        result: Dict[str, Any] = {
            "domain": self.domain,
            "http_port": self.http_port,
            "https_port": self.https_port,
            "timeout": self.timeout,
            "user_agent": self.user_agent,
            "allow_invasive": self.allow_invasive,
        }
        if self.source_path is not None:
            result["source_path"] = self.source_path
        if self.source_files:
            result["source_files"] = list(self.source_files)
        if self.source_languages:
            result["source_languages"] = list(self.source_languages)
        if self.source_exclude:
            result["source_exclude"] = list(self.source_exclude)
        result["source_recursive"] = self.source_recursive
        result["source_detailed_report"] = self.source_detailed_report
        result["source_min_severity"] = self.source_min_severity
        result["source_max_files"] = self.source_max_files
        result["source_max_size_mb"] = self.source_max_size_mb
        result["source_advanced_rules"] = self.source_advanced_rules
        return result


@dataclass(frozen=True)
class Finding:
    check: str
    title: str
    severity: str
    description: str
    remediation: str
    impact: Optional[str] = None
    evidence: Optional[str] = None
    i18n_key: Optional[str] = None
    i18n_params: Optional[Dict[str, Any]] = None

    def to_dict(self) -> Dict[str, Any]:
        result = {
            "check": self.check,
            "title": self.title,
            "severity": self.severity,
            "description": self.description,
            "remediation": self.remediation,
            "impact": self.impact,
            "evidence": self.evidence,
        }
        if self.i18n_key is not None:
            result["i18n_key"] = self.i18n_key
        if self.i18n_params:
            result["i18n_params"] = self.i18n_params
        return result


@dataclass
class ScanResult:
    request: ScanRequest
    findings: List[Finding] = field(default_factory=list)
    generated_at: _dt.datetime = field(default_factory=lambda: _dt.datetime.now(_dt.UTC))
    started_at: Optional[_dt.datetime] = None
    finished_at: Optional[_dt.datetime] = None

    def by_severity(self) -> Dict[str, List[Finding]]:
        grouped: Dict[str, List[Finding]] = {}
        for finding in self.findings:
            grouped.setdefault(finding.severity, []).append(finding)
        return grouped

    def to_dict(self) -> Dict[str, Any]:
        result = {
            "request": self.request.to_dict(),
            "findings": [finding.to_dict() for finding in self.findings],
            "generated_at": _format_datetime(self.generated_at),
        }
        if self.started_at:
            result["started_at"] = _format_datetime(self.started_at)
        if self.finished_at:
            result["finished_at"] = _format_datetime(self.finished_at)
        return result

    @classmethod
    def from_dict(cls, data: Dict[str, Any]) -> "ScanResult":
        request_data = data.get("request", {})
        for key in ("source_files", "source_languages", "source_exclude"):
            value = request_data.get(key)
            if isinstance(value, list):
                request_data[key] = tuple(value)
        request = ScanRequest(**request_data)
        findings = [Finding(**finding) for finding in data.get("findings", [])]
        generated_at = _parse_datetime(data.get("generated_at"))
        started_at = _parse_datetime(data.get("started_at"), allow_none=True)
        finished_at = _parse_datetime(data.get("finished_at"), allow_none=True)
        return cls(
            request=request,
            findings=findings,
            generated_at=generated_at,
            started_at=started_at,
            finished_at=finished_at,
        )


def _format_datetime(value: _dt.datetime) -> str:
    if value.tzinfo is None:
        value = value.replace(tzinfo=_dt.UTC)
    value = value.astimezone(_dt.UTC)
    return value.replace(microsecond=0).isoformat().replace("+00:00", "Z")


def _parse_datetime(raw: Optional[str], allow_none: bool = False) -> Optional[_dt.datetime]:
    if raw is None:
        return None if allow_none else _dt.datetime.now(_dt.UTC)
    normalized = raw.replace("Z", "+00:00")
    dt = _dt.datetime.fromisoformat(normalized)
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=_dt.UTC)
    return dt
