"""File-based memory store for nathan-core.

The file system IS the memory. No database required.
Memories are curated and small — attach ALL to prompt, no search needed.

Architecture ref: ARCHITECTURE.md Sections 2, 3, 7
Design decision: Files over DB (zero deps, human-readable, git-trackable)
"""

from __future__ import annotations

import contextlib
import hashlib
import json
import logging
import time
from dataclasses import dataclass, field
from pathlib import Path

from netanel_core.config import NathanConfig

logger = logging.getLogger(__name__)

_MAX_DEDUP_SCAN_FILES: int = 1000
"""Maximum number of existing files to scan during deduplication (JPL Rule 5 — bounded reads)."""

_MAX_MEMORY_FILES: int = 1000
"""Maximum number of memory files to read in read_all_memories (JPL Rule 5 — bounded reads)."""

# Maximum number of .md files scanned when computing the next auto-increment prefix.
# Prevents unbounded iteration in directories with excessive files (JPL Power of 10).
_MAX_SCAN_FILES = 1_000


def _content_hash(title: str, content: str) -> str:
    """SHA-256 hash of title+content for deduplication.

    Excludes timestamp and metadata so identical logical content
    is deduplicated even if written at different times.
    """
    payload = f"{title}\n{content}"
    return hashlib.sha256(payload.encode()).hexdigest()


@dataclass
class Memory:
    """A single memory entry (pattern, learning, decision)."""

    title: str
    content: str
    metadata: dict = field(default_factory=dict)
    timestamp: float = field(default_factory=time.time)
    source_task: str = ""

    def to_markdown(self) -> str:
        """Serialize memory to markdown file content."""
        lines = [
            f"# {self.title}",
            "",
            f"**Timestamp:** {self.timestamp}",
        ]
        if self.source_task:
            lines.append(f"**Source task:** {self.source_task}")
        if self.metadata:
            lines.append(f"**Metadata:** {json.dumps(self.metadata)}")
        lines.extend(["", "---", "", self.content])
        return "\n".join(lines)

    @classmethod
    def from_markdown(cls, text: str, filepath: Path | None = None) -> Memory:
        """Deserialize memory from markdown file content.

        Parses the structured markdown format back into a Memory object.
        Gracefully handles files that don't match expected format.
        """
        lines = text.lstrip().split("\n")
        title = ""
        content_lines = []
        timestamp = 0.0
        source_task = ""
        metadata = {}

        # Parse header
        in_header = True
        separator_seen = False

        for line in lines:
            if in_header:
                if line.startswith("# "):
                    title = line[2:].strip()
                elif line.startswith("**Timestamp:**"):
                    with contextlib.suppress(ValueError, IndexError):
                        timestamp = float(line.split("**Timestamp:**")[1].strip())
                elif line.startswith("**Source task:**"):
                    source_task = line.split("**Source task:**")[1].strip()
                elif line.startswith("**Metadata:**"):
                    with contextlib.suppress(json.JSONDecodeError, IndexError):
                        raw = line.split("**Metadata:**")[1].strip()
                        metadata = json.loads(raw)
                elif line.strip() == "---":
                    separator_seen = True
                    in_header = False
            else:
                content_lines.append(line)

        # If no separator was found, treat entire text as content
        if not separator_seen:
            content_lines = lines
            if title:
                # Remove only the first title line, preserve all other content
                content_lines = lines[1:]

        # BUG-083: use lstrip("\n") instead of strip() so that:
        # - the structural blank line that to_markdown() places after "---" is removed (leading "\n")
        # - trailing newlines in user content are preserved (write-read idempotency)
        content = "\n".join(content_lines).lstrip("\n")

        return cls(
            title=title or (filepath.stem if filepath else "untitled"),
            content=content,
            metadata=metadata,
            timestamp=timestamp,
            source_task=source_task,
        )


