"""Scan engine: walk paths, extract, run detectors, assemble a Report.

Text detectors run over each document's full extracted text (not per line) so that
multi-line structures such as case captions are detected; each match's offset is then
mapped back to a unit locator (page / paragraph / line). Detector groups are gated by
profile so the repo profile can add secret detectors without changing the core.
"""
from __future__ import annotations

import os
from typing import Iterable, Optional, Union

from .detectors import structured
from .detectors.base import Match, dedupe_matches
from .extract import REPO_TEXT_EXTS, SUPPORTED_EXTS, extract, kind_for
from .models import ExtractedDoc, Finding, Report

PathArg = Union[str, "os.PathLike[str]", Iterable[str]]


def walk_paths(paths: Iterable[str], profile: Optional[str] = None) -> list[str]:
    """Expand files and directories into a sorted list of scannable files.

    Under the repo profile, source and config extensions are walked too so secrets in
    code are reached; explicitly named files are always included.
    """
    exts = SUPPORTED_EXTS | (REPO_TEXT_EXTS if profile == "repo" else set())
    out: list[str] = []
    for p in paths:
        if os.path.isdir(p):
            for root, _dirs, files in os.walk(p):
                # Skip VCS and virtualenv noise.
                if any(seg in root for seg in (os.sep + ".git", os.sep + ".venv",
                                               os.sep + "node_modules")):
                    continue
                for name in files:
                    fp = os.path.join(root, name)
                    if os.path.splitext(fp)[1].lower() in exts:
                        out.append(fp)
        elif os.path.isfile(p):
            out.append(p)
    return sorted(set(out))


def _effective_kind(fp: str, profile: Optional[str]) -> Optional[str]:
    kind = kind_for(fp)
    if kind is not None:
        return kind
    if profile == "repo" and os.path.splitext(fp)[1].lower() in REPO_TEXT_EXTS:
        return "txt"
    return None


def locator_for_offset(doc: ExtractedDoc, offset: int) -> str:
    loc = doc.units[0].locator if doc.units else ""
    for u in doc.units:
        if u.base_offset <= offset:
            loc = u.locator
        else:
            break
    return loc


def collect_matches(full_text: str, profile: Optional[str] = None) -> list[Match]:
    """Run the enabled text-detector groups over the document text.

    Stage 1 wires structured PII. Later stages register name-by-frame, privilege
    banners, and (repo profile only) secret detectors here.
    """
    matches: list[Match] = list(structured.detect_all(full_text))

    # Name-by-frame detection (R-3), added in Stage 2.
    try:
        from .detectors import names as _names  # noqa: WPS433
        matches.extend(_names.detect_all(full_text))
    except ImportError:
        pass

    # Privilege/confidentiality banners (R-5), added in Stage 3.
    try:
        from .detectors import banners as _banners  # noqa: WPS433
        matches.extend(_banners.detect_all(full_text))
    except ImportError:
        pass

    # Secret detectors run for the repo profile (R-8), added in Stage 5.
    if profile == "repo":
        try:
            from .detectors import secrets as _secrets  # noqa: WPS433
            matches.extend(_secrets.detect_all(full_text))
        except ImportError:
            pass

    # Seed-propagation (V-3): names found by frames seed a string-match sweep so bare
    # first/last-name repeats elsewhere are flagged deterministically, no model call.
    name_seeds = [m for m in matches if m.type in ("person_name", "entity_name")]
    if name_seeds:
        from .propagate import propagate_seeds  # noqa: WPS433
        matches.extend(propagate_seeds(full_text, name_seeds))

    return dedupe_matches(matches)


def _matches_to_findings(doc: ExtractedDoc, matches: list[Match]) -> list[Finding]:
    findings: list[Finding] = []
    for m in matches:
        findings.append(
            Finding(
                detector=m.detector,
                type=m.type,
                severity=m.severity,
                file=doc.path,
                location=locator_for_offset(doc, m.start),
                span=(m.start, m.end),
                preview=m.preview,
                role=m.role,
                confidence=m.confidence,
                detail=m.detail,
            )
        )
    return findings


def scan_doc(doc: ExtractedDoc, profile: Optional[str] = None) -> list[Finding]:
    matches = collect_matches(doc.full_text, profile=profile)
    findings = _matches_to_findings(doc, matches)
    findings.extend(doc.metadata_findings)
    return findings


def _normalize_paths(paths: PathArg) -> list[str]:
    if isinstance(paths, (str, os.PathLike)):
        return [os.fspath(paths)]
    return [os.fspath(p) for p in paths]


def scan_path(paths: PathArg, profile: Optional[str] = None) -> Report:
    """Public API: scan one or more files/directories and return a Report."""
    report = Report()
    files = walk_paths(_normalize_paths(paths), profile=profile)
    for fp in files:
        kind = _effective_kind(fp, profile)
        if kind is None:
            continue
        report.scanned_files.append(fp)
        try:
            # The email profile parses a message body and its attachments (R-8).
            if profile == "email" and kind == "eml":
                from .email_scan import scan_eml  # noqa: WPS433
                findings, notes = scan_eml(fp, profile=profile)
                report.add(*findings)
                for note in notes:
                    report.note(note)
                continue
            doc = extract(fp)
            from .detectors.metadata import scan_metadata  # noqa: WPS433
            doc.metadata_findings.extend(scan_metadata(fp, doc.kind))
            for note in doc.notes:
                report.note(note)
            report.add(*scan_doc(doc, profile=profile))
        except Exception as exc:  # noqa: BLE001 - one bad file must not abort the scan
            report.errors.append(f"{fp}: {type(exc).__name__}: {exc}")
    return report
