"""Browse, timeline, changelog, request-log, and count operations mixin."""
from __future__ import annotations

import json
from dataclasses import asdict
from typing import Any, Dict, List, Optional

from cortex.storage._helpers import _now, _row_to_claim

# Import from package __init__ — single source of truth.
from cortex.storage.queries.claims import _MAX_PAGE_SIZE


def _claim_to_dict(claim: Any) -> Dict[str, Any]:
    """Convert a SemanticClaim dataclass to a JSON-safe dict.

    Uses ``dataclasses.asdict`` for full field coverage, then converts any
    non-JSON-serialisable values (e.g. ``datetime`` objects) to strings.
    """
    d = asdict(claim)
    for k, v in d.items():
        if hasattr(v, "isoformat"):          # datetime → str
            d[k] = v.isoformat()
    return d


class ClaimBrowseMixin:
    """Mixin providing browse, timeline, changelog, request-log, and count ops."""

    async def log_changelog(
        self,
        claim_id: str,
        version: int,
        content_snapshot: str,
        action: str,
        reason: Optional[str],
        changed_by: str,
        diff_summary: Optional[str] = None,
    ) -> Dict[str, Any]:
        """Insert an immutable audit entry into claim_changelog."""
        async with self._write_lock:
            conn = await self._get_conn()
            now = _now()
            async with conn.execute(
                """
                INSERT INTO claim_changelog
                    (claim_id, version, content_snapshot, action, reason,
                     changed_by, diff_summary, created_at)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?)
                RETURNING *
                """,
                (claim_id, version, content_snapshot, action, reason,
                 changed_by, diff_summary, now),
            ) as cur:
                row = await cur.fetchone()
            await conn.commit()
            return dict(row)

    async def get_changelog(self, claim_id: str) -> List[Dict[str, Any]]:
        """Return all changelog entries for *claim_id*, ordered by version ascending."""
        conn = await self._get_conn()
        async with conn.execute(
            """
            SELECT * FROM claim_changelog
            WHERE claim_id = ?
            ORDER BY version ASC
            """,
            (claim_id,),
        ) as cur:
            rows = await cur.fetchall()
        return [dict(r) for r in rows]

    async def browse_memories(
        self,
        owner_id: str = "default",
        page: int = 1,
        per_page: int = 50,
        time_range: str = "all",
        status: Optional[str] = None,
        category: Optional[str] = None,
        entity_name: Optional[str] = None,
        q: Optional[str] = None,
        sort_by: str = "updated_at",
        sort_order: str = "desc",
        source_session_id: Optional[str] = None,
        include_deleted: bool = False,
        include_expired: bool = False,
        exclude_source_session: Optional[str] = None,
        include_embeddings: bool = False,
    ) -> Dict[str, Any]:
        """Browse and filter semantic claims with pagination.

        Returns a dict with keys: items (list of dicts), total, page, per_page,
        pages, category_counts.
        """
        # Clamp per_page at the storage layer (L2 audit fix).
        # The HTTP layer also caps this, but storage must enforce its own ceiling
        # so that direct storage callers cannot bypass the limit.
        per_page = min(int(per_page), _MAX_PAGE_SIZE)
        conn = await self._get_conn()

        # Validate sort params to prevent SQL injection
        _valid_sort_by = {"updated_at", "created_at", "confidence", "access_count", "mentions"}
        _valid_sort_order = {"desc", "asc"}
        if sort_by not in _valid_sort_by:
            sort_by = "updated_at"
        if sort_order not in _valid_sort_order:
            sort_order = "desc"

        # Build WHERE clause
        conditions: List[str] = ["sc.owner_id = ?"]
        params: List[Any] = [owner_id]

        # Soft-delete filter (exclude by default)
        if not include_deleted:
            conditions.append("(sc.is_deleted = 0 OR sc.is_deleted IS NULL)")

        # Temporal expiry filter — exclude claims whose valid_to is in the past (#925)
        if not include_expired:
            conditions.append(
                "(sc.valid_to IS NULL OR sc.valid_to > datetime('now'))"
            )

        # Status filter
        if status and status != "all":
            conditions.append("sc.status = ?")
            params.append(status)

        # Category filter
        if category:
            conditions.append("sc.category = ?")
            params.append(category)

        # Time range filter
        if time_range == "1d":
            conditions.append("sc.updated_at >= datetime('now', '-1 day')")
        elif time_range == "7d":
            conditions.append("sc.updated_at >= datetime('now', '-7 days')")
        elif time_range == "30d":
            conditions.append("sc.updated_at >= datetime('now', '-30 days')")

        # Provenance filter (issue #748) — filter by source_session_id in metadata_json
        if source_session_id:
            conditions.append("json_extract(sc.metadata_json, '$.source_session_id') = ?")
            params.append(source_session_id)

        # Exclude claims from a specific source session (#1220 — echo loop prevention)
        if exclude_source_session:
            conditions.append(
                "(sc.source_session IS NULL OR sc.source_session != ?)"
            )
            params.append(exclude_source_session)
            conditions.append(
                "(sc.metadata_json IS NULL OR json_extract(sc.metadata_json, '$.source_session_id') IS NULL"
                " OR json_extract(sc.metadata_json, '$.source_session_id') != ?)"
            )
            params.append(exclude_source_session)

        # Entity name filter (join entity aliases)
        if entity_name:
            # Escape LIKE wildcards in user input (#220)
            safe_name = entity_name.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
            conditions.append(
                "sc.subject_entity_id IN ("
                "  SELECT entity_id FROM entity_alias"
                "  WHERE alias LIKE ? ESCAPE '\\' AND owner_id = ?"
                ")"
            )
            params.extend([f"%{safe_name}%", owner_id])

        # Keyword search via FTS
        fts_ids: Optional[List[str]] = None
        if q:
            try:
                sanitized_q = self._sanitize_fts_query_strict(q)
                async with conn.execute(
                    "SELECT claim_id FROM fts_claims WHERE fts_claims MATCH ?",
                    (sanitized_q,),
                ) as cur:
                    fts_rows = await cur.fetchall()
                fts_ids = [r[0] for r in fts_rows]
                if fts_ids:
                    placeholders = ",".join("?" for _ in fts_ids)
                    conditions.append(f"sc.id IN ({placeholders})")
                    params.extend(fts_ids)
                else:
                    # No FTS match — return empty result quickly
                    return {
                        "items": [], "total": 0, "page": page,
                        "per_page": per_page, "pages": 0,
                        "category_counts": {},
                    }
            except Exception:
                # FTS not available — fall back to LIKE search
                # Escape LIKE wildcards in user input (#220)
                safe_q = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
                conditions.append(
                    "(sc.subject LIKE ? ESCAPE '\\'"
                    " OR sc.predicate LIKE ? ESCAPE '\\'"
                    " OR sc.object_value LIKE ? ESCAPE '\\')"
                )
                like_q = f"%{safe_q}%"
                params.extend([like_q, like_q, like_q])

        where_clause = " AND ".join(conditions)

        # Count total
        count_sql = f"SELECT COUNT(*) FROM semantic_claim sc WHERE {where_clause}"
        async with conn.execute(count_sql, params) as cur:
            row = await cur.fetchone()
        total = row[0] if row else 0

        # Fetch page
        offset = (page - 1) * per_page
        select_sql = (
            f"SELECT sc.* FROM semantic_claim sc "
            f"WHERE {where_clause} "
            f"ORDER BY sc.{sort_by} {sort_order.upper()} "
            f"LIMIT ? OFFSET ?"
        )
        async with conn.execute(select_sql, params + [per_page, offset]) as cur:
            rows = await cur.fetchall()

        items = [_row_to_claim(r) for r in rows]
        items_out = []
        for c in items:
            # Build a human-readable content field from the SPO triple
            obj_preview = (c.object_value[:200] if c.object_value else "")
            content = f"{c.subject} {c.predicate} {obj_preview}".strip()
            # Full claim serialization — expose all SemanticClaim fields
            item_dict = _claim_to_dict(c)
            item_dict["content"] = content           # computed summary field
            item_dict["object_value"] = obj_preview  # truncated for browse

            # Surface provenance fields from metadata_json (issue #748)
            _meta_raw = item_dict.get("metadata_json")
            if _meta_raw:
                try:
                    _meta = json.loads(_meta_raw) if isinstance(_meta_raw, str) else _meta_raw
                    for _pk in ("source_session_id", "source_message_id", "source_channel", "source_type"):
                        if _pk in _meta:
                            item_dict[_pk] = _meta[_pk]
                except (ValueError, TypeError):
                    pass
            items_out.append(item_dict)

        # Attach embeddings when requested (#R-297)
        if include_embeddings and items_out:
            import base64
            claim_ids = [it["id"] for it in items_out]
            placeholders = ",".join("?" for _ in claim_ids)
            async with conn.execute(
                f"SELECT item_id, embedding, model_id "
                f"FROM embedding_index "
                f"WHERE item_type = 'claim' AND item_id IN ({placeholders}) "
                f"ORDER BY created_at DESC",
                claim_ids,
            ) as cur:
                emb_rows = await cur.fetchall()
            emb_map: Dict[str, Any] = {}
            for r in emb_rows:
                # Handle both tuple and dict row types; ORDER BY created_at DESC
                # means first row per item_id is newest — skip dupes
                if isinstance(r, (list, tuple)):
                    item_id, blob, model = r[0], r[1], r[2]
                else:
                    item_id, blob, model = r["item_id"], r["embedding"], r["model_id"]
                if item_id not in emb_map:  # keep newest (first seen due to DESC order)
                    emb_map[item_id] = {
                        "embedding": base64.b64encode(blob).decode() if blob else None,
                        "embedding_model": model,
                    }
            for it in items_out:
                emb_data = emb_map.get(it["id"])
                if emb_data:
                    it["embedding"] = emb_data["embedding"]
                    it["embedding_model"] = emb_data["embedding_model"]
                else:
                    it["embedding"] = None
                    it["embedding_model"] = None

        # Category counts (across owner, excluding soft-deleted; expired filter matches main query)
        _cat_conditions = [
            "owner_id = ?",
            "(is_deleted = 0 OR is_deleted IS NULL)",
        ]
        if not include_expired:
            _cat_conditions.append(
                "(valid_to IS NULL OR valid_to > datetime('now'))"
            )
        _cat_params: List[Any] = [owner_id]
        if status and status != "all":
            _cat_conditions.append("status = ?")
            _cat_params.append(status)
        _cat_where = " AND ".join(_cat_conditions)
        async with conn.execute(
            f"SELECT category, COUNT(*) FROM semantic_claim WHERE {_cat_where} GROUP BY category",
            _cat_params,
        ) as cur:
            cat_rows = await cur.fetchall()
        category_counts = {r[0]: r[1] for r in cat_rows}

        pages = max(1, (total + per_page - 1) // per_page) if total > 0 else 0

        return {
            "items": items_out,
            "total": total,
            "page": page,
            "per_page": per_page,
            "pages": pages,
            "category_counts": category_counts,
        }

    async def get_timeline(
        self,
        subject: str,
        predicate: Optional[str] = None,
        owner_id: str = "default",
    ) -> List[Dict[str, Any]]:
        """Return the temporal history for a subject (+ optional predicate).

        Returns all claims — including expired — ordered by valid_from ASC.
        Useful for auditing how a fact changed over time (#925).
        """
        conn = await self._get_conn()
        conditions = [
            "owner_id = ?",
            "subject = ?",
            "(is_deleted = 0 OR is_deleted IS NULL)",
        ]
        params: List[Any] = [owner_id, subject]
        if predicate:
            conditions.append("predicate = ?")
            params.append(predicate)

        where_clause = " AND ".join(conditions)
        sql = (
            f"SELECT * FROM semantic_claim WHERE {where_clause} "
            "ORDER BY COALESCE(valid_from, created_at) ASC"
        )
        async with conn.execute(sql, params) as cur:
            rows = await cur.fetchall()

        result = []
        for c in (_row_to_claim(r) for r in rows):
            # Full claim serialization — expose all SemanticClaim fields
            result.append(_claim_to_dict(c))
        return result

    async def get_category_counts(self, owner_id: str = "default") -> List[Dict[str, Any]]:
        """Return per-category claim counts for the given owner.

        Excludes soft-deleted and temporally expired claims to match
        the browse_memories behaviour (#R-242).
        """
        conn = await self._get_conn()
        async with conn.execute(
            """
            SELECT category, COUNT(*) as cnt
            FROM semantic_claim
            WHERE owner_id = ?
              AND (is_deleted = 0 OR is_deleted IS NULL)
              AND (valid_to IS NULL OR valid_to > datetime('now'))
            GROUP BY category
            ORDER BY cnt DESC
            """,
            (owner_id,),
        ) as cur:
            rows = await cur.fetchall()
        return [{"name": r[0], "count": r[1]} for r in rows]

    async def log_request(
        self,
        owner_id: str,
        request_type: str,
        entities: Optional[Any] = None,
        has_results: Optional[bool] = None,
        latency_ms: Optional[int] = None,
        payload: Optional[Any] = None,
        response_summary: Optional[str] = None,
        status: str = "success",
        session_id: Optional[str] = None,
    ) -> Dict[str, Any]:
        """Insert a structured request log entry."""
        async with self._write_lock:
            conn = await self._get_conn()
            now = _now()

            entities_json: Optional[str] = None
            if entities is not None:
                entities_json = json.dumps(entities) if not isinstance(entities, str) else entities

            request_payload: Optional[str] = None
            if payload is not None:
                request_payload = json.dumps(payload) if not isinstance(payload, str) else payload

            has_results_int: Optional[int] = None
            if has_results is not None:
                has_results_int = 1 if has_results else 0

            async with conn.execute(
                """
                INSERT INTO request_log
                    (owner_id, request_type, entities_json, has_results, latency_ms,
                     request_payload, response_summary, status, session_id, created_at)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                RETURNING *
                """,
                (owner_id, request_type, entities_json, has_results_int, latency_ms,
                 request_payload, response_summary, status, session_id, now),
            ) as cur:
                row = await cur.fetchone()
            await conn.commit()
            return dict(row)

    async def get_request_log(
        self,
        owner_id: str,
        request_type: Optional[str] = None,
        limit: int = 50,
        offset: int = 0,
    ) -> List[Dict[str, Any]]:
        """Return paginated request log entries for an owner."""
        conn = await self._get_conn()
        if request_type is not None:
            async with conn.execute(
                """
                SELECT * FROM request_log
                WHERE owner_id = ? AND request_type = ?
                ORDER BY created_at DESC
                LIMIT ? OFFSET ?
                """,
                (owner_id, request_type, limit, offset),
            ) as cur:
                rows = await cur.fetchall()
        else:
            async with conn.execute(
                """
                SELECT * FROM request_log
                WHERE owner_id = ?
                ORDER BY created_at DESC
                LIMIT ? OFFSET ?
                """,
                (owner_id, limit, offset),
            ) as cur:
                rows = await cur.fetchall()
        return [dict(r) for r in rows]

    async def count_claims(self, owner_id: str = "default", include_deleted: bool = False) -> int:
        conn = await self._get_conn()
        if include_deleted:
            sql = "SELECT COUNT(*) FROM semantic_claim WHERE owner_id = ?"
        else:
            sql = "SELECT COUNT(*) FROM semantic_claim WHERE owner_id = ? AND (is_deleted = 0 OR is_deleted IS NULL)"
        async with conn.execute(sql, (owner_id,)) as cur:
            row = await cur.fetchone()
        return row[0] if row else 0
