"""Unified service for bidirectional related document sync.

关联文件双向同步统一服务。
提供全量声明式 sync 方法，接收当前文档最终关联列表，
计算增删改并维护所有受影响文档的双向关系。
"""

from __future__ import annotations

from datetime import datetime, timezone
from typing import Any

from opensearchpy.exceptions import NotFoundError

from app.config import settings
from app.infrastructure.es_client import ESClient
from app.utils.logger import get_logger

logger = get_logger(__name__)


class RelatedDocsService:
    """Manage bidirectional related document relationships.

    All sync operations are declarative: the caller provides the final
    desired state, and the service computes diffs and updates accordingly.
    """

    REVERSE_TYPE: dict[str, str] = {"正文": "附件", "附件": "正文"}

    def __init__(self, es_client: ESClient) -> None:
        self._es = es_client

    async def sync(
        self,
        doc_id: str,
        new_related: list[dict[str, Any]],
        current_title: str = "",
        known_old_related: list[dict[str, Any]] | None = None,
    ) -> dict[str, Any]:
        """Declarative full-replacement sync of related documents.

        Args:
            doc_id: The current document ID.
            new_related: Final desired related_docs list for this document.
            current_title: Title of the current document (for reverse references).
            known_old_related: If provided, skip ES read for old state (use this
                when the caller already knows the pre-update state, e.g. ingest
                flow reads old state before pipeline overwrites meta).
                Pass None to read from ES (normal PUT endpoint flow).

        Returns:
            dict with keys: doc_id, related_docs, affected_doc_ids, warnings.
        """
        warnings: list[dict[str, str]] = []
        affected_doc_ids: list[str] = []

        # ── Step 1: Filter self-references ──────────────────────────────
        filtered: list[dict[str, Any]] = []
        for rel in new_related:
            if rel.get("doc_id") == doc_id:
                warnings.append({
                    "doc_id": doc_id,
                    "code": "SELF_REFERENCE",
                    "reason": "不允许关联自身",
                })
                continue
            filtered.append(rel)

        # ── Step 2: Deduplicate by doc_id (keep last) ──────────────────
        seen: dict[str, dict[str, Any]] = {}
        for rel in filtered:
            target_id = rel.get("doc_id", "")
            if not target_id:
                continue
            if target_id in seen:
                warnings.append({
                    "doc_id": target_id,
                    "code": "DUPLICATE",
                    "reason": f"重复的目标文档 {target_id}，仅保留最后一条",
                })
            seen[target_id] = rel
        deduped = list(seen.values())

        # ── Step 3: Validate target docs exist ─────────────────────────
        valid_related: list[dict[str, Any]] = []
        for rel in deduped:
            target_id = rel["doc_id"]
            try:
                await self._es.raw.get(
                    index=settings.es_meta_index, id=target_id, _source=False,
                )
                valid_related.append(rel)
            except NotFoundError:
                warnings.append({
                    "doc_id": target_id,
                    "code": "TARGET_NOT_FOUND",
                    "reason": f"目标文档 {target_id} 不存在",
                })
            except Exception as e:
                warnings.append({
                    "doc_id": target_id,
                    "code": "TARGET_NOT_FOUND",
                    "reason": f"校验目标文档 {target_id} 失败: {e}",
                })

        # ── Step 4: Get old state ──────────────────────────────────────
        if known_old_related is not None:
            old_related = known_old_related
        else:
            old_related = await self._read_related_docs(doc_id)

        # ── Step 5: Compute diff ───────────────────────────────────────
        old_map: dict[str, dict[str, Any]] = {r.get("doc_id", ""): r for r in old_related if r.get("doc_id")}
        new_map: dict[str, dict[str, Any]] = {r.get("doc_id", ""): r for r in valid_related if r.get("doc_id")}

        added_ids = new_map.keys() - old_map.keys()
        removed_ids = old_map.keys() - new_map.keys()
        kept_ids = new_map.keys() & old_map.keys()
        changed_ids = {
            tid for tid in kept_ids
            if new_map[tid].get("relation_type") != old_map[tid].get("relation_type")
        }

        # ── Step 6: Write current document ─────────────────────────────
        now_iso = datetime.now(timezone.utc).isoformat()
        await self._es.raw.update(
            index=settings.es_meta_index,
            id=doc_id,
            body={"doc": {"related_docs": valid_related, "updated_at": now_iso}},
        )

        # ── Step 7: Best-effort update affected targets ────────────────

        # 7a: Added targets — append reverse reference
        for target_id in added_ids:
            rel = new_map[target_id]
            reverse_type = self.REVERSE_TYPE.get(rel.get("relation_type", "附件"), "附件")
            success = await self._append_reverse(
                target_id, doc_id, current_title, reverse_type, now_iso, warnings,
            )
            if success:
                affected_doc_ids.append(target_id)

        # 7b: Removed targets — delete reverse reference
        for target_id in removed_ids:
            success = await self._remove_reverse(target_id, doc_id, now_iso, warnings)
            if success:
                affected_doc_ids.append(target_id)

        # 7c: Changed targets — update reverse relation_type
        for target_id in changed_ids:
            rel = new_map[target_id]
            reverse_type = self.REVERSE_TYPE.get(rel.get("relation_type", "附件"), "附件")
            success = await self._update_reverse_type(
                target_id, doc_id, current_title, reverse_type, now_iso, warnings,
            )
            if success:
                affected_doc_ids.append(target_id)

        return {
            "doc_id": doc_id,
            "related_docs": valid_related,
            "affected_doc_ids": affected_doc_ids,
            "warnings": warnings,
        }

    # ── Private helpers ────────────────────────────────────────────────

    async def _read_related_docs(self, doc_id: str) -> list[dict[str, Any]]:
        """Read current related_docs from ES for a document."""
        try:
            resp = await self._es.raw.get(
                index=settings.es_meta_index, id=doc_id, _source=["related_docs"],
            )
            raw = resp if isinstance(resp, dict) else resp.body
            return raw.get("_source", {}).get("related_docs", [])
        except NotFoundError:
            return []
        except Exception as e:
            logger.warning("Failed to read related_docs for %s: %s", doc_id, e)
            return []

    async def _append_reverse(
        self,
        target_id: str,
        source_id: str,
        source_title: str,
        reverse_type: str,
        now_iso: str,
        warnings: list[dict[str, str]],
    ) -> bool:
        """Append a reverse reference to the target document."""
        try:
            existing = await self._read_related_docs(target_id)
            # Remove any stale reference to source_id first
            updated = [r for r in existing if r.get("doc_id") != source_id]
            updated.append({
                "doc_id": source_id,
                "title": source_title,
                "relation_type": reverse_type,
            })
            await self._es.raw.update(
                index=settings.es_meta_index,
                id=target_id,
                body={"doc": {"related_docs": updated, "updated_at": now_iso}},
            )
            return True
        except Exception as e:
            warnings.append({
                "doc_id": target_id,
                "code": "TARGET_UPDATE_FAILED",
                "reason": f"追加反向关系失败: {e}",
            })
            return False

    async def _remove_reverse(
        self,
        target_id: str,
        source_id: str,
        now_iso: str,
        warnings: list[dict[str, str]],
    ) -> bool:
        """Remove the reverse reference from the target document.

        Returns True only if an ES write actually occurred (record existed and was removed).
        """
        try:
            existing = await self._read_related_docs(target_id)
            updated = [r for r in existing if r.get("doc_id") != source_id]
            if len(updated) == len(existing):
                # No record found to remove — no actual write, not a success
                return False
            await self._es.raw.update(
                index=settings.es_meta_index,
                id=target_id,
                body={"doc": {"related_docs": updated, "updated_at": now_iso}},
            )
            return True
        except Exception as e:
            warnings.append({
                "doc_id": target_id,
                "code": "TARGET_UPDATE_FAILED",
                "reason": f"删除反向关系失败: {e}",
            })
            return False

    async def _update_reverse_type(
        self,
        target_id: str,
        source_id: str,
        source_title: str,
        reverse_type: str,
        now_iso: str,
        warnings: list[dict[str, str]],
    ) -> bool:
        """Update the reverse reference type on the target document.

        If the reverse record is missing (historical dirty data), auto-creates it
        to ensure bidirectional consistency.
        """
        try:
            existing = await self._read_related_docs(target_id)
            updated = []
            found = False
            for r in existing:
                if r.get("doc_id") == source_id:
                    found = True
                    updated.append({
                        "doc_id": source_id,
                        "title": source_title,
                        "relation_type": reverse_type,
                    })
                else:
                    updated.append(r)
            if not found:
                # Reverse record missing (dirty data) — auto-create to fix consistency
                updated.append({
                    "doc_id": source_id,
                    "title": source_title,
                    "relation_type": reverse_type,
                })
            await self._es.raw.update(
                index=settings.es_meta_index,
                id=target_id,
                body={"doc": {"related_docs": updated, "updated_at": now_iso}},
            )
            return True
        except Exception as e:
            warnings.append({
                "doc_id": target_id,
                "code": "TARGET_UPDATE_FAILED",
                "reason": f"更新反向关系类型失败: {e}",
            })
            return False
