"""data/vault/ 双命名空间 · _index.json · taxonomy 路由。"""

from __future__ import annotations

import json
import re
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any

import yaml

from niannian.core.config import resolve_config_path
from niannian.vault.frontmatter import DEFAULT_PAGE_TYPE, normalize_page_type, parse_md, serialize_md, validate_frontmatter

_PKG_ROOT = Path(__file__).resolve().parent.parent.parent
_INDEX_VERSION = 1
PAGE_TYPE_DIRS = frozenset({"sources", "concepts", "entities", "synthesis"})


def _config_vault_path(name: str) -> Path:
    cfg_dir = resolve_config_path().parent
    candidate = cfg_dir / "vault" / name
    if candidate.is_file():
        return candidate
    return _PKG_ROOT / "config" / "vault" / name


def _slugify(title: str) -> str:
    s = re.sub(r"[^\w\u4e00-\u9fff-]+", "-", title.strip())
    s = re.sub(r"-+", "-", s).strip("-")
    return s[:80] or "untitled"


def _today_id() -> str:
    return datetime.now(timezone.utc).strftime("%Y%m%d")


class VaultStore:
    def __init__(self, home: Path, *, manifest=None, config: dict | None = None) -> None:
        self._home = home
        self._root = home / "vault"
        self._index_path = self._root / "_index.json"
        self._manifest = manifest
        self._config = config or {}
        self.ensure_layout()

    def ensure_layout(self) -> None:
        for sub in ("user", "niannian", "_rules"):
            (self._root / sub).mkdir(parents=True, exist_ok=True)
        rules = self._root / "_rules" / "routing_rules.yaml"
        if not rules.is_file():
            rules.write_text("rules: []\n", encoding="utf-8")
        if not self._index_path.is_file():
            self._write_index({"version": _INDEX_VERSION, "entries": []})

    def load_taxonomy(self, namespace: str) -> dict[str, Any]:
        name = "taxonomy.yaml" if namespace == "user" else "niannian_taxonomy.yaml"
        override = self._home / "config" / "vault" / name
        path = override if override.is_file() else _config_vault_path(name)
        if not path.is_file():
            return {"categories": []}
        with path.open(encoding="utf-8") as f:
            data = yaml.safe_load(f) or {}
        return data if isinstance(data, dict) else {"categories": []}

    def _known_subprojects(self, namespace: str, category: str) -> set[str]:
        tax = self.load_taxonomy(namespace)
        for cat in tax.get("categories") or []:
            if not isinstance(cat, dict):
                continue
            cid = str(cat.get("id") or cat.get("label") or "")
            if cid == category:
                subs = cat.get("subprojects") or []
                return {str(s) for s in subs if s}
        return set()

    def make_vault_id(self) -> str:
        return f"vlt_{_today_id()}_{uuid.uuid4().hex[:5]}"

    def load_index(self) -> dict[str, Any]:
        if not self._index_path.is_file():
            return {"version": _INDEX_VERSION, "entries": []}
        data = json.loads(self._index_path.read_text(encoding="utf-8"))
        if not isinstance(data, dict):
            return {"version": _INDEX_VERSION, "entries": []}
        data.setdefault("version", _INDEX_VERSION)
        data.setdefault("entries", [])
        return data

    def _write_index(self, doc: dict[str, Any]) -> None:
        self._index_path.parent.mkdir(parents=True, exist_ok=True)
        self._index_path.write_text(json.dumps(doc, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
        self._track_file("vault/_index.json", self._index_path)

    def _track_file(self, rel: str, path: Path) -> None:
        if self._manifest is not None:
            self._manifest.update_file_entry(rel, path)

    def _tree_page_entry(
        self,
        ns: str,
        cat_name: str,
        sub_name: str,
        md: Path,
        *,
        page_type: str | None = None,
    ) -> dict[str, str]:
        try:
            fm, _ = parse_md(md.read_text(encoding="utf-8"))
        except OSError:
            fm = {}
        if page_type:
            rel = f"{ns}/{cat_name}/{sub_name}/{page_type}/{md.name}"
        else:
            rel = f"{ns}/{cat_name}/{sub_name}/{md.name}"
        return {
            "vault_id": str(fm.get("id") or ""),
            "title": str(fm.get("title") or md.stem),
            "rel_path": rel,
        }

    def resolve_rel_path(
        self,
        *,
        namespace: str,
        category: str | None,
        subproject: str | None,
        title: str,
        taxonomy_status: str = "confirmed",
        proposed_subproject: str | None = None,
        page_type: str | None = None,
    ) -> tuple[str, str, str, str]:
        """返回 (rel_path, category, subproject, slug)。"""
        ns = namespace if namespace in ("user", "niannian") else "user"
        slug = _slugify(title)
        cat = (category or "").strip()
        sub = (subproject or "").strip()
        status = taxonomy_status or "confirmed"
        if status == "pending" or not cat or not sub:
            prop = (proposed_subproject or sub or cat or "未分类").strip()
            cat = "待确认"
            sub = prop
        elif ns == "user":
            known = self._known_subprojects(ns, cat)
            if sub and known and sub not in known:
                prop = sub
                cat = "待确认"
                sub = prop
                status = "pending"
        pt = normalize_page_type(page_type) if page_type else None
        if pt:
            rel = f"{ns}/{cat}/{sub}/{pt}/{slug}.md"
        else:
            rel = f"{ns}/{cat}/{sub}/{slug}.md"
        return rel, cat, sub, slug

    def write_document(
        self,
        *,
        namespace: str,
        title: str,
        body: str,
        frontmatter: dict[str, Any],
        vault_id: str | None = None,
    ) -> dict[str, Any]:
        fm = validate_frontmatter(dict(frontmatter))
        fm.setdefault("title", title)
        fm.setdefault("namespace", namespace)
        vid = vault_id or fm.get("id") or self.make_vault_id()
        fm["id"] = vid
        fm.setdefault("vault_version", 1)
        fm.setdefault("extracted_at", datetime.now(timezone.utc).isoformat())
        use_typed_path = bool(str(frontmatter.get("page_type") or "").strip())
        route_page_type = normalize_page_type(fm.get("page_type")) if use_typed_path else None
        rel, cat, sub, slug = self.resolve_rel_path(
            namespace=namespace,
            category=str(fm.get("category") or ""),
            subproject=str(fm.get("subproject") or ""),
            title=title,
            taxonomy_status=str(fm.get("taxonomy_status") or "confirmed"),
            proposed_subproject=fm.get("proposed_subproject"),
            page_type=route_page_type,
        )
        fm["category"] = cat
        fm["subproject"] = sub
        if cat == "待确认" or str(fm.get("taxonomy_status") or "") == "pending":
            fm["taxonomy_status"] = "pending"
        else:
            fm["taxonomy_status"] = "confirmed"
        abs_path = self._root / rel
        abs_path.parent.mkdir(parents=True, exist_ok=True)
        abs_path.write_text(serialize_md(fm, body), encoding="utf-8")
        self._track_file(f"vault/{rel}", abs_path)
        entry = {
            "vault_id": vid,
            "namespace": namespace,
            "rel_path": rel,
            "title": title,
            "category": cat,
            "subproject": sub,
            "slug": slug,
            "atom_id": fm.get("atom_id"),
            "created_at": fm.get("extracted_at"),
        }
        idx = self.load_index()
        entries = [e for e in (idx.get("entries") or []) if isinstance(e, dict) and e.get("vault_id") != vid]
        entries.append(entry)
        idx["entries"] = entries
        self._write_index(idx)
        try:
            from niannian.vault.import_hooks import on_vault_document_written

            on_vault_document_written(self, entry=entry, body=body)
        except Exception:
            pass
        return {"vault_id": vid, "rel_path": rel, "frontmatter": fm, "body": body}

    def read_document(self, rel_path: str) -> dict[str, Any]:
        rel = rel_path.lstrip("/")
        if rel.startswith("vault/"):
            rel = rel[6:]
        path = self._root / rel
        if not path.is_file():
            raise FileNotFoundError(rel_path)
        raw = path.read_text(encoding="utf-8")
        fm, body = parse_md(raw)
        return {"rel_path": rel, "frontmatter": fm, "body": body, "raw": raw}

    def list_tree(self, namespace: str) -> dict[str, Any]:
        ns = namespace if namespace in ("user", "niannian") else "user"
        tree: dict[str, dict[str, list[dict[str, str]]]] = {}
        base = self._root / ns
        if not base.is_dir():
            return {"namespace": ns, "categories": tree}
        for cat_dir in sorted(base.iterdir()):
            if not cat_dir.is_dir():
                continue
            cat_name = cat_dir.name
            tree.setdefault(cat_name, {})
            for sub_dir in sorted(cat_dir.iterdir()):
                if not sub_dir.is_dir():
                    continue
                sub_name = sub_dir.name
                pages: list[dict[str, str]] = []
                for md in sorted(sub_dir.glob("*.md")):
                    pages.append(self._tree_page_entry(ns, cat_name, sub_name, md))
                for type_dir in sorted(sub_dir.iterdir()):
                    if not type_dir.is_dir() or type_dir.name not in PAGE_TYPE_DIRS:
                        continue
                    for md in sorted(type_dir.glob("*.md")):
                        pages.append(
                            self._tree_page_entry(ns, cat_name, sub_name, md, page_type=type_dir.name)
                        )
                tree[cat_name][sub_name] = pages
        return {"namespace": ns, "categories": tree}

    def update_entry_atom(self, vault_id: str, atom_id: str) -> None:
        idx = self.load_index()
        changed = False
        for entry in idx.get("entries") or []:
            if isinstance(entry, dict) and entry.get("vault_id") == vault_id:
                entry["atom_id"] = atom_id
                changed = True
        if changed:
            self._write_index(idx)

    def rollback_import(self, vault_id: str, rel_path: str) -> None:
        """Import 事务失败：删 index 项 + partial MD。"""
        idx = self.load_index()
        idx["entries"] = [
            e
            for e in (idx.get("entries") or [])
            if not (isinstance(e, dict) and e.get("vault_id") == vault_id)
        ]
        self._write_index(idx)
        rel = rel_path.lstrip("/")
        if rel.startswith("vault/"):
            rel = rel[6:]
        path = self._root / rel
        if path.is_file():
            path.unlink()
            parent = path.parent
            while parent.is_dir() and parent != self._root:
                try:
                    if any(parent.iterdir()):
                        break
                    parent.rmdir()
                    parent = parent.parent
                except OSError:
                    break

    def list_md_rels(self) -> list[str]:
        rels: list[str] = []
        if not self._root.is_dir():
            return rels
        for path in sorted(self._root.rglob("*.md")):
            if path.is_file():
                rels.append(f"vault/{path.relative_to(self._root).as_posix()}")
        return rels

    def find_entry(self, vault_id: str) -> dict[str, Any] | None:
        for entry in self.load_index().get("entries") or []:
            if isinstance(entry, dict) and entry.get("vault_id") == vault_id:
                return entry
        return None

    @staticmethod
    def _parse_created_at(value: str | None) -> datetime | None:
        if not value:
            return None
        try:
            dt = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
        except ValueError:
            return None
        if dt.tzinfo is None:
            dt = dt.replace(tzinfo=timezone.utc)
        return dt

    def _entry_created_at(self, entry: dict[str, Any]) -> datetime | None:
        ts = self._parse_created_at(str(entry.get("created_at") or ""))
        if ts is not None:
            return ts
        rel = str(entry.get("rel_path") or "")
        try:
            doc = self.read_document(rel)
            fm = doc.get("frontmatter") or {}
            return self._parse_created_at(str(fm.get("extracted_at") or fm.get("created_at") or ""))
        except (FileNotFoundError, OSError):
            return None

    def find_recent_import_by_source_url(
        self,
        source_url: str,
        *,
        namespace: str = "user",
        window_hours: int = 24,
    ) -> dict[str, Any] | None:
        url = str(source_url or "").strip()
        if not url:
            return None
        ns = namespace if namespace in ("user", "niannian") else "user"
        cutoff = datetime.now(timezone.utc) - timedelta(hours=window_hours)
        for entry in reversed(self.load_index().get("entries") or []):
            if not isinstance(entry, dict):
                continue
            if str(entry.get("namespace") or "") != ns:
                continue
            created = self._entry_created_at(entry)
            if created is None or created < cutoff:
                continue
            rel = str(entry.get("rel_path") or "")
            try:
                doc = self.read_document(rel)
                fm = doc.get("frontmatter") or {}
            except (FileNotFoundError, OSError):
                continue
            if str(fm.get("source_url") or "").strip() != url:
                continue
            return {
                "vault_id": str(entry.get("vault_id") or fm.get("id") or ""),
                "rel_path": rel,
                "source_url": url,
                "created_at": created.isoformat(),
            }
        return None

    def find_recent_import_by_vault_id(
        self,
        vault_id: str,
        *,
        window_hours: int = 24,
    ) -> dict[str, Any] | None:
        vid = str(vault_id or "").strip()
        if not vid:
            return None
        entry = self.find_entry(vid)
        if entry is None:
            return None
        created = self._entry_created_at(entry)
        if created is None:
            return None
        cutoff = datetime.now(timezone.utc) - timedelta(hours=window_hours)
        if created < cutoff:
            return None
        rel = str(entry.get("rel_path") or "")
        source_url = None
        try:
            doc = self.read_document(rel)
            fm = doc.get("frontmatter") or {}
            source_url = fm.get("source_url")
        except (FileNotFoundError, OSError):
            pass
        return {
            "vault_id": vid,
            "rel_path": rel,
            "source_url": source_url,
            "created_at": created.isoformat(),
        }

    def find_by_frontmatter(
        self,
        *,
        namespace: str,
        key: str,
        value: str,
        limit: int = 20,
    ) -> list[dict[str, Any]]:
        ns = namespace if namespace in ("user", "niannian") else "user"
        hits: list[dict[str, Any]] = []
        for entry in self.load_index().get("entries") or []:
            if not isinstance(entry, dict):
                continue
            if str(entry.get("namespace") or "") != ns:
                continue
            rel = str(entry.get("rel_path") or "")
            try:
                doc = self.read_document(rel)
                fm = doc.get("frontmatter") or {}
            except (FileNotFoundError, OSError):
                continue
            if str(fm.get(key) or "") != str(value):
                continue
            body = doc.get("body") or ""
            hits.append(
                {
                    "vault_id": entry.get("vault_id"),
                    "rel_path": rel,
                    "title": str(fm.get("title") or entry.get("title") or ""),
                    "category": fm.get("category") or entry.get("category"),
                    "subproject": fm.get("subproject") or entry.get("subproject"),
                    "snippet": body[:120].replace("\n", " ").strip(),
                    "score": fm.get("score"),
                    "score_verdict": fm.get("score_verdict"),
                }
            )
            if len(hits) >= limit:
                break
        return hits

    def relocate_document(
        self,
        vault_id: str,
        *,
        category: str,
        subproject: str,
        taxonomy_status: str = "confirmed",
    ) -> dict[str, str]:
        entry = self.find_entry(vault_id)
        if entry is None:
            raise FileNotFoundError(vault_id)
        old_rel = str(entry["rel_path"])
        doc = self.read_document(old_rel)
        fm = dict(doc.get("frontmatter") or {})
        body = doc.get("body") or ""
        title = str(fm.get("title") or entry.get("title") or "untitled")
        ns = str(fm.get("namespace") or entry.get("namespace") or "user")
        slug = entry.get("slug") or _slugify(title)
        new_rel = f"{ns}/{category}/{subproject}/{slug}.md"
        if new_rel == old_rel:
            fm["category"] = category
            fm["subproject"] = subproject
            fm["taxonomy_status"] = taxonomy_status
            path = self._root / old_rel
            path.write_text(serialize_md(fm, body), encoding="utf-8")
            return {"vault_id": vault_id, "from": old_rel, "to": old_rel}

        old_path = self._root / old_rel
        new_path = self._root / new_rel
        new_path.parent.mkdir(parents=True, exist_ok=True)
        fm["category"] = category
        fm["subproject"] = subproject
        fm["taxonomy_status"] = taxonomy_status
        fm.pop("proposed_subproject", None)
        fm.pop("proposed_category", None)
        new_path.write_text(serialize_md(fm, body), encoding="utf-8")
        self._track_file(f"vault/{new_rel}", new_path)
        if old_path.is_file() and old_path != new_path:
            old_path.unlink()
            parent = old_path.parent
            if parent.is_dir() and not any(parent.iterdir()):
                parent.rmdir()
        idx = self.load_index()
        for e in idx.get("entries") or []:
            if isinstance(e, dict) and e.get("vault_id") == vault_id:
                e["rel_path"] = new_rel
                e["category"] = category
                e["subproject"] = subproject
        self._write_index(idx)
        return {"vault_id": vault_id, "from": old_rel, "to": new_rel}
