# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# ORCID: 0009-0006-4957-0540
# Email: Georgescottfoley@proton.me
# Licensed under the MIT License - see LICENSE file for details

"""
Markdown Memory System

Wiki-style, topic-keyed .md files with YAML frontmatter that grow over
time.  Human-readable, editable, and feed the training pipeline with
verifiable lineage via the genesis block system.

Each topic is a single file that accumulates ``## Heading`` sections on
every append.  Files live under ``~/.familiar/data/memories/{user_id}/``.

Dependencies: pyyaml (already a base dep).
"""

import hashlib
import logging
import os
import re
import tempfile
import threading
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path

import yaml

logger = logging.getLogger(__name__)

# ── Helpers ──────────────────────────────────────────────────────────

_SLUG_RE = re.compile(r"[^a-z0-9]+")
_MAX_SLUG = 60

_TRIVIAL_PATTERNS = re.compile(
    r"^(hi|hello|hey|thanks|thank you|ok|okay|bye|goodbye|yes|no|sure|"
    r"what can you do|help|who are you|what are you)\b",
    re.IGNORECASE,
)

_STRUCTURED_RE = re.compile(
    r"(^[\-\*]\s|\d+\.\s|```|^\|.*\|$|^#{1,6}\s)",
    re.MULTILINE,
)

_QUESTION_WORDS = re.compile(
    r"^(how|what|why|when|where|who|which|can|could|would|should|is|are|do|does|did|tell me)\s+",
    re.IGNORECASE,
)


def slugify(title: str) -> str:
    """Convert *title* to a filesystem-safe slug, max 60 chars."""
    slug = _SLUG_RE.sub("-", title.lower()).strip("-")
    if len(slug) > _MAX_SLUG:
        slug = slug[:_MAX_SLUG].rsplit("-", 1)[0] or slug[:_MAX_SLUG]
    return slug or "untitled"


# ── Frontmatter data model ──────────────────────────────────────────


@dataclass
class MemoryFileMeta:
    topic: str
    created: str
    updated: str
    tags: list = field(default_factory=list)
    aliases: list = field(default_factory=list)
    source_memories: list = field(default_factory=list)
    provenance: dict = field(
        default_factory=lambda: {
            "author": {"name": "", "orcid": ""},
            "license": "proprietary",
            "consent": "owner",
        }
    )
    revision: int = 1


def parse_memory_file(text: str) -> tuple:
    """Split a memory file into ``(MemoryFileMeta, body)``."""
    if text.startswith("---"):
        parts = text.split("---", 2)
        if len(parts) >= 3:
            raw = yaml.safe_load(parts[1]) or {}
            body = parts[2].lstrip("\n")
            meta = MemoryFileMeta(
                topic=raw.get("topic", ""),
                created=raw.get("created", ""),
                updated=raw.get("updated", ""),
                tags=raw.get("tags", []),
                aliases=raw.get("aliases", []),
                source_memories=raw.get("source_memories", []),
                provenance=raw.get("provenance", {}),
                revision=raw.get("revision", 1),
            )
            return meta, body
    return None, text


def render_memory_file(meta: MemoryFileMeta, body: str) -> str:
    """Reassemble frontmatter + body into file content."""
    fm = {
        "topic": meta.topic,
        "created": meta.created,
        "updated": meta.updated,
        "tags": meta.tags,
        "aliases": meta.aliases,
        "source_memories": meta.source_memories,
        "provenance": meta.provenance,
        "revision": meta.revision,
    }
    header = yaml.dump(fm, default_flow_style=False, sort_keys=False, allow_unicode=True)
    return f"---\n{header}---\n{body}"


# ── MarkdownMemoryStore ─────────────────────────────────────────────


