"""
cortex/workers/embedding_backfill.py — Background embedding catch-up worker (#1443).

Self-healing background worker that finds claims missing embeddings and
fills them in. Runs as a daemon thread with its own event loop, similar
to BatchWorker (#1326).

Architecture:
    Scanner  → finds active claims with no embedding_index row → inserts into embedding_backfill
    Processor → takes pending items, embeds them, stores in embedding_index → marks done
    Runner   → daemon thread that runs scanner + processor on a configurable interval

The worker is independent — it does NOT import or depend on the extraction
pipeline. It uses the same LLM provider embed() call the pipeline uses.
"""
from __future__ import annotations

import asyncio
import logging
import struct
import threading
import time
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional

import aiosqlite

logger = logging.getLogger(__name__)


def _now_iso() -> str:
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def _pack_floats(v: List[float]) -> bytes:
    """Pack float list to binary blob (same as storage._helpers._pack_floats)."""
    return struct.pack(f"{len(v)}f", *v)


class EmbeddingBackfillWorker:
    """Background worker that finds and fills missing embeddings.

    Runs in a daemon thread with its own asyncio event loop and its own
    database connection, avoiding cross-event-loop issues.

    Args:
        db_path: Path to the SQLite database.
        plugin: CortexPlugin instance (used to access LLM provider for embeddings).
        batch_size: Number of items to process per batch (default 50).
        interval_seconds: Seconds between scan/process cycles (default 300).
        max_retries: Max attempts before marking an item as 'failed' (default 3).
    """

    def __init__(
        self,
        db_path: str,
        plugin: Any = None,
        *,
        batch_size: int = 50,
        interval_seconds: int = 300,
        max_retries: int = 3,
    ) -> None:
        self._db_path = db_path
        self._plugin = plugin
        self._batch_size = batch_size
        self._interval_seconds = interval_seconds
        self._max_retries = max_retries

        # Thread management
        self._thread: Optional[threading.Thread] = None
        self._stop_event = threading.Event()

        # Observable state
        self._started_at: Optional[str] = None
        self._last_scan_at: Optional[str] = None
        self._last_batch: Optional[Dict[str, Any]] = None
        self._state: str = "idle"  # idle | starting | running | crashed | stopped

    @property
    def db_path(self) -> str:
        """Public accessor for database path."""
        return self._db_path

    @property
    def is_alive(self) -> bool:
        return self._thread is not None and self._thread.is_alive()

    @property
    def status(self) -> dict:
        return {
            "alive": self.is_alive,
            "state": self._state,
            "started_at": self._started_at,
            "last_scan_at": self._last_scan_at,
            "last_batch": self._last_batch,
        }

    def start(self) -> None:
        """Start the background worker thread."""
        if self.is_alive:
            logger.warning("Embedding backfill worker already running")
            return
        self._stop_event.clear()
        self._state = "starting"
        self._started_at = _now_iso()
        self._thread = threading.Thread(
            target=self._run_thread,
            name="embedding-backfill-worker",
            daemon=True,
        )
        self._thread.start()
        logger.info("Embedding backfill worker started (batch_size=%d, interval=%ds)",
                     self._batch_size, self._interval_seconds)

    def stop(self) -> None:
        """Signal the worker to stop gracefully."""
        self._stop_event.set()
        if self._thread is not None:
            self._thread.join(timeout=30)
        self._state = "stopped"
        logger.info("Embedding backfill worker stopped")

    def _run_thread(self) -> None:
        """Thread entry point — create event loop and run the async loop."""
        loop = asyncio.new_event_loop()
        asyncio.set_event_loop(loop)
        try:
            loop.run_until_complete(self._run_loop())
        except Exception as exc:
            self._state = "crashed"
            logger.error("Embedding backfill worker crashed: %s", exc, exc_info=True)
        finally:
            loop.close()

    async def _run_loop(self) -> None:
        """Main async loop: scan then process, repeat on interval."""
        self._state = "running"
        while not self._stop_event.is_set():
            try:
                async with aiosqlite.connect(self._db_path) as conn:
                    conn.row_factory = aiosqlite.Row
                    # Ensure table exists (migration may not have run yet)
                    await self._ensure_table(conn)
                    # Reset any 'processing' items left from a previous crash
                    await self._reset_stale_processing(conn)

                    while not self._stop_event.is_set():
                        scan_count = await self._scan_missing_embeddings(conn)
                        self._last_scan_at = _now_iso()
                        if scan_count > 0:
                            logger.info("Embedding backfill: scanned %d missing embeddings", scan_count)

                        batch_result = await self._process_batch(conn)
                        if batch_result and batch_result["size"] > 0:
                            self._last_batch = batch_result
                            remaining = await self._count_pending(conn)
                            logger.info(
                                "Embedding backfill: processed %d, success %d, failed %d, queue remaining %d",
                                batch_result["size"],
                                batch_result["success"],
                                batch_result["failed"],
                                remaining,
                            )

                        # Wait for next cycle, checking stop every second
                        for _ in range(self._interval_seconds):
                            if self._stop_event.is_set():
                                return
                            await asyncio.sleep(1)

            except Exception as exc:
                logger.error("Embedding backfill worker loop error: %s", exc, exc_info=True)
                # Wait before retrying the connection
                for _ in range(30):
                    if self._stop_event.is_set():
                        return
                    await asyncio.sleep(1)

    async def _ensure_table(self, conn: aiosqlite.Connection) -> None:
        """Create the backfill table if migration hasn't run yet."""
        await conn.execute("""
            CREATE TABLE IF NOT EXISTS embedding_backfill (
                id           INTEGER PRIMARY KEY AUTOINCREMENT,
                claim_id     TEXT NOT NULL UNIQUE,
                status       TEXT NOT NULL DEFAULT 'pending',
                attempts     INTEGER NOT NULL DEFAULT 0,
                last_error   TEXT,
                created_at   TEXT NOT NULL,
                started_at   TEXT,
                completed_at TEXT
            )
        """)
        await conn.execute(
            "CREATE INDEX IF NOT EXISTS idx_backfill_status ON embedding_backfill(status)"
        )
        await conn.commit()

    async def _reset_stale_processing(self, conn: aiosqlite.Connection) -> None:
        """Reset 'processing' items back to 'pending' on startup (crash recovery)."""
        await conn.execute(
            "UPDATE embedding_backfill SET status = 'pending', started_at = NULL "
            "WHERE status = 'processing'"
        )
        await conn.commit()

    async def _scan_missing_embeddings(self, conn: aiosqlite.Connection) -> int:
        """Find active claims with no embedding and insert into backfill queue.

        Returns the count of newly inserted items.
        """
        async with conn.execute("""
            SELECT sc.id
            FROM semantic_claim sc
            LEFT JOIN embedding_index ei ON ei.item_type = 'claim' AND ei.item_id = sc.id
            LEFT JOIN embedding_backfill eb ON eb.claim_id = sc.id
            WHERE sc.status = 'active'
              AND ei.id IS NULL
              AND eb.id IS NULL
        """) as cur:
            rows = await cur.fetchall()

        if not rows:
            return 0

        now = _now_iso()
        for row in rows:
            try:
                await conn.execute(
                    "INSERT OR IGNORE INTO embedding_backfill (claim_id, status, created_at) "
                    "VALUES (?, 'pending', ?)",
                    (row["id"], now),
                )
            except Exception:
                pass  # UNIQUE constraint — already queued

        await conn.commit()
        return len(rows)

    async def _process_batch(self, conn: aiosqlite.Connection) -> Optional[Dict[str, Any]]:
        """Process up to batch_size pending items.

        Returns a dict with batch stats, or None if no items to process.
        """
        # Fetch pending items
        async with conn.execute(
            "SELECT id, claim_id FROM embedding_backfill "
            "WHERE status = 'pending' ORDER BY id LIMIT ?",
            (self._batch_size,),
        ) as cur:
            items = await cur.fetchall()

        if not items:
            return None

        # Mark as processing
        now = _now_iso()
        item_ids = [item["id"] for item in items]
        placeholders = ",".join("?" for _ in item_ids)
        await conn.execute(
            f"UPDATE embedding_backfill SET status = 'processing', started_at = ? "
            f"WHERE id IN ({placeholders})",
            [now, *item_ids],
        )
        await conn.commit()

        success_count = 0
        fail_count = 0
        start_time = time.monotonic()

        for item in items:
            backfill_id = item["id"]
            claim_id = item["claim_id"]
            try:
                # Check if claim still exists and is active
                async with conn.execute(
                    "SELECT subject, predicate, object_value, status "
                    "FROM semantic_claim WHERE id = ?",
                    (claim_id,),
                ) as cur:
                    claim_row = await cur.fetchone()

                if claim_row is None or claim_row["status"] != "active":
                    # Claim was deleted or deactivated — mark done and skip
                    await conn.execute(
                        "UPDATE embedding_backfill SET status = 'done', completed_at = ? "
                        "WHERE id = ?",
                        (_now_iso(), backfill_id),
                    )
                    await conn.commit()
                    success_count += 1
                    continue

                # Check if embedding was added by another process in the meantime
                async with conn.execute(
                    "SELECT id FROM embedding_index WHERE item_type = 'claim' AND item_id = ?",
                    (claim_id,),
                ) as cur:
                    existing = await cur.fetchone()

                if existing:
                    # Already embedded — mark done
                    await conn.execute(
                        "UPDATE embedding_backfill SET status = 'done', completed_at = ? "
                        "WHERE id = ?",
                        (_now_iso(), backfill_id),
                    )
                    await conn.commit()
                    success_count += 1
                    continue

                # Build embed text (same format as wake_pipeline)
                embed_text = (
                    f"{claim_row['subject']} "
                    f"{claim_row['predicate']} "
                    f"{claim_row['object_value']}"
                )

                # Get embedding from provider
                llm = self._plugin.llm
                config = self._plugin.config
                embed_model = config.embedding_model

                embed_response = await llm.embed(
                    texts=[embed_text],
                    input_type="document",
                )

                if not embed_response or not embed_response.embeddings:
                    raise RuntimeError("Empty embedding response from provider")

                vector = embed_response.embeddings[0]
                if not vector:
                    raise RuntimeError("Empty embedding vector")

                # Store in embedding_index (same logic as store_embedding)
                import math
                if any(math.isnan(x) or math.isinf(x) for x in vector):
                    raise RuntimeError("NaN/Inf in embedding vector")
                if all(x == 0.0 for x in vector):
                    raise RuntimeError("Zero-vector embedding")

                blob = _pack_floats(vector)

                # Delete any existing (upsert pattern)
                await conn.execute(
                    "DELETE FROM embedding_index WHERE item_type = 'claim' AND item_id = ? AND model_id = ?",
                    (claim_id, embed_model),
                )
                await conn.execute(
                    "INSERT INTO embedding_index (item_type, item_id, embedding, model_id, created_at) "
                    "VALUES ('claim', ?, ?, ?, ?)",
                    (claim_id, blob, embed_model, _now_iso()),
                )

                # Also insert into vec_index if available
                try:
                    await conn.execute(
                        "DELETE FROM vec_index WHERE item_type = 'claim' AND item_id = ?",
                        (claim_id,),
                    )
                    await conn.execute(
                        "INSERT INTO vec_index (item_type, item_id, embedding) VALUES ('claim', ?, ?)",
                        (claim_id, blob),
                    )
                except Exception:
                    pass  # vec_index may not be available

                # Mark done
                await conn.execute(
                    "UPDATE embedding_backfill SET status = 'done', completed_at = ? "
                    "WHERE id = ?",
                    (_now_iso(), backfill_id),
                )
                await conn.commit()
                success_count += 1

            except Exception as exc:
                fail_count += 1
                error_msg = str(exc)[:500]

                # Increment attempts, check if exceeded max retries
                async with conn.execute(
                    "SELECT attempts FROM embedding_backfill WHERE id = ?",
                    (backfill_id,),
                ) as cur:
                    row = await cur.fetchone()
                    current_attempts = (row["attempts"] if row else 0) + 1

                if current_attempts >= self._max_retries:
                    new_status = "failed"
                else:
                    new_status = "pending"

                await conn.execute(
                    "UPDATE embedding_backfill SET status = ?, attempts = ?, "
                    "last_error = ?, started_at = NULL WHERE id = ?",
                    (new_status, current_attempts, error_msg, backfill_id),
                )
                await conn.commit()

                logger.warning(
                    "Embedding backfill failed for claim %s (attempt %d/%d): %s",
                    claim_id, current_attempts, self._max_retries, error_msg,
                )

        duration_ms = int((time.monotonic() - start_time) * 1000)
        return {
            "size": len(items),
            "success": success_count,
            "failed": fail_count,
            "duration_ms": duration_ms,
        }

    async def _count_pending(self, conn: aiosqlite.Connection) -> int:
        """Count items still pending in the backfill queue."""
        async with conn.execute(
            "SELECT COUNT(*) FROM embedding_backfill WHERE status = 'pending'"
        ) as cur:
            row = await cur.fetchone()
            return row[0] if row else 0

    async def get_queue_stats(self, conn: aiosqlite.Connection) -> Dict[str, int]:
        """Get counts by status for the health endpoint."""
        result: Dict[str, int] = {"pending": 0, "processing": 0, "done": 0, "failed": 0}
        async with conn.execute(
            "SELECT status, COUNT(*) as cnt FROM embedding_backfill GROUP BY status"
        ) as cur:
            rows = await cur.fetchall()
            for row in rows:
                result[row["status"]] = row["cnt"]
        return result
