"""Ingestion pipeline: markdown text → chunks → embeddings → Supabase.

Usage::

    from cerefox.ingestion.pipeline import IngestionPipeline
    from cerefox.config import Settings
    from cerefox.db.client import CerefoxClient
    from cerefox.embeddings.cloud import CloudEmbedder

    settings = Settings()
    client = CerefoxClient(settings)
    embedder = CloudEmbedder(...)
    pipeline = IngestionPipeline(client, embedder, settings)

    result = pipeline.ingest_text(
        text="# My note\\n\\nSome content.",
        title="My note",
        source="paste",
        project_ids=["<uuid>"],
    )
"""

from __future__ import annotations

import hashlib
import logging
import re
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Literal

from cerefox.chunking.markdown import chunk_markdown

if TYPE_CHECKING:
    from cerefox.config import Settings
    from cerefox.db.client import CerefoxClient
    from cerefox.embeddings.base import Embedder

log = logging.getLogger(__name__)


@dataclass
class IngestResult:
    """Summary returned by every ingest_* call.

    ``action`` is the canonical outcome:
    - ``"created"``  — new document written to the knowledge base.
    - ``"updated"``  — existing document updated; check ``reindexed`` to
                       distinguish a full re-embed from a metadata-only save.
    - ``"skipped"``  — identical content already present; nothing written.

    ``reindexed`` is only meaningful when ``action == "updated"`` and
    indicates that chunks were deleted and re-embedded because the content
    changed.
    """

    document_id: str
    title: str
    chunk_count: int
    total_chars: int
    action: Literal["created", "updated", "skipped"]
    reindexed: bool = False  # True when chunks were re-embedded (content changed on update)
    project_ids: list[str] = field(default_factory=list)
    note: str = ""  # Optional warning (e.g. update_if_exists overridden by document_id)

    @property
    def skipped(self) -> bool:
        """Back-compat shim — prefer checking ``action`` directly."""
        return self.action == "skipped"


