"""Long-term memory extraction and formatting helpers."""

from __future__ import annotations

import json
import re
from dataclasses import dataclass
from typing import Any

from memnixa.config import AppConfig
from memnixa.gateway.messages import InboundMessageContext
from memnixa.models import LLMResponse

ALLOWED_MEMORY_TYPES = {
    "constraint",
    "correction",
    "decision",
    "episodic_summary",
    "goal",
    "preference",
    "project_fact",
    "self_model",
    "todo",
    "user_profile",
}
ALWAYS_INJECT_MEMORY_TYPES = {
    "constraint",
    "correction",
    "goal",
    "preference",
    "self_model",
    "user_profile",
}
MEMORY_WRITE_ACTIONS = {"upsert", "ignore"}
RELATED_MEMORY_REVIEW_LIMIT = 12
SELF_MEMORY_SCOPE: tuple[str, str] = ("self", "memnixa")


@dataclass(frozen=True, slots=True)
class MemoryCandidate:
    """Structured memory write decision returned by the extractor model."""

    action: str
    scope_type: str
    scope_id: str
    memory_type: str
    content: str
    conflict_key: str | None = None
    confidence: float = 0.0
    existing_id: int | None = None
    reason: str | None = None
    metadata: dict[str, Any] | None = None


def build_memory_extraction_messages(
    *,
    inbound: InboundMessageContext,
    reply: str,
    tool_events: list[dict[str, Any]],
    allowed_scopes: list[tuple[str, str]],
    existing_items: list[Any],
) -> list[dict[str, str]]:
    """Build a tightly scoped review prompt for the memory model."""
    transcript = [
        f"User: {inbound.body_for_agent.strip()}",
        f"Assistant: {(reply or '').strip()}",
    ]
    if tool_events:
        transcript.append(
            "Tool events:\n"
            + "\n".join(
                f"- {event.get('tool_name')}: {json.dumps(event.get('result'), ensure_ascii=False)}"
                for event in tool_events[:4]
            )
        )
    scope_lines = ["Available scopes:"]
    for scope_type, scope_id in allowed_scopes:
        scope_lines.append(f"- {scope_type}:{scope_id}")
    existing_lines = ["Existing related memories:"]
    if existing_items:
        for item in existing_items[:RELATED_MEMORY_REVIEW_LIMIT]:
            metadata = item.metadata if isinstance(getattr(item, "metadata", None), dict) else {}
            existing_lines.append(
                "- "
                + json.dumps(
                    {
                        "id": item.id,
                        "scope_type": item.scope_type,
                        "scope_id": item.scope_id,
                        "memory_type": item.memory_type,
                        "content": item.content,
                        "conflict_key": item.conflict_key,
                        "confidence": item.confidence,
                        "updated_at": item.updated_at,
                        "last_recalled_at": item.last_recalled_at,
                        "metadata": metadata,
                    },
                    ensure_ascii=False,
                )
            )
    else:
        existing_lines.append("- []")
    prompt = (
        "Review this turn and decide how long-term memory should change.\n"
        "Return strict JSON only with shape {\"operations\": [...]}.\n"
        "Each operation must include action.\n"
        "Allowed action values: upsert, ignore.\n"
        "For action=upsert include: scope_type, scope_id, memory_type, content, conflict_key, confidence.\n"
        "For action=upsert you may also include existing_id when this fact should update an existing memory.\n"
        "For action=ignore include: content, reason.\n"
        "You may include metadata on upsert items, especially stability, source_kind, expires_at.\n"
        "Allowed memory_type values: constraint, correction, decision, episodic_summary, "
        "goal, preference, project_fact, self_model, todo, user_profile.\n"
        "Rules:\n"
        "- Reject transient chatter, one-off tool outputs, secrets, tokens, passwords, cookies, raw logs.\n"
        "- Reject time-sensitive or low-durability facts such as today's news, current events, weather, temporary listings, and ephemeral workspace snapshots.\n"
        "- Use self_model only for the agent's own stable identity, role, capability boundaries, behavior contract, and long-lived self-description.\n"
        "- Prefer user scope for user habits and preferences.\n"
        "- Prefer agent scope for project rules, design decisions, and stable environment facts.\n"
        "- Prefer self:memnixa scope for self_model memories that should apply across all users, sessions, and workspaces.\n"
        "- Reuse existing_id and an existing conflict_key when the new fact is the same memory in updated wording.\n"
        "- If an existing memory already captures the fact, update it instead of creating a duplicate.\n"
        "- confidence must be between 0 and 1.\n"
        "- If nothing durable should be stored, return {\"operations\": []}.\n\n"
        + "\n".join(scope_lines)
        + "\n\n"
        + "\n".join(existing_lines)
        + "\n\nTranscript:\n"
        + "\n".join(transcript)
    )
    return [
        {
            "role": "system",
            "content": (
                "You review long-term memory updates for an agent. "
                "Output JSON only and do not add commentary."
            ),
        },
        {"role": "user", "content": prompt},
    ]


