"""18+ 露骨文本可逆占位 — 入库前 $id$，对外返回前还原（词表在 persist/adult_lexicon）。"""
from __future__ import annotations

import fcntl
import json
import os
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any

_PLACEHOLDER_RE = re.compile(r"\$(\d+)\$")


def _lexicon_dir(cfg: dict[str, Any]) -> Path:
    import os as _os

    sub = str(cfg.get("lexicon_subdir") or "adult_lexicon").strip() or "adult_lexicon"
    persist = _os.environ.get("NIANNIAN_PERSIST_DIR")
    if persist:
        return Path(persist).expanduser().resolve() / sub
    from niannian.core.config import resolve_data_home

    return resolve_data_home() / sub


def _load_terms(cfg: dict[str, Any]) -> list[str]:
    terms = [str(t).strip() for t in (cfg.get("terms") or []) if str(t).strip()]
    terms_file = cfg.get("terms_file")
    if terms_file:
        path = Path(str(terms_file)).expanduser()
        if not path.is_absolute():
            from niannian.core.config import _PKG_ROOT

            path = (_PKG_ROOT / path).resolve()
        if path.is_file():
            for line in path.read_text(encoding="utf-8").splitlines():
                t = line.strip()
                if t and not t.startswith("#"):
                    terms.append(t)
    # 长词优先，减少短词误伤
    return sorted(set(terms), key=len, reverse=True)


def _merge_intervals(spans: list[tuple[int, int]]) -> list[tuple[int, int]]:
    if not spans:
        return []
    ordered = sorted(spans)
    merged: list[tuple[int, int]] = [ordered[0]]
    for start, end in ordered[1:]:
        last_start, last_end = merged[-1]
        if start <= last_end:
            merged[-1] = (last_start, max(last_end, end))
        else:
            merged.append((start, end))
    return merged


@dataclass
class EncodeResult:
    text: str
    meta_patch: dict[str, Any]
    redacted: bool


class AdultRedactor:
    def __init__(self, cfg: dict[str, Any] | None = None) -> None:
        self._cfg = dict(cfg or {})
        self._terms = _load_terms(self._cfg)

    @classmethod
    def from_app_config(cls, config: dict[str, Any]) -> AdultRedactor:
        return cls(config.get("adult_redaction") or {})

    @property
    def enabled(self) -> bool:
        return bool(self._cfg.get("enabled", False))

    def known_terms_set(self) -> set[str]:
        return set(self._terms)

    def term_in_lexicon(self, term: str) -> bool:
        row = str(term or "").strip()
        return bool(row) and row in self.known_terms_set()

    def applies_to_project(self, project: str) -> bool:
        if not self.enabled:
            return False
        projects = self._cfg.get("projects")
        if not projects:
            return True
        return project in projects

    def _force_redact(self, tags: list[str] | None, meta: dict[str, Any] | None) -> bool:
        force_tags = {str(t) for t in (self._cfg.get("force_tags") or ["18x", "adult", "露骨"])}
        tag_set = {str(t) for t in (tags or [])}
        if tag_set & force_tags:
            return True
        rating = (meta or {}).get("content_rating") or (meta or {}).get("adult")
        return str(rating).lower() in ("18x", "adult", "explicit", "true", "1")

    def _find_spans(self, text: str) -> list[tuple[int, int]]:
        if not text or not self._terms:
            return []
        spans: list[tuple[int, int]] = []
        for term in self._terms:
            if not term:
                continue
            start = 0
            while True:
                idx = text.find(term, start)
                if idx < 0:
                    break
                spans.append((idx, idx + len(term)))
                start = idx + len(term)
        return _merge_intervals(spans)

    def _lexicon_path(self, project: str) -> Path:
        safe = re.sub(r"[^\w.-]+", "_", project or "global").strip("_") or "global"
        return _lexicon_dir(self._cfg) / f"{safe}.json"

    def _load_lexicon(self, project: str) -> dict[str, Any]:
        path = self._lexicon_path(project)
        if not path.is_file():
            return {"next_id": 1, "refs": {}}
        with path.open(encoding="utf-8") as fh:
            data = json.load(fh)
        refs = data.get("refs") if isinstance(data.get("refs"), dict) else {}
        next_id = int(data.get("next_id") or 1)
        return {"next_id": max(1, next_id), "refs": {str(k): str(v) for k, v in refs.items()}}

    def _save_lexicon(self, project: str, lexicon: dict[str, Any]) -> None:
        path = self._lexicon_path(project)
        path.parent.mkdir(parents=True, exist_ok=True)
        payload = {
            "next_id": int(lexicon.get("next_id") or 1),
            "refs": dict(lexicon.get("refs") or {}),
        }
        with path.open("w", encoding="utf-8") as fh:
            fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
            try:
                json.dump(payload, fh, ensure_ascii=False, indent=2)
                fh.write("\n")
                fh.flush()
                os.fsync(fh.fileno())
            finally:
                fcntl.flock(fh.fileno(), fcntl.LOCK_UN)

    def encode_for_storage(
        self,
        text: str,
        *,
        project: str = "global",
        tags: list[str] | None = None,
        meta: dict[str, Any] | None = None,
    ) -> EncodeResult:
        """写入小忆前：露骨片段 → $id$；原文进 project 词表。"""
        raw = (text or "").strip()
        if not raw or not self.applies_to_project(project):
            return EncodeResult(text=raw, meta_patch={}, redacted=False)

        spans: list[tuple[int, int]]
        if self._force_redact(tags, meta):
            spans = [(0, len(raw))]
        else:
            spans = self._find_spans(raw)
        if not spans:
            return EncodeResult(text=raw, meta_patch={}, redacted=False)

        lexicon = self._load_lexicon(project)
        refs: dict[str, str] = lexicon["refs"]
        next_id = int(lexicon["next_id"])
        used_ids: list[str] = []

        parts: list[str] = []
        cursor = 0
        for start, end in spans:
            if start > cursor:
                parts.append(raw[cursor:start])
            span_text = raw[start:end]
            token_id = str(next_id)
            next_id += 1
            refs[token_id] = span_text
            used_ids.append(token_id)
            parts.append(f"${token_id}$")
            cursor = end
        if cursor < len(raw):
            parts.append(raw[cursor:])

        lexicon["next_id"] = next_id
        lexicon["refs"] = refs
        self._save_lexicon(project, lexicon)

        meta_patch = {
            "adult_redaction": {
                "version": 1,
                "refs": used_ids,
            }
        }
        return EncodeResult(text="".join(parts), meta_patch=meta_patch, redacted=True)

    def decode_for_display(self, text: str, *, project: str = "global") -> str:
        """对外返回前：$id$ → 中文原文。"""
        if not text or "$" not in text:
            return text
        if not _PLACEHOLDER_RE.search(text):
            return text
        lexicon = self._load_lexicon(project)
        refs: dict[str, str] = lexicon.get("refs") or {}

        def _repl(match: re.Match[str]) -> str:
            key = match.group(1)
            return refs.get(key, match.group(0))

        return _PLACEHOLDER_RE.sub(_repl, text)

    def decode_citations(
        self, citations: list[dict[str, Any]], *, project: str
    ) -> list[dict[str, Any]]:
        out: list[dict[str, Any]] = []
        for item in citations:
            row = dict(item)
            if "snippet" in row and isinstance(row["snippet"], str):
                row["snippet"] = self.decode_for_display(row["snippet"], project=project)
            out.append(row)
        return out
