"""Sanitize mode and the hashed mapping (R-9).

Writes a safe copy per input file with consistent role-based pseudonyms, sequential
phones and emails, generic addresses, format-preserving masks for SSN and account
identifiers, and a single per-document date shift that preserves intervals. Output
metadata is stripped. The real-to-dummy mapping is written to a salted-hash sidecar
under a private map directory, never under --out and never inside the package.
"""
from __future__ import annotations

import os
import random
from dataclasses import dataclass, field
from typing import Optional

from .detectors import names, structured
from .detectors.base import Match, dedupe_matches
from .extract import extract
from .mapping import (Mapping, default_map_dir, get_or_create_salt, write_sidecar)
from .models import ExtractedDoc, Report
from .pseudonyms import find_dates, make_assigner, shift_date

REPLACE_NAME_TYPES = {"person_name", "entity_name"}
REPLACE_STRUCTURED_TYPES = {
    "ssn", "ein", "bank_account", "routing_number", "payment_card",
    "phone", "email", "address",
}
# Not replaced: case_number (public docket; R-9 does not list it), banners, secrets.


@dataclass
class SanitizeResult:
    outputs: list[str] = field(default_factory=list)
    map_paths: list[str] = field(default_factory=list)
    report: Report = field(default_factory=Report)
    notes: list[str] = field(default_factory=list)
    # V-2: per-output "verified clean?" after the verify pass re-scanned the safe copy.
    verified: dict = field(default_factory=dict)

    def to_dict(self) -> dict:
        return {
            "outputs": list(self.outputs),
            "map_paths": list(self.map_paths),
            "notes": list(self.notes),
            "verified": dict(self.verified),
            "report": self.report.to_dict(),
        }


@dataclass
class VerifyResult:
    """Outcome of re-scanning a sanitized output for residual sensitive content (V-2)."""
    verified: bool
    residue: list = field(default_factory=list)       # non-dummy findings in the safe copy
    remediated: bool = False                           # a conservative mask was applied
    residue_after: list = field(default_factory=list)  # still present after mask + recheck


def apply_replacements(text: str, repls: list[tuple[int, int, str]]) -> str:
    out = text
    for start, end, replacement in sorted(repls, key=lambda r: r[0], reverse=True):
        out = out[:start] + replacement + out[end:]
    return out


def _replaceable_matches(text: str) -> list[Match]:
    combined = list(names.detect_all(text)) + list(structured.detect_all(text))
    # Merge open-set NER names when the tier is installed; a no-op otherwise (R-11).
    from .ner import ner_name_matches
    combined += ner_name_matches(text)
    keep = [m for m in combined
            if m.type in REPLACE_NAME_TYPES or m.type in REPLACE_STRUCTURED_TYPES]
    return dedupe_matches(keep)


def sanitize_text(text: str, mapping: Mapping, offset_days: int) -> tuple[str, list]:
    """Return (sanitized_text, list_of_(start,end,real,dummy,type)) for the report."""
    repls: list[tuple[int, int, str]] = []
    audit: list[tuple[int, int, str, str, str]] = []
    used_spans: list[tuple[int, int]] = []

    def _overlaps(s: int, e: int) -> bool:
        return any(not (e <= us or s >= ue) for us, ue in used_spans)

    for m in _replaceable_matches(text):
        if _overlaps(m.start, m.end):
            continue
        dummy = mapping.get_or_assign(m.raw, ftype=m.type, role=m.role,
                                      assigner=make_assigner(m.raw))
        repls.append((m.start, m.end, dummy))
        audit.append((m.start, m.end, m.raw, dummy, m.type))
        used_spans.append((m.start, m.end))

    for start, end, raw, idx in find_dates(text):
        if _overlaps(start, end):
            continue
        shifted = shift_date(raw, idx, offset_days)
        if not shifted:
            continue
        mapping.get_or_assign(raw, ftype="date", role=None,
                              assigner=lambda role, ftype, seq, _s=shifted: _s)
        repls.append((start, end, shifted))
        audit.append((start, end, raw, shifted, "date"))
        used_spans.append((start, end))

    return apply_replacements(text, repls), audit


def _write_txt(out_dir: str, base: str, text: str) -> str:
    path = os.path.join(out_dir, base)
    with open(path, "w", encoding="utf-8") as fh:
        fh.write(text)
    return path


def _write_pdf_as_text(out_dir: str, base: str, text: str) -> str:
    # No reliable in-place PDF text edit and no OCR; emit a metadata-free text rendering.
    path = os.path.join(out_dir, base + ".sanitized.txt")
    with open(path, "w", encoding="utf-8") as fh:
        fh.write(text)
    return path


def _write_docx(out_dir: str, base: str, text: str) -> str:
    import docx

    document = docx.Document()
    for line in text.split("\n"):
        document.add_paragraph(line)
    # Strip identifying core properties so the safe copy carries no author/metadata.
    cp = document.core_properties
    for attr in ("author", "last_modified_by", "title", "comments", "category",
                 "keywords", "subject", "company"):
        try:
            setattr(cp, attr, "")
        except (AttributeError, ValueError):
            pass
    path = os.path.join(out_dir, base)
    document.save(path)
    return path


