from __future__ import annotations

from dataclasses import dataclass
from typing import Any, Dict, List, Literal

CollectorState = Literal["ok", "partial", "skipped", "error"]
ScanMode = Literal["quick", "full"]
OSFamily = Literal["windows", "macos", "linux", "unknown"]

META_SCHEMA_VERSION = 1
ALLOWED_COLLECTOR_STATES = {"ok", "partial", "skipped", "error"}
ALLOWED_MODES = {"quick", "full"}
ALLOWED_OS_FAMILY = {"windows", "macos", "linux", "unknown"}


class SchemaError(Exception):
    pass


@dataclass(frozen=True)
class ValidationIssue:
    level: Literal["error", "warning"]
    path: str
    message: str


def _is_str(x: Any) -> bool:
    return isinstance(x, str) and len(x) > 0


def _is_int(x: Any) -> bool:
    return isinstance(x, int) and not isinstance(x, bool)


def _get(d: Dict[str, Any], key: str) -> Any:
    return d.get(key)


def validate_meta(meta: Dict[str, Any]) -> List[ValidationIssue]:
    """
    Validate meta.json for MVP contract.
    Returns list of issues (errors/warnings). Caller decides whether to fail.
    """
    issues: List[ValidationIssue] = []

    def err(path: str, msg: str) -> None:
        issues.append(ValidationIssue("error", path, msg))

    def warn(path: str, msg: str) -> None:
        issues.append(ValidationIssue("warning", path, msg))

    sv = _get(meta, "schema_version")
    if not _is_int(sv):
        err("$.schema_version", "schema_version must be an int.")
    elif sv != META_SCHEMA_VERSION:
        warn("$.schema_version", f"expected {META_SCHEMA_VERSION}, got {sv}.")

    product = _get(meta, "product")
    if not _is_str(product):
        err("$.product", "product must be a non-empty string.")
    elif product != "auditwalk":
        warn("$.product", "product is expected to be 'auditwalk'.")

    pv = _get(meta, "product_version")
    if not _is_str(pv):
        err("$.product_version", "product_version must be a non-empty string.")

    run_id = _get(meta, "run_id")
    if not _is_str(run_id):
        err("$.run_id", "run_id must be a non-empty string.")
    elif not run_id.startswith("run_"):
        err("$.run_id", "run_id must start with 'run_'.")

    created_utc = _get(meta, "created_utc")
    if not _is_int(created_utc):
        err("$.created_utc", "created_utc must be an int (unix seconds).")
    elif created_utc <= 0:
        err("$.created_utc", "created_utc must be > 0.")

    created_tz_offset = _get(meta, "created_tz_offset")
    if created_tz_offset is not None and not isinstance(created_tz_offset, str):
        err("$.created_tz_offset", "created_tz_offset must be a string if present.")

    mode = _get(meta, "mode")
    if not _is_str(mode):
        err("$.mode", "mode must be a non-empty string.")
    elif mode not in ALLOWED_MODES:
        err("$.mode", f"mode must be one of {sorted(ALLOWED_MODES)}.")

    label = _get(meta, "label")
    if label is not None and not isinstance(label, str):
        err("$.label", "label must be a string if present.")

    baseline = _get(meta, "baseline")
    if baseline is not None and not isinstance(baseline, bool):
        err("$.baseline", "baseline must be boolean if present.")

    host = _get(meta, "host")
    if not isinstance(host, dict):
        err("$.host", "host must be an object.")
    else:
        hostname = host.get("hostname")
        if not _is_str(hostname):
            err("$.host.hostname", "hostname must be a non-empty string.")

        os_family = host.get("os_family", "unknown")
        if not _is_str(os_family):
            err("$.host.os_family", "os_family must be a string.")
        elif os_family not in ALLOWED_OS_FAMILY:
            warn("$.host.os_family", f"os_family should be one of {sorted(ALLOWED_OS_FAMILY)}.")

        arch = host.get("arch")
        if not _is_str(arch):
            err("$.host.arch", "arch must be a non-empty string.")

        for key in ("os_name", "os_version"):
            if key in host and host[key] is not None and not isinstance(host[key], str):
                err(f"$.host.{key}", f"{key} must be a string if present.")

    cs = _get(meta, "collector_status")
    if not isinstance(cs, dict):
        err("$.collector_status", "collector_status must be an object.")
    else:
        expected = ["system", "disk", "processes", "network", "persistence", "installed", "files", "firewall"]
        for key in expected:
            if key not in cs:
                warn(f"$.collector_status.{key}", "missing collector status key.")
                continue
            value = cs.get(key)
            if not _is_str(value):
                err(f"$.collector_status.{key}", "collector status must be a string.")
            elif value not in ALLOWED_COLLECTOR_STATES:
                err(f"$.collector_status.{key}", f"must be one of {sorted(ALLOWED_COLLECTOR_STATES)}.")

    opts = _get(meta, "options")
    if not isinstance(opts, dict):
        err("$.options", "options must be an object.")
    else:
        if not isinstance(opts.get("hash"), bool):
            err("$.options.hash", "hash must be boolean.")

        max_files = opts.get("max_files")
        if not _is_int(max_files) or max_files <= 0:
            err("$.options.max_files", "max_files must be a positive int.")

        max_procs = opts.get("max_procs")
        if not _is_int(max_procs) or max_procs <= 0:
            err("$.options.max_procs", "max_procs must be a positive int.")

        timeout_seconds = opts.get("timeout_seconds")
        if not _is_int(timeout_seconds) or timeout_seconds <= 0:
            err("$.options.timeout_seconds", "timeout_seconds must be a positive int.")

        for key in ("roots", "exclude"):
            if key in opts and opts[key] is not None:
                if not isinstance(opts[key], list) or not all(isinstance(x, str) for x in opts[key]):
                    err(f"$.options.{key}", f"{key} must be a list of strings if present.")

    notes = _get(meta, "notes")
    if notes is not None:
        if not isinstance(notes, dict):
            err("$.notes", "notes must be an object if present.")
        else:
            for key in ("warnings", "errors"):
                if key in notes and notes[key] is not None:
                    if not isinstance(notes[key], list) or not all(isinstance(x, str) for x in notes[key]):
                        err(f"$.notes.{key}", f"{key} must be a list of strings if present.")

    return issues