def parse_memory_candidates(
    response: LLMResponse,
    *,
    allowed_scope_ids: set[tuple[str, str]],
    existing_memory_ids: set[int] | None = None,
) -> list[MemoryCandidate]:
    """Parse extractor output into validated memory write decisions."""
    if response.finish_reason == "error":
        return []
    payload = _load_json_payload(response.content or "")
    items = payload.get("operations")
    if not isinstance(items, list):
        items = payload.get("items")
    if not isinstance(items, list):
        return []
    candidates: list[MemoryCandidate] = []
    for item in items:
        if not isinstance(item, dict):
            continue
        action = str(item.get("action") or "upsert").strip().lower()
        if action not in MEMORY_WRITE_ACTIONS:
            continue
        content = _normalize_content(str(item.get("content") or ""))
        reason = _normalize_content(str(item.get("reason") or ""))
        confidence = _normalize_confidence(item.get("confidence"))
        metadata = item.get("metadata")
        if action == "ignore":
            if not content:
                continue
            candidates.append(
                MemoryCandidate(
                    action="ignore",
                    scope_type="",
                    scope_id="",
                    memory_type="",
                    content=content,
                    confidence=confidence,
                    reason=reason or None,
                    metadata=metadata if isinstance(metadata, dict) else None,
                )
            )
            continue
        scope_type = str(item.get("scope_type") or "").strip().lower()
        scope_id = str(item.get("scope_id") or "").strip()
        memory_type = str(item.get("memory_type") or "").strip().lower()
        if not scope_type or not scope_id or not memory_type or not content:
            continue
        if memory_type not in ALLOWED_MEMORY_TYPES:
            continue
        if (scope_type, scope_id) not in allowed_scope_ids:
            continue
        conflict_key = str(item.get("conflict_key") or "").strip().lower() or None
        existing_id = _normalize_existing_id(item.get("existing_id"))
        if existing_id is not None and existing_memory_ids is not None:
            if existing_id not in existing_memory_ids:
                existing_id = None
        normalized_metadata = metadata if isinstance(metadata, dict) else None
        normalized_conflict_key = normalize_conflict_key(
            memory_type=memory_type,
            content=content,
            conflict_key=conflict_key,
            metadata=normalized_metadata,
        )
        candidates.append(
            MemoryCandidate(
                action="upsert",
                scope_type=scope_type,
                scope_id=scope_id,
                memory_type=memory_type,
                content=content,
                conflict_key=normalized_conflict_key,
                confidence=confidence,
                existing_id=existing_id,
                reason=reason or None,
                metadata=normalized_metadata,
            )
        )
    return candidates


def resolve_allowed_memory_scopes(
    *,
    inbound: InboundMessageContext,
    config: AppConfig,
) -> list[tuple[str, str]]:
    """Return the scopes the extractor may write into for this turn."""
    scopes = [SELF_MEMORY_SCOPE, ("agent", str(config.workspace))]
    scopes.append(("session", inbound.session_id))
    if inbound.actor_user_id:
        scopes.append(("user", inbound.actor_user_id))
    return scopes


def build_memory_context(
    *,
    stable_items: list[Any],
    relevant_items: list[dict[str, Any]],
    max_chars: int,
) -> str | None:
    """Render recalled memory into a compact context block."""
    lines = ["## Recalled Memory", ""]
    if stable_items:
        lines.append("Always apply these durable memories:")
        for item in stable_items:
            lines.append(f"- [{item.memory_type}] {item.content}")
        lines.append("")
    if relevant_items:
        lines.append("Relevant memories for this request:")
        for item in relevant_items:
            lines.append(f"- [{item['memory_type']}] {item['content']}")
        lines.append("")
    rendered = "\n".join(lines).strip()
    if rendered == "## Recalled Memory":
        return None
    return rendered[:max_chars].rstrip()


def should_extract_memory(inbound: InboundMessageContext, reply: str) -> bool:
    """Cheap guard to skip obviously useless extraction turns."""
    user_text = inbound.body_for_agent.strip()
    reply_text = (reply or "").strip()
    if not user_text or not reply_text:
        return False
    if user_text.startswith("/"):
        return False
    return True


