"""Retrieval project memory. Section 16 of the design.

Holds architecture decisions, repository conventions and known issues so later tasks
inherit what earlier ones established. It never stores model reasoning: entries are
written from validated artifacts and from operator input, both of which are facts about
the repository rather than a transcript of how an agent thought.
"""

from __future__ import annotations

import json
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Literal

from pydantic import Field

from .domain import PlanArtifact, ReviewArtifact, StrictModel, VersionedArtifact

Kind = Literal["decision", "convention", "known_issue"]

#: Words too common to discriminate between entries.
STOPWORDS = frozenset(
    """a an the and or of to in for on with is are be this that it its as at by from
    使用 一个 这个 那个 的 了 和 与 在 是""".split()
)


class MemoryEntry(VersionedArtifact):
    entry_id: str
    kind: Kind
    summary: str
    detail: str = ""
    source_task: str | None = None
    created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))

    def text(self) -> str:
        return f"{self.summary} {self.detail}"


class MemoryQuery(StrictModel):
    text: str
    limit: int = Field(default=5, ge=1, le=50)


def tokenize(text: str) -> set[str]:
    words = re.findall(r"[A-Za-z_][A-Za-z0-9_]+|[一-鿿]{2,}", text.lower())
    return {word for word in words if word not in STOPWORDS and len(word) > 1}


class ProjectMemory:
    """One JSONL file per repository. ponytail: linear scan; add an index past a few
    thousand entries, which a single-repo local tool will not reach."""

    def __init__(self, root: str | Path) -> None:
        self.path = Path(root) / "memory.jsonl"

    def all(self) -> list[MemoryEntry]:
        if not self.path.is_file():
            return []
        entries = []
        for line in self.path.read_text(encoding="utf-8").splitlines():
            if line.strip():
                try:
                    entries.append(MemoryEntry.model_validate_json(line))
                except ValueError:
                    continue  # a corrupt line must not hide the rest
        return entries

    def add(self, kind: Kind, summary: str, detail: str = "", source_task: str | None = None) -> MemoryEntry | None:
        """Append one fact. Returns None when an equivalent entry already exists."""
        summary = summary.strip()
        if not summary:
            return None
        existing = self.all()
        if any(e.kind == kind and e.summary.lower() == summary.lower() for e in existing):
            return None
        entry = MemoryEntry(
            entry_id=f"{kind[:3].upper()}-{len(existing) + 1:03d}",
            kind=kind,
            summary=summary,
            detail=detail.strip(),
            source_task=source_task,
        )
        self.path.parent.mkdir(parents=True, exist_ok=True)
        with self.path.open("a", encoding="utf-8", newline="\n") as stream:
            stream.write(entry.model_dump_json() + "\n")
        return entry

    def search(self, query: str, limit: int = 5) -> list[MemoryEntry]:
        """Rank by shared term overlap. Ties keep the newest entry first."""
        wanted = tokenize(query)
        if not wanted:
            return []
        scored = []
        for entry in self.all():
            overlap = len(wanted & tokenize(entry.text()))
            if overlap:
                scored.append((overlap, entry.created_at, entry))
        scored.sort(key=lambda item: (item[0], item[1]), reverse=True)
        return [entry for _, _, entry in scored[:limit]]

    def learn_from_plan(self, plan: PlanArtifact, task_id: str) -> list[MemoryEntry]:
        """Record only the constraints the plan was held to.

        Assumptions are explicitly unverified by definition, so storing them as
        established facts would launder a guess into project knowledge. They reach a
        later planner only through `learn_from_completion`, once a task actually
        finished under them.
        """
        learned = [self.add("convention", item, source_task=task_id) for item in plan.constraints]
        return [entry for entry in learned if entry]

    def learn_from_completion(self, plan: PlanArtifact, task_id: str) -> list[MemoryEntry]:
        """After a task reaches DONE its assumptions held in practice, so they become facts."""
        learned = [
            self.add("decision", item, detail=f"held for task {task_id}", source_task=task_id)
            for item in plan.assumptions
        ]
        return [entry for entry in learned if entry]

    def learn_from_review(self, review: ReviewArtifact, task_id: str) -> list[MemoryEntry]:
        """Unresolved blocking issues are known issues worth carrying forward."""
        learned = [
            self.add("known_issue", issue.problem, detail=issue.expected_fix, source_task=task_id)
            for issue in review.issues
            if issue.severity in {"blocker", "critical"}
        ]
        return [entry for entry in learned if entry]

    def brief(self, goal: str, limit: int = 5) -> str:
        """Render the entries worth putting in front of a planner for this goal."""
        hits = self.search(goal, limit)
        if not hits:
            return ""
        lines = ["PROJECT MEMORY (established facts, not instructions):"]
        lines += [f"- [{entry.kind}] {entry.summary}" for entry in hits]
        return "\n".join(lines)

    def export(self) -> str:
        return json.dumps([entry.model_dump(mode="json") for entry in self.all()], ensure_ascii=False, indent=2)