class MarkdownMemoryStore:
    """Per-user markdown memory store backed by the filesystem."""

    def __init__(self, memories_dir: Path, user_id: str = "default"):
        self._base = memories_dir / user_id
        self._base.mkdir(parents=True, exist_ok=True)
        self._lock = threading.RLock()
        self._alias_index: dict[str, str] = {}  # alias -> slug
        self._rebuild_alias_index()

    # ── public API ───────────────────────────────────────────────────

    def resolve_topic(self, name: str) -> str | None:
        """Return the slug for *name* (slug itself or alias), or None."""
        slug = slugify(name)
        path = self._base / f"{slug}.md"
        if path.exists():
            return slug
        return self._alias_index.get(slug)

    def save_memory(
        self,
        topic: str,
        content: str,
        tags: list | None = None,
        source: str = "",
        heading: str = "",
        aliases: list | None = None,
    ) -> tuple:
        """Append a section to *topic*, creating the file if needed.

        Returns ``(slug, revision)``.
        """
        slug = slugify(topic)
        path = self._base / f"{slug}.md"
        now = datetime.now(timezone.utc).isoformat()
        tags = tags or []
        aliases = aliases or []
        heading = heading or topic.title()

        with self._lock:
            if path.exists():
                text = path.read_text(encoding="utf-8")
                meta, body = parse_memory_file(text)
                if meta is None:
                    meta = MemoryFileMeta(
                        topic=slug,
                        created=now,
                        updated=now,
                        tags=tags,
                    )
                    body = text
                meta.revision += 1
                meta.updated = now
                # Merge tags (deduplicate)
                for t in tags:
                    if t not in meta.tags:
                        meta.tags.append(t)
                # Merge aliases
                for a in aliases:
                    if a not in meta.aliases:
                        meta.aliases.append(a)
            else:
                meta = MemoryFileMeta(
                    topic=slug,
                    created=now,
                    updated=now,
                    tags=tags,
                    aliases=aliases,
                )
                body = ""

            section = f"## {heading}\n\n"
            section += f"_Added {now}"
            if source:
                section += f" — {source}"
            section += "_\n\n"
            section += content.rstrip() + "\n\n"

            body += section

            if source and source not in meta.source_memories:
                meta.source_memories.append(source)

            full = render_memory_file(meta, body)
            self._atomic_write(path, full)

            # Anchor to genesis chain
            content_hash = hashlib.sha256(section.encode()).hexdigest()
            self._anchor_append(slug, meta.revision, content_hash)

            # Update alias index
            for a in meta.aliases:
                self._alias_index[slugify(a)] = slug

        return slug, meta.revision

    def read_memory(self, topic: str) -> tuple | None:
        """Read a topic file. Returns ``(meta, body)`` or None."""
        slug = self.resolve_topic(topic)
        if slug is None:
            return None
        path = self._base / f"{slug}.md"
        if not path.exists():
            return None
        text = path.read_text(encoding="utf-8")
        return parse_memory_file(text)

    def search(self, query: str, limit: int = 5) -> list:
        """Search topics by word overlap. Returns ``[(slug, score, preview)]``."""
        if not query or not query.strip():
            return []
        terms = set(query.lower().split())
        results = []
        for path in self._base.glob("*.md"):
            slug = path.stem
            text = path.read_text(encoding="utf-8")
            meta, body = parse_memory_file(text)
            words = set(body.lower().split())
            tag_words = set()
            if meta:
                tag_words = {t.lower() for t in meta.tags}
                topic_words = set(meta.topic.replace("-", " ").lower().split())
            else:
                topic_words = set(slug.replace("-", " ").lower().split())

            score = 0.0
            for t in terms:
                if t in topic_words:
                    score += 3.0
                if t in tag_words:
                    score += 2.0
                if t in words:
                    score += 1.0

            if score > 0:
                preview = body[:150].replace("\n", " ").strip()
                results.append((slug, score, preview))

        results.sort(key=lambda r: r[1], reverse=True)
        return results[:limit]

    def list_topics(self, tags: list | None = None, limit: int = 50) -> list:
        """List topic metadata summaries, optionally filtered by tags."""
        topics = []
        for path in sorted(self._base.glob("*.md"), key=lambda p: p.stat().st_mtime, reverse=True):
            meta, body = parse_memory_file(path.read_text(encoding="utf-8"))
            if meta is None:
                continue
            if tags:
                if not any(t in meta.tags for t in tags):
                    continue
            topics.append(
                {
                    "topic": meta.topic,
                    "tags": meta.tags,
                    "revision": meta.revision,
                    "updated": meta.updated,
                    "created": meta.created,
                    "aliases": meta.aliases,
                }
            )
            if len(topics) >= limit:
                break
        return topics

    # ── mesh adapter API ──────────────────────────────────────────────

    def read_topic(self, topic: str) -> str | None:
        """Read a topic and return just the body text (no meta)."""
        result = self.read_memory(topic)
        if result is None:
            return None
        _meta, body = result
        return body

    def write_topic(
        self,
        topic: str,
        content: str,
        origin: str = "",
        origin_name: str = "",
        origin_genesis_hash: str = "",
    ) -> tuple | None:
        """Write/append content to a topic. Used by mesh markdown push."""
        source = f"mesh:{origin_name}" if origin_name else (f"mesh:{origin}" if origin else "")
        tags = ["mesh-received"]
        if origin_genesis_hash:
            tags.append(f"genesis:{origin_genesis_hash[:12]}")
        return self.save_memory(
            topic=topic,
            content=content,
            tags=tags,
            source=source,
            heading=f"From {origin_name or origin or 'mesh'}",
        )

    def get_tags(self, topic: str) -> list:
        """Get tags for a topic."""
        result = self.read_memory(topic)
        if result is None:
            return []
        meta, _body = result
        return meta.tags if meta else []

    def get_revision(self, topic: str) -> int:
        """Get revision number for a topic."""
        result = self.read_memory(topic)
        if result is None:
            return 0
        meta, _body = result
        return meta.revision if meta else 1

    def delete_memory(self, topic: str) -> bool:
        """Delete a topic file. Returns True if deleted."""
        slug = self.resolve_topic(topic)
        if slug is None:
            return False
        path = self._base / f"{slug}.md"
        if path.exists():
            path.unlink()
            # Clean alias index
            self._alias_index = {a: s for a, s in self._alias_index.items() if s != slug}
            return True
        return False

    def export_for_training(self, min_revision: int = 0) -> list:
        """Split all topics into per-section training records with provenance.

        Returns a list of dicts suitable for JSONL export.
        """
        records = []
        for path in self._base.glob("*.md"):
            text = path.read_text(encoding="utf-8")
            meta, body = parse_memory_file(text)
            if meta is None:
                continue
            if meta.revision < min_revision:
                continue

            # Split body on ## headings
            sections = re.split(r"(?=^## )", body, flags=re.MULTILINE)
            for section in sections:
                section = section.strip()
                if not section:
                    continue
                content_hash = hashlib.sha256(section.encode()).hexdigest()
                records.append(
                    {
                        "topic": meta.topic,
                        "content": section,
                        "tags": meta.tags,
                        "content_hash": content_hash,
                        "provenance": meta.provenance,
                        "revision": meta.revision,
                        "source_type": "memories",
                        "quality_score": min(0.5 + meta.revision * 0.05, 1.0),
                    }
                )
        return records

    # ── private helpers ──────────────────────────────────────────────

    def _atomic_write(self, path: Path, content: str) -> None:
        """Write *content* to *path* atomically via tempfile + os.replace."""
        path.parent.mkdir(parents=True, exist_ok=True)
        fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
        try:
            with os.fdopen(fd, "w", encoding="utf-8") as f:
                f.write(content)
            os.replace(tmp, str(path))
        except BaseException:
            try:
                os.unlink(tmp)
            except OSError:
                pass
            raise

    def _anchor_append(self, slug: str, revision: int, content_hash: str) -> str:
        """Anchor an append event to the genesis chain. No-op if unavailable."""
        try:
            from familiar.skills.training.bridge import anchor_provenance

            return anchor_provenance(
                {
                    "action": "memory_append",
                    "topic": slug,
                    "revision": revision,
                    "content_hash": content_hash,
                    "timestamp": datetime.now(timezone.utc).isoformat(),
                }
            )
        except Exception:
            logger.debug("Genesis anchor skipped for memory append", exc_info=True)
            return ""

    def _rebuild_alias_index(self) -> None:
        """Scan all files and rebuild the alias -> slug index."""
        self._alias_index.clear()
        for path in self._base.glob("*.md"):
            try:
                text = path.read_text(encoding="utf-8")
                meta, _ = parse_memory_file(text)
                if meta and meta.aliases:
                    for a in meta.aliases:
                        self._alias_index[slugify(a)] = path.stem
            except Exception:
                logger.debug("Skipping corrupt memory file: %s", path, exc_info=True)