def collect_memory_review_items(
    *,
    recent_items: list[Any],
    searched_items: list[dict[str, Any]],
) -> list[Any]:
    """Merge recent and searched memories into a compact review set."""
    merged: list[Any] = []
    seen_ids: set[int] = set()
    for item in recent_items:
        item_id = int(getattr(item, "id", 0) or 0)
        if item_id <= 0 or item_id in seen_ids:
            continue
        seen_ids.add(item_id)
        merged.append(item)
    for item in searched_items:
        item_id = int(item.get("id") or 0)
        if item_id <= 0 or item_id in seen_ids:
            continue
        seen_ids.add(item_id)
        merged.append(_SearchMemoryReviewItem.from_mapping(item))
    return merged[:RELATED_MEMORY_REVIEW_LIMIT]


def is_likely_volatile_memory(content: str, metadata: dict[str, Any] | None = None) -> bool:
    """Return whether a memory candidate looks too time-sensitive or noisy."""
    lowered = content.strip().lower()
    if not lowered:
        return True
    stability = str((metadata or {}).get("stability") or "").strip().lower()
    if stability in {"volatile", "temporary", "ephemeral"}:
        return True
    volatile_patterns = (
        "today news",
        "today's news",
        "latest news",
        "breaking news",
        "news summary",
        "weather",
        "stock price",
        "exchange rate",
        "current directory contains",
        "directory contains",
        "今日新闻",
        "今日要闻",
        "新闻包含",
        "国际要闻",
        "欧盟动态",
        "美国内政",
        "中国相关",
        "当前目录包含",
        "目录包含",
    )
    return any(pattern in lowered for pattern in volatile_patterns)


def normalize_conflict_key(
    *,
    memory_type: str,
    content: str,
    conflict_key: str | None,
    metadata: dict[str, Any] | None = None,
) -> str | None:
    """Normalize conflict keys for a few high-value recurring memory shapes."""
    normalized = conflict_key.strip().lower() if conflict_key and conflict_key.strip() else None
    lowered = content.strip().lower()
    if memory_type in {"user_profile", "preference"}:
        if any(
            pattern in lowered
            for pattern in (
                "被称呼",
                "称呼为",
                "希望被称呼",
                "call me",
                "prefer to be called",
                "prefers to be called",
                "preferred name",
                "my preferred name",
            )
        ):
            return "user_profile.preferred_name"
    if normalized:
        return normalized
    metadata_key = str((metadata or {}).get("conflict_key_hint") or "").strip().lower()
    return metadata_key or None


@dataclass(frozen=True, slots=True)
class _SearchMemoryReviewItem:
    """Minimal adapter so search results look like stored memory records."""

    id: int
    scope_type: str
    scope_id: str
    memory_type: str
    content: str
    conflict_key: str | None
    confidence: float
    metadata: dict[str, Any] | None
    updated_at: str
    last_recalled_at: str | None = None

    @classmethod
    def from_mapping(cls, item: dict[str, Any]) -> "_SearchMemoryReviewItem":
        return cls(
            id=int(item.get("id") or 0),
            scope_type=str(item.get("scope_type") or ""),
            scope_id=str(item.get("scope_id") or ""),
            memory_type=str(item.get("memory_type") or ""),
            content=str(item.get("content") or ""),
            conflict_key=(
                str(item.get("conflict_key") or "").strip().lower() or None
            ),
            confidence=_normalize_confidence(item.get("confidence")),
            metadata=item.get("metadata") if isinstance(item.get("metadata"), dict) else None,
            updated_at=str(item.get("updated_at") or ""),
            last_recalled_at=(
                str(item.get("last_recalled_at"))
                if item.get("last_recalled_at") is not None
                else None
            ),
        )


def _load_json_payload(content: str) -> dict[str, Any]:
    text = content.strip()
    if not text:
        return {}
    fenced = re.search(r"```(?:json)?\s*(\{[\s\S]*\})\s*```", text)
    if fenced:
        text = fenced.group(1)
    try:
        parsed = json.loads(text)
    except json.JSONDecodeError:
        return {}
    return parsed if isinstance(parsed, dict) else {}


def _normalize_content(content: str) -> str:
    normalized = re.sub(r"\s+", " ", content).strip()
    if len(normalized) > 400:
        normalized = normalized[:400].rstrip()
    return normalized


def _normalize_confidence(value: Any) -> float:
    try:
        confidence = float(value)
    except (TypeError, ValueError):
        return 0.0
    return max(0.0, min(1.0, confidence))


def _normalize_existing_id(value: Any) -> int | None:
    try:
        normalized = int(value)
    except (TypeError, ValueError):
        return None
    return normalized if normalized > 0 else None