def assert_meta_valid(meta: Dict[str, Any]) -> None:
    issues = validate_meta(meta)
    errors = [issue for issue in issues if issue.level == "error"]
    if errors:
        msgs = "\n".join([f"{issue.path}: {issue.message}" for issue in errors])
        raise SchemaError(f"meta.json validation failed:\n{msgs}")


def normalize_meta(meta: Dict[str, Any]) -> Dict[str, Any]:
    """
    Fill safe defaults (does not invent run identity fields).
    """
    out = dict(meta)
    out.setdefault("schema_version", META_SCHEMA_VERSION)
    out.setdefault("product", "auditwalk")
    out.setdefault("product_version", "0.1.0")

    host = dict(out.get("host") or {})
    host.setdefault("os_family", "unknown")
    out["host"] = host

    out.setdefault("collector_status", {})

    if "notes" in out and out["notes"] is None:
        out.pop("notes")
    return out

def validate_score(score: Dict[str, Any]) -> List[ValidationIssue]:
    issues: List[ValidationIssue] = []

    def err(path: str, msg: str) -> None:
        issues.append(ValidationIssue("error", path, msg))

    total = score.get("total")
    if not _is_int(total):
        err("$.total", "total must be an int.")
    elif total < 0:
        err("$.total", "total must be >= 0.")

    tier = score.get("tier")
    if tier is not None and not isinstance(tier, str):
        err("$.tier", "tier must be a string if present.")

    breakdown = score.get("breakdown")
    if breakdown is not None and not isinstance(breakdown, dict):
        err("$.breakdown", "breakdown must be an object if present.")

    findings = score.get("findings")
    if findings is not None:
        if not isinstance(findings, list):
            err("$.findings", "findings must be a list if present.")
        else:
            for i, f in enumerate(findings):
                if not isinstance(f, dict):
                    err(f"$.findings[{i}]", "each finding must be an object.")
                    continue
                if "id" in f and f["id"] is not None and not isinstance(f["id"], str):
                    err(f"$.findings[{i}].id", "id must be a string if present.")
                if "severity" in f and f["severity"] is not None and not isinstance(f["severity"], str):
                    err(f"$.findings[{i}].severity", "severity must be a string if present.")

    return issues


def assert_score_valid(score: Dict[str, Any]) -> None:
    issues = validate_score(score)
    errors = [i for i in issues if i.level == "error"]
    if errors:
        msgs = "\n".join([f"{i.path}: {i.message}" for i in errors])
        raise SchemaError(f"score.json validation failed:\n{msgs}")