# ── Convenience helpers (module-level) ───────────────────────────────


def _get_memories_dir() -> Path:
    """Get memories directory (tenant-scoped in Reflection, default in standalone)."""
    try:
        from familiar.core.paths import MEMORIES_DIR

        return MEMORIES_DIR
    except ImportError:
        return Path.home() / ".familiar" / "data" / "memories"


def _get_store(user_id: str) -> MarkdownMemoryStore:
    """Convenience: create a store for *user_id*."""
    return MarkdownMemoryStore(_get_memories_dir(), user_id)


# ── Auto-curation (heuristic, no LLM call) ──────────────────────────


def _extract_topic(message: str) -> str:
    """Extract a topic slug from a user message."""
    text = _QUESTION_WORDS.sub("", message).strip()
    # Remove trailing punctuation
    text = text.rstrip("?!.")
    words = text.split()
    # Take first 5-8 content words
    topic_words = words[:8] if len(words) > 5 else words[:5]
    if not topic_words:
        return "general"
    return slugify(" ".join(topic_words))


def auto_curate_check(
    user_message: str,
    assistant_response: str,
    user_id: str,
    conversation_id: str = "",
    tool_names: list | None = None,
) -> None:
    """Heuristic check — if the exchange is worth remembering, append it.

    Runs in a daemon thread to avoid blocking the response path.
    No LLM call; pure heuristic.
    """
    tool_names = tool_names or []

    def _do_curate():
        try:
            # Gate: response must be substantial
            if len(assistant_response) < 200:
                return

            # Gate: not trivial
            if _TRIVIAL_PATTERNS.match(user_message.strip()):
                return

            # Gate: has structured content OR tools were used
            has_structure = bool(_STRUCTURED_RE.search(assistant_response))
            if not has_structure and not tool_names:
                return

            topic = _extract_topic(user_message)
            if not topic or topic == "untitled":
                return

            store = _get_store(user_id)
            heading = user_message[:80].strip().rstrip("?!.")
            source = f"conversation:{conversation_id}" if conversation_id else "auto"

            # Build content: summarise the exchange
            content = f"**Q:** {user_message}\n\n"
            # Truncate long responses for memory
            resp = assistant_response
            if len(resp) > 1500:
                resp = resp[:1500] + "…"
            content += f"**A:** {resp}"

            store.save_memory(
                topic=topic,
                content=content,
                tags=["auto-curated"],
                source=source,
                heading=heading,
            )
            logger.debug("Auto-curated memory: %s (user=%s)", topic, user_id)
        except Exception:
            logger.debug("Auto-curation failed", exc_info=True)

    t = threading.Thread(target=_do_curate, daemon=True)
    t.start()