class IngestionPipeline:
    """Orchestrates the full parse → chunk → embed → store flow.

    All public methods are synchronous.  For fire-and-forget background
    ingestion wrap them in a thread or process pool.

    Args:
        client: Supabase client wrapper.
        embedder: Any object satisfying the :class:`~cerefox.embeddings.base.Embedder` protocol.
        settings: Application settings (chunk sizes, etc.).
    """

    def __init__(
        self,
        client: "CerefoxClient",
        embedder: "Embedder",
        settings: "Settings",
    ) -> None:
        self._client = client
        self._embedder = embedder
        self._settings = settings

    # ── Public API ────────────────────────────────────────────────────────

    def ingest_text(
        self,
        text: str,
        title: str,
        source: str = "paste",
        source_path: str | None = None,
        project_name: str | None = None,
        project_id: str | None = None,
        project_ids: list[str] | None = None,
        project_names: list[str] | None = None,
        metadata: dict | None = None,
        update_existing: bool = False,
        document_id: str | None = None,
        author: str = "unknown",
        author_type: str = "user",
    ) -> IngestResult:
        """Ingest a raw markdown string.

        Args:
            text: Markdown content to ingest.
            title: Human-readable document title.
            source: Origin label (e.g. ``"file"``, ``"paste"``, ``"agent"``).
            source_path: Optional filesystem path or URL the content came from.
            project_name: Single project name (created if absent). On update,
                non-destructive add — "ensure this membership exists, leave
                others alone."
            project_id: Single project UUID. Same non-destructive-add semantics
                as ``project_name`` on update.
            project_ids: Explicit list of project UUIDs. Full-set semantics —
                destructive replace on update. Wins over ``project_names``.
            project_names: Explicit list of project names (each created if
                absent). Full-set semantics — destructive replace on update.
                The name-based equivalent of ``project_ids``. Use when an agent
                wants to set the document's full project membership in one call.
            metadata: Arbitrary key/value pairs stored as JSONB on the document.
            update_existing: When True, look up an existing document by
                ``source_path`` (for file ingestion) or by ``title`` (for
                paste/agent content).  If found, update it in-place instead of
                creating a new document.  Falls through to normal create when no
                match is found.

        Returns:
            :class:`IngestResult` summary.
        """
        # ── ID-based update (explicit document_id provided) ───────────────────
        # Bypasses title lookup and hash dedup -- the caller explicitly named
        # which document to update.  Errors if the document does not exist.
        #
        # Project semantics on update (per issue #38):
        # - List form (``project_ids=[…]`` or ``project_names=[…]``): full-set
        #   semantics — destructive replace. Used by the web UI's edit form
        #   and by agents that want explicit set-the-membership semantics.
        # - Singular form (``project_name`` / ``project_id``): non-destructive
        #   add — "ensure this membership exists, leave others alone." Agents
        #   must not be able to silently strip memberships an operator added
        #   via the web UI when they only intended to add one.
        list_form_provided = project_ids is not None or project_names is not None
        if document_id is not None:
            existing_doc = self._client.get_document_by_id(document_id)
            if existing_doc is None:
                raise ValueError(f"Document not found: {document_id}")
            # Resolve the full-set list (if either form was provided) for the
            # destructive-replace path. Both project_ids and project_names
            # converge to a single resolved list here.
            full_set_resolved: list[str] | None = None
            if list_form_provided:
                full_set_resolved = self._resolve_project_ids(
                    project_ids=project_ids,
                    project_id=None,
                    project_name=None,
                    project_names=project_names,
                )
            result = self.update_document(
                document_id=document_id,
                text=text,
                title=title,
                source=source,
                project_ids=full_set_resolved,
                metadata=metadata,
                author=author,
                author_type=author_type,
            )
            # Non-destructive add for singular project_name / project_id on update.
            # Only fires when the caller did NOT supply a list form.
            if not list_form_provided and (project_id or project_name):
                singular_resolved = self._resolve_project_ids(
                    project_ids=None, project_id=project_id, project_name=project_name,
                )
                if singular_resolved:
                    self._client.add_document_to_projects(document_id, singular_resolved)
                    result.project_ids = self._client.get_document_project_ids(document_id)
            if not update_existing:
                result.note = "document_id provided; update_if_exists flag was overridden"
            return result

        # ── Update-existing shortcut ───────────────────────────────────────────
        if update_existing:
            existing_doc: dict | None = None
            lookup_key = "source_path" if source_path else "title"
            if source_path:
                existing_doc = self._client.find_document_by_source_path(source_path)
            if existing_doc is None:
                existing_doc = self._client.find_document_by_title(title)
            if existing_doc is not None:
                log.info(
                    "update_existing: found doc %s by %s, updating in place",
                    existing_doc["id"],
                    lookup_key,
                )
                # Same semantics as the document_id branch:
                # list form = full-set destructive replace,
                # singular form = non-destructive add (issue #38).
                full_set_resolved = None
                if list_form_provided:
                    full_set_resolved = self._resolve_project_ids(
                        project_ids=project_ids,
                        project_id=None,
                        project_name=None,
                        project_names=project_names,
                    )
                result = self.update_document(
                    document_id=existing_doc["id"],
                    text=text,
                    title=title,
                    source=source,
                    project_ids=full_set_resolved,
                    metadata=metadata,
                    author=author,
                    author_type=author_type,
                )
                if not list_form_provided and (project_id or project_name):
                    singular_resolved = self._resolve_project_ids(
                        project_ids=None, project_id=project_id, project_name=project_name,
                    )
                    if singular_resolved:
                        self._client.add_document_to_projects(existing_doc["id"], singular_resolved)
                        result.project_ids = self._client.get_document_project_ids(existing_doc["id"])
                return result
            log.info("update_existing: no existing doc found — creating new document")

        # Resolve project_ids from the various caller styles (singular or list, ID or name).
        resolved_ids = self._resolve_project_ids(
            project_ids, project_id, project_name, project_names=project_names,
        )

        validated_meta = metadata or {}

        content_hash = _hash(text)

        # ── Deduplication ──────────────────────────────────────────────────
        existing = self._client.get_document_by_hash(content_hash)
        if existing is not None:
            log.info("Document already ingested (hash match): %s", existing["id"])
            existing_project_ids = self._client.get_document_project_ids(existing["id"])
            return IngestResult(
                document_id=existing["id"],
                title=existing.get("title", title),
                chunk_count=existing.get("chunk_count", 0),
                total_chars=existing.get("total_chars", 0),
                action="skipped",
                project_ids=existing_project_ids,
            )

        # ── Chunk ─────────────────────────────────────────────────────────
        s = self._settings
        chunks = chunk_markdown(
            text,
            max_chunk_chars=s.max_chunk_chars,
            min_chunk_chars=s.min_chunk_chars,
        )
        total_chars = sum(c.char_count for c in chunks)

        # ── Derive source_path ─────────────────────────────────────────────
        # Derive a source_path from the title when none was provided (e.g. paste ingestion),
        # so every document always has a meaningful filename for downloads.
        if not source_path:
            import re  # noqa: PLC0415
            slug = re.sub(r"[^\w\s-]", "", title.lower())
            slug = re.sub(r"[\s_-]+", "-", slug).strip("-") or "document"
            source_path = f"{slug}.md"

        # ── Embed chunks ──────────────────────────────────────────────────
        # Prepend document title to embedding input (contextual enrichment).
        # The stored chunk.content is unchanged -- only the embedding vector
        # captures the title context. This improves semantic search recall
        # when searching by document name or related terms.
        chunk_rows: list[dict[str, Any]] = []
        if chunks:
            texts = [f"# {title}\n{c.content}" for c in chunks]
            embeddings = self._embedder.embed_batch(texts)

            chunk_rows = [
                {
                    "chunk_index": c.chunk_index,
                    "heading_path": c.heading_path,
                    "heading_level": c.heading_level,
                    "title": c.title,
                    "content": c.content,
                    "char_count": c.char_count,
                    "embedding_primary": embeddings[i],
                    "embedder_primary": self._embedder.model_name,
                }
                for i, c in enumerate(chunks)
            ]

        # ── Ingest via RPC (atomic: insert doc + chunks + audit entry) ────
        review_status = "pending_review" if author_type == "agent" else "approved"
        result = self._client.ingest_document_rpc(
            document_id=None,
            title=title,
            source=source,
            source_path=source_path,
            content_hash=content_hash,
            metadata=validated_meta,
            review_status=review_status,
            chunks=chunk_rows,
            author=author,
            author_type=author_type,
        )
        document_id: str = result["document_id"]
        log.info("Created document %s (%d chunks) via RPC", document_id, len(chunks))

        # ── Assign projects (M2M) ──────────────────────────────────────────
        if resolved_ids:
            self._client.assign_document_projects(document_id, resolved_ids)

        return IngestResult(
            document_id=document_id,
            title=title,
            chunk_count=len(chunks),
            total_chars=total_chars,
            action="created",
            project_ids=resolved_ids,
        )

    def update_document(
        self,
        document_id: str,
        text: str,
        title: str,
        source: str = "manual",
        project_id: str | None = None,
        project_ids: list[str] | None = None,
        metadata: dict | None = None,
        author: str = "unknown",
        author_type: str = "user",
    ) -> IngestResult:
        """Re-ingest an existing document in place, preserving its ID.

        When content changes, archives current chunks as a version (via
        cerefox_snapshot_version RPC) before inserting new ones. This provides
        accidental-deletion protection — previous content is recoverable for up
        to ``settings.version_retention_hours`` hours.

        When only title/metadata changes (content unchanged), no version is
        created and no re-embedding is performed.

        Project assignments are only updated when ``project_ids`` (or legacy
        ``project_id``) is explicitly provided; ``None`` leaves current
        assignments unchanged, while ``[]`` removes all project assignments.

        Args:
            document_id: UUID of the document to update.
            text: New markdown content.
            title: New document title.
            source: Origin label stored in the version record (e.g. ``"file"``,
                ``"paste"``, ``"agent"``).
            project_id: Single project UUID (legacy; prefer ``project_ids``).
            project_ids: New list of project UUIDs.  ``None`` = unchanged;
                ``[]`` = remove from all projects.
            metadata: New metadata dict. If None, existing metadata is preserved.

        Returns:
            :class:`IngestResult` summary (skipped=False always).

        Raises:
            ValueError: If the document is not found, or if the new content is
                identical to another document (hash collision on a different ID).
        """
        # ── Verify document exists ─────────────────────────────────────────
        existing = self._client.get_document_by_id(document_id)
        if existing is None:
            raise ValueError(f"Document {document_id!r} not found")

        # ── Hash check ─────────────────────────────────────────────────────
        new_hash = _hash(text)
        content_unchanged = new_hash == existing.get("content_hash")

        # Guard against collision with a *different* document.
        if not content_unchanged:
            collision = self._client.get_document_by_hash(new_hash)
            if collision is not None and collision["id"] != document_id:
                raise ValueError(
                    f"Identical content already exists as document {collision['title']!r}. "
                    "Edit that document or change the content before saving."
                )

        # metadata is passed through as-is (open-ended JSONB)

        # ── Resolve project associations ───────────────────────────────────
        new_project_ids: list[str] | None = None
        if project_ids is not None:
            new_project_ids = [p for p in project_ids if p]
        elif project_id is not None:
            new_project_ids = [project_id]

        # Check actual chunks in DB, not the stored chunk_count on the document
        # record. If a previous ingestion failed mid-way (e.g. embedding API error),
        # the document record may have chunk_count > 0 but zero actual chunks.
        actual_chunks = self._client.list_chunks_for_document(document_id)
        has_chunks = len(actual_chunks) > 0
        if content_unchanged and has_chunks:
            # Content didn't change and chunks exist — skip chunking, versioning,
            # and chunk swap.  Update title, metadata, and project associations.
            old_title = existing.get("title", "")
            title_changed = old_title != title

            update_data: dict = {"title": title}
            if metadata is not None:
                update_data["metadata"] = metadata
            self._client.update_document(document_id, update_data)

            # If title changed, refresh embeddings (contextual enrichment) and FTS
            # for all current chunks. No version snapshot — content is identical.
            if title_changed:
                log.info(
                    "Document %s title changed '%s' → '%s' -- re-embedding %d chunks",
                    document_id, old_title, title, len(actual_chunks),
                )
                texts = [f"# {title}\n{c['content']}" for c in actual_chunks]
                try:
                    embeddings = self._embedder.embed_batch(texts)
                    for chunk, embedding in zip(actual_chunks, embeddings):
                        self._client.update_chunk_embedding(
                            chunk["id"], embedding, self._embedder.model_name
                        )
                except Exception as exc:
                    log.warning(
                        "Failed to re-embed chunks after title change for %s: %s",
                        document_id, exc,
                    )
                try:
                    self._client.update_chunk_fts(document_id, title)
                except Exception as exc:
                    log.warning(
                        "Failed to update FTS after title change for %s: %s",
                        document_id, exc,
                    )

            if new_project_ids is not None:
                self._client.assign_document_projects(document_id, new_project_ids)
                final_project_ids = new_project_ids
            else:
                final_project_ids = self._client.get_document_project_ids(document_id)

            chunk_count = existing.get("chunk_count") or 0
            total_chars = existing.get("total_chars") or 0
            log.info("Document %s unchanged -- skipped reindex (%d chunks)", document_id, chunk_count)

            try:
                self._client.create_audit_entry(
                    operation="update-metadata",
                    author=author,
                    author_type=author_type,
                    document_id=document_id,
                    size_before=total_chars,
                    size_after=total_chars,
                    description=f"Updated metadata for '{title}' (content unchanged)",
                )
            except Exception as exc:
                log.warning("Failed to create audit entry for document %s: %s", document_id, exc)

            return IngestResult(
                document_id=document_id,
                title=title,
                chunk_count=chunk_count,
                total_chars=total_chars,
                action="updated",
                reindexed=False,
                project_ids=final_project_ids,
            )

        # ── Chunk + embed (content changed) ───────────────────────────────
        s = self._settings
        chunks = chunk_markdown(
            text,
            max_chunk_chars=s.max_chunk_chars,
            min_chunk_chars=s.min_chunk_chars,
        )
        total_chars = sum(c.char_count for c in chunks)

        # Embed before touching the DB -- if embedding fails we haven't broken anything.
        # Prepend document title for contextual enrichment (stored content unchanged).
        texts = [f"# {title}\n{c.content}" for c in chunks]
        embeddings = self._embedder.embed_batch(texts) if chunks else []

        chunk_rows = [
            {
                "chunk_index": c.chunk_index,
                "heading_path": c.heading_path,
                "heading_level": c.heading_level,
                "title": c.title,
                "content": c.content,
                "char_count": c.char_count,
                "embedding_primary": embeddings[i],
                "embedder_primary": self._embedder.model_name,
            }
            for i, c in enumerate(chunks)
        ] if chunks else []

        # ── Ingest via RPC (atomic: snapshot + update doc + insert chunks + audit) ──
        review_status = "pending_review" if author_type == "agent" else "approved"
        result = self._client.ingest_document_rpc(
            document_id=document_id,
            title=title,
            source=source,
            source_path=existing.get("source_path"),
            content_hash=new_hash,
            metadata=metadata if metadata is not None else existing.get("metadata", {}),
            review_status=review_status,
            chunks=chunk_rows,
            author=author,
            author_type=author_type,
            source_label=source,
            retention_hours=s.version_retention_hours,
            cleanup_enabled=s.version_cleanup_enabled,
        )
        log.info(
            "Updated document %s (%d chunks) via RPC, version_id=%s",
            document_id, len(chunks), result.get("version_id"),
        )

        # ── Update project associations if provided ────────────────────────
        if new_project_ids is not None:
            self._client.assign_document_projects(document_id, new_project_ids)
            final_project_ids = new_project_ids
        else:
            final_project_ids = self._client.get_document_project_ids(document_id)

        return IngestResult(
            document_id=document_id,
            title=title,
            chunk_count=len(chunks),
            total_chars=total_chars,
            action="updated",
            reindexed=True,
            project_ids=final_project_ids,
        )

    def ingest_file(
        self,
        path: str,
        title: str | None = None,
        project_name: str | None = None,
        project_ids: list[str] | None = None,
        metadata: dict | None = None,
        update_existing: bool = False,
        document_id: str | None = None,
        source: str | None = None,
        author: str = "unknown",
        author_type: str = "user",
    ) -> IngestResult:
        """Read a markdown file from disk and ingest it.

        Args:
            path: Absolute or relative path to a ``.md`` file.
            title: Document title.  Defaults to the filename stem.
            project_name: Optional project name to assign the document to.
            project_ids: Optional list of project UUIDs (M2M).
            metadata: Arbitrary JSONB metadata.
            update_existing: When True, update an existing document with the
                same ``source_path`` (resolved absolute path) instead of
                creating a new document.
            document_id: UUID of an existing document to update deterministically.
                Bypasses source-path matching. Errors if the document does not exist.
            source: Source label to record on the document. Defaults to ``"file"``.
            author: Free-text identity recorded in the audit log.
            author_type: ``"user"`` or ``"agent"``; drives review_status routing.

        Returns:
            :class:`IngestResult` summary.
        """
        from pathlib import Path  # noqa: PLC0415

        p = Path(path)
        text = p.read_text(encoding="utf-8")
        return self.ingest_text(
            text=text,
            title=title or p.stem,
            source=source or "file",
            source_path=str(p.resolve()),
            project_name=project_name,
            project_ids=project_ids,
            metadata=metadata,
            update_existing=update_existing,
            document_id=document_id,
            author=author,
            author_type=author_type,
        )

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

    def _resolve_project_ids(
        self,
        project_ids: list[str] | None,
        project_id: str | None,
        project_name: str | None,
        project_names: list[str] | None = None,
    ) -> list[str]:
        """Return a normalised list of project UUIDs from the caller's inputs.

        Precedence (highest wins):
          1. ``project_ids`` (explicit UUID list) — used as-is, empties stripped.
          2. ``project_names`` (explicit name list) — each name resolved via
             ``get_or_create_project`` and assembled in order.
          3. ``project_id`` (single UUID) — wrapped in a list.
          4. ``project_name`` (single name) — resolved and wrapped in a list.

        Note that tiers 1 and 2 carry FULL-SET semantics (destructive replace
        when passed to ``assign_document_projects``) and tiers 3 and 4 carry
        SINGLE-HINT semantics (non-destructive add via
        ``add_document_to_projects``). The caller chooses semantics by which
        argument they pass; this helper just resolves the values.
        """
        if project_ids is not None:
            return [p for p in project_ids if p]
        if project_names is not None:
            resolved: list[str] = []
            for name in project_names:
                if not name:
                    continue
                project = self._client.get_or_create_project(name)
                resolved.append(project["id"])
            return resolved
        if project_id:
            return [project_id]
        if project_name:
            project = self._client.get_or_create_project(project_name)
            return [project["id"]]
        return []


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


def _normalize(text: str) -> str:
    """Normalise content before hashing.

    Converts CRLF (and bare CR) line endings to LF, strips leading/trailing
    whitespace, and collapses 3+ consecutive newlines to two.  This ensures
    that hashes are stable across round-trips through the web edit form
    (browsers submit textarea content with CRLF per the HTML spec) and the
    TypeScript Edge Function.
    """
    text = text.replace("\r\n", "\n").replace("\r", "\n")
    return re.sub(r"\n{3,}", "\n\n", text.strip())


def _hash(text: str) -> str:
    """Return a SHA-256 hex digest of the normalised UTF-8 encoded text."""
    return hashlib.sha256(_normalize(text).encode("utf-8")).hexdigest()