class MemoryStore:
    """File-based memory store.

    Reads and writes memories as markdown files organized by namespace.
    Curated memories are small — read ALL per namespace, no search.

    Directory structure:
        memories/
        ├── global/patterns/       # Shared across all namespaces
        └── {namespace}/
            ├── patterns/          # What worked, what didn't
            └── prompts/           # Prompt versions
    """

    def __init__(self, config: NathanConfig) -> None:
        self._config = config
        config.ensure_directories()

    @property
    def config(self) -> NathanConfig:
        return self._config

    def write_memory(
        self,
        title: str,
        content: str,
        *,
        metadata: dict | None = None,
        source_task: str = "",
        namespace: str | None = None,
    ) -> Path:
        """Write a memory to the namespace's patterns directory.

        Deduplication: before writing, compute SHA-256 of title+content and
        compare against existing files. If an identical hash is found, skip
        the write silently and return the path of the existing file.

        Args:
            title: Short descriptive title for the memory.
            content: The actual learning/pattern/decision content.
            metadata: Optional key-value metadata.
            source_task: Optional description of the task that produced this.
            namespace: Override namespace (default: config's namespace).
                       Use "global" for shared memories.

        Returns:
            Path to the written file (new or existing duplicate).
        """
        # BUG-082: normalise line endings before writing so CRLF content from
        # Windows tools or LLM responses is stored as LF. Python's write_text()
        # universal-newline mode would do this silently; we do it explicitly so
        # the contract is visible and intentional.
        content = content.replace("\r\n", "\n").replace("\r", "\n")

        memory = Memory(
            title=title,
            content=content,
            metadata=metadata or {},
            source_task=source_task,
        )

        target_dir = self._patterns_dir(namespace)
        target_dir.mkdir(parents=True, exist_ok=True)

        # Deduplication check — compare SHA-256 of title+content against
        # every existing file. Timestamp and metadata are intentionally
        # excluded so the same logical memory is only stored once.
        # BUG-054: normalize before hashing so whitespace variations don't create duplicates
        normalized_title = title.strip()
        normalized_content = content.strip()
        new_hash = _content_hash(normalized_title, normalized_content)
        all_existing = sorted(target_dir.glob("*.md"))
        if len(all_existing) > _MAX_DEDUP_SCAN_FILES:
            logger.warning(
                "Deduplication scan truncated: %d files in namespace, scanning only first %d.",
                len(all_existing),
                _MAX_DEDUP_SCAN_FILES,
            )
        for existing_path in all_existing[:_MAX_DEDUP_SCAN_FILES]:
            with contextlib.suppress(OSError):
                existing_text = existing_path.read_text(encoding="utf-8")
                existing_lines = existing_text.strip().split("\n")
                if not existing_lines:
                    continue
                # Parse title: first line starting with "# "
                existing_title = (
                    existing_lines[0][2:].strip()
                    if existing_lines[0].startswith("# ")
                    else existing_lines[0].strip()
                )
                # Parse content: everything after the "---" separator
                separator_idx = None
                for i, line in enumerate(existing_lines):
                    if line.strip() == "---":
                        separator_idx = i
                        break
                if separator_idx is not None:
                    existing_content = "\n".join(existing_lines[separator_idx + 1 :]).lstrip("\n")
                else:
                    existing_content = "\n".join(existing_lines[1:]).lstrip("\n")

                if _content_hash(existing_title, existing_content) == new_hash:
                    logger.debug(
                        "Duplicate memory skipped (hash=%s): %s", new_hash[:12], existing_path.name
                    )
                    return existing_path

        # Collect all numeric prefixes across every file, take max to handle gaps
        # and non-numeric filenames — follows prompt_evolver._get_current_version() pattern.
        # Materialize first so we can enforce an upper bound (JPL Power of 10 / BUG-060).
        existing_files = list(target_dir.glob("*.md"))
        if len(existing_files) > _MAX_SCAN_FILES:
            raise ValueError(
                f"Directory {target_dir} contains {len(existing_files)} .md files, "
                f"which exceeds the maximum scan limit of {_MAX_SCAN_FILES}. "
                "Reduce the number of files or increase _MAX_SCAN_FILES."
            )
        _nums: list[int] = []
        for _f in existing_files:
            with contextlib.suppress(ValueError, IndexError):
                _nums.append(int(_f.name.split("-", 1)[0]))
        next_num = max(_nums, default=0) + 1
        # Sanitize title for filename
        safe_title = "".join(c if c.isalnum() or c in "-_" else "-" for c in title.lower()).strip(
            "-"
        )[:50]
        if not safe_title:
            safe_title = "untitled"
        filename = f"{next_num:03d}-{safe_title}.md"

        filepath = target_dir / filename
        filepath.write_text(memory.to_markdown(), encoding="utf-8")
        return filepath

    def read_all_memories(self, *, namespace: str | None = None) -> list[Memory]:
        """Read ALL memories from a namespace's patterns directory.

        Memories are curated and small — no search needed, attach all to prompt.

        Args:
            namespace: Override namespace (default: config's namespace).

        Returns:
            List of all Memory objects in the namespace, sorted by filename.
        """
        target_dir = self._patterns_dir(namespace)
        if not target_dir.exists():
            return []

        memories = []
        # BUG-086: Use a tail slice [-_MAX_MEMORY_FILES:] so truncation drops the OLDEST
        # files, not the newest. sorted() ascending preserves chronological order that
        # ContextBuilder._trim_memories() depends on. The tail slice keeps the most
        # recent N files when the directory exceeds the limit.
        all_files = sorted(target_dir.glob("*.md"))
        for filepath in all_files[-_MAX_MEMORY_FILES:]:
            with contextlib.suppress(OSError):
                text = filepath.read_text(encoding="utf-8")
                memories.append(Memory.from_markdown(text, filepath))

        return memories

    def read_global_memories(self) -> list[Memory]:
        """Read ALL memories from the global shared namespace.

        Global memories apply to every role/task.

        Returns:
            List of all global Memory objects.
        """
        return self.read_all_memories(namespace="global")

    def read_all_with_global(self) -> list[Memory]:
        """Read namespace memories + global memories combined.

        This is what ContextBuilder will call — all relevant knowledge.

        Returns:
            Combined list: global memories first, then namespace memories.
        """
        global_memories = self.read_global_memories()
        namespace_memories = self.read_all_memories()
        return global_memories + namespace_memories

    def read_prompt(self, *, version: str | None = None) -> str | None:
        """Read the current prompt for this namespace.

        Args:
            version: Specific version (e.g., "v1", "v2"). Default: "current".

        Returns:
            Prompt text, or None if no prompt exists yet.
        """
        prompts_dir = self._config.prompts_dir
        if not prompts_dir.exists():
            return None

        filename = f"prompt_{version or 'current'}.md"
        prompt_path = prompts_dir / filename
        if not prompt_path.exists():
            return None

        return prompt_path.read_text(encoding="utf-8")

    def write_prompt(self, content: str, *, version: str | None = None) -> Path:
        """Write a prompt version for this namespace.

        Args:
            content: The prompt text.
            version: Version label (e.g., "v1", "v2"). Default: "current".

        Returns:
            Path to the written prompt file.
        """
        prompts_dir = self._config.prompts_dir
        prompts_dir.mkdir(parents=True, exist_ok=True)

        filename = f"prompt_{version or 'current'}.md"
        filepath = prompts_dir / filename
        filepath.write_text(content, encoding="utf-8")
        return filepath

    def count_memories(self, *, namespace: str | None = None) -> int:
        """Count pattern files in a namespace without reading them all.

        Useful for evolution triggers (pattern accumulation check).
        """
        target_dir = self._patterns_dir(namespace)
        if not target_dir.exists():
            return 0
        return len(list(target_dir.glob("*.md")))

    def _patterns_dir(self, namespace: str | None = None) -> Path:
        """Get the patterns directory for a namespace."""
        if namespace == "global":
            return self._config.global_dir / "patterns"
        if namespace is not None:
            return self._config.memories_dir / namespace / "patterns"
        return self._config.patterns_dir