def _write_image_stripped(out_dir: str, base: str, src: str) -> str:
    from PIL import Image

    path = os.path.join(out_dir, base)
    with Image.open(src) as img:
        clean = Image.new(img.mode, img.size)
        clean.putdata(list(img.getdata()))
        clean.save(path)
    return path


def _write_output(fp: str, doc: ExtractedDoc, sanitized: str, out_dir: str) -> str:
    base = os.path.basename(fp)
    if doc.kind in ("txt", "eml"):
        return _write_txt(out_dir, base, sanitized)
    if doc.kind == "docx":
        return _write_docx(out_dir, base, sanitized)
    if doc.kind == "pdf":
        return _write_pdf_as_text(out_dir, base, sanitized)
    if doc.kind == "image":
        return _write_image_stripped(out_dir, base, fp)
    return _write_txt(out_dir, base, sanitized)


def _rewrite_output(path: str, kind: str, text: str) -> None:
    """Overwrite an already-written output file with masked text (verify remediation)."""
    if kind == "docx" and path.endswith(".docx"):
        _write_docx(os.path.dirname(path), os.path.basename(path), text)
    else:
        with open(path, "w", encoding="utf-8") as fh:
            fh.write(text)


def verify_output(path: str, kind: str, dummies: set) -> VerifyResult:
    """V-2 verify pass: re-scan a sanitized output for residue of the replaced classes.

    Pseudonyms produced by sanitize are excluded (they are the intended output). If any
    real residue remains, it is conservatively masked and the output is re-scanned once;
    the file is reported not-verified-clean with the residue listed. A safe copy that was
    already clean as written is reported verified clean.
    """
    if kind == "image":
        from .detectors.metadata import scan_metadata
        return VerifyResult(verified=not scan_metadata(path, "image"))
    try:
        text = extract(path).full_text
    except Exception:  # noqa: BLE001 - unreadable output: nothing to verify here
        return VerifyResult(verified=True)

    clean = {d.strip() for d in dummies}
    residue = [m for m in _replaceable_matches(text) if m.raw.strip() not in clean]
    if not residue:
        return VerifyResult(verified=True)

    masked = apply_replacements(text, [(m.start, m.end, "[redacted]") for m in residue])
    _rewrite_output(path, kind, masked)
    try:
        text2 = extract(path).full_text
        residue_after = [m for m in _replaceable_matches(text2) if m.raw.strip() not in clean]
    except Exception:  # noqa: BLE001
        residue_after = []
    return VerifyResult(verified=False, residue=residue, remediated=True,
                        residue_after=residue_after)


def sanitize_path(
    paths,
    out_dir: str,
    *,
    profile: Optional[str] = None,
    map_dir: Optional[str] = None,
    corpus_consistent: bool = False,
) -> SanitizeResult:
    """Public API: write a sanitized copy of each input file into out_dir, with the
    salted-hash mapping sidecar written under map_dir (default ~/.privsweep/maps)."""
    from .engine import _normalize_paths, scan_doc, walk_paths

    salt = get_or_create_salt()
    map_dir = map_dir or default_map_dir()
    os.makedirs(out_dir, exist_ok=True)
    result = SanitizeResult()

    shared_mapping = Mapping(salt=salt) if corpus_consistent else None
    shared_offset = random.randint(-365, 365) if corpus_consistent else None

    for fp in walk_paths(_normalize_paths(paths)):
        doc = extract(fp)
        # Attach metadata so the report reflects what the strip removed.
        try:
            from .detectors.metadata import scan_metadata
            doc.metadata_findings.extend(scan_metadata(fp, doc.kind))
        except ImportError:
            pass

        mapping = shared_mapping if shared_mapping is not None else Mapping(salt=salt)
        offset = shared_offset if shared_offset is not None else random.randint(-365, 365)

        sanitized, _audit = sanitize_text(doc.full_text, mapping, offset)
        out_path = _write_output(fp, doc, sanitized, out_dir)
        result.outputs.append(out_path)
        result.report.add(*scan_doc(doc, profile=profile))
        for note in doc.notes:
            result.report.note(note)

        # V-2 verify pass: re-scan the safe copy; pseudonyms are excluded as intended output.
        dummies = {a[3] for a in _audit}
        vr = verify_output(out_path, doc.kind, dummies)
        result.verified[out_path] = vr.verified
        if not vr.verified:
            listed = ", ".join(sorted({(m.preview or m.raw) for m in vr.residue})[:8])
            tail = ("masked, now clean" if not vr.residue_after
                    else "RESIDUE STILL PRESENT after masking")
            result.notes.append(
                f"verify: {os.path.basename(out_path)} was NOT clean as first written; "
                f"residue [{listed}] ({tail}).")

        if shared_mapping is None:
            result.map_paths.append(write_sidecar(map_dir, os.path.basename(fp), mapping))

    if shared_mapping is not None:
        result.map_paths.append(write_sidecar(map_dir, "corpus", shared_mapping))

    return result
