"""Redis cache layer — the ONLY module that reads from Redis for task data.
Falls back to store.py on cache miss, then syncs."""

from __future__ import annotations

import json
import logging
import time

import asyncpg
from redis.asyncio import Redis

from loom.bus import channels
from loom.graph import store
from loom.graph.task import PRIORITY_SCORES, Task, TaskStatus

logger = logging.getLogger(__name__)


class _CircuitBreaker:
    """In-memory circuit breaker for Redis operations."""

    def __init__(self, failure_threshold: int = 5, cooldown_seconds: float = 30.0):
        self._failure_threshold = failure_threshold
        self._cooldown_seconds = cooldown_seconds
        self._consecutive_failures = 0
        self._last_failure_time = 0.0
        self._state = "closed"  # closed | open | half-open

    @property
    def state(self) -> str:
        if self._state == "open":
            if time.monotonic() - self._last_failure_time >= self._cooldown_seconds:
                self._state = "half-open"
                logger.warning("Circuit breaker transitioning to half-open")
        return self._state

    def record_success(self) -> None:
        if self._state == "half-open":
            logger.warning("Circuit breaker closing after successful probe")
        self._consecutive_failures = 0
        self._state = "closed"

    def record_failure(self) -> None:
        self._consecutive_failures += 1
        self._last_failure_time = time.monotonic()
        if self._consecutive_failures >= self._failure_threshold and self._state == "closed":
            self._state = "open"
            logger.warning(
                "Circuit breaker opening after %d consecutive failures",
                self._consecutive_failures,
            )

    @property
    def should_allow_request(self) -> bool:
        state = self.state  # triggers half-open check
        return state in ("closed", "half-open")


# Module-level circuit breaker instance
_redis_cb = _CircuitBreaker()


# Fields stored in the Redis hash for each task
_HASH_FIELDS = [
    "id", "project_id", "title", "status", "priority", "assignee",
    "parent_id", "context", "output", "done_when", "depends_on",
    "created_at", "updated_at", "claimed_at", "done_at",
    # Phase 3: claim TTL, retry, dead letter
    "claim_expires_at", "retry_count", "max_retries",
    "last_failed_at", "retry_after", "dead_letter", "dead_letter_reason",
]


def _task_to_hash(task: Task) -> dict[str, str]:
    """Serialize a Task to a flat dict of strings for Redis hash storage."""
    data: dict[str, str] = {}
    for field in _HASH_FIELDS:
        val = getattr(task, field)
        if val is None:
            data[field] = ""
        elif isinstance(val, (dict, list)):
            data[field] = json.dumps(val)
        else:
            data[field] = str(val)
    return data


def _hash_to_task(data: dict[str, str]) -> Task:
    """Deserialize a Redis hash dict back to a Task."""
    parsed: dict = {}
    for field in _HASH_FIELDS:
        val = data.get(field, "")
        if field in ("context", "output"):
            parsed[field] = json.loads(val) if val else {}
        elif field == "depends_on":
            parsed[field] = json.loads(val) if val else []
        elif field in ("created_at", "updated_at", "claimed_at", "done_at",
                        "claim_expires_at", "last_failed_at", "retry_after"):
            parsed[field] = val if val else None
        elif field in ("assignee", "parent_id", "done_when", "dead_letter_reason"):
            parsed[field] = val if val else None
        elif field in ("retry_count", "max_retries"):
            parsed[field] = int(val) if val else 0
        elif field == "dead_letter":
            parsed[field] = val.lower() == "true" if val else False
        else:
            parsed[field] = val
    return Task(**parsed)


async def sync_task(redis: Redis, task: Task) -> None:
    """Write a task to its Redis hash and update status sets."""
    key = channels.task_key(task.project_id, task.id)
    await redis.hset(key, mapping=_task_to_hash(task))

    # Update status sets: remove from all, add to current
    project_id = task.project_id
    for status in TaskStatus:
        await redis.srem(channels.status_set(project_id, status.value), task.id)
    await redis.sadd(channels.status_set(project_id, task.status.value), task.id)


async def add_to_ready_queue(redis: Redis, task: Task) -> None:
    """Add a task to the ready sorted set with its priority score."""
    score = PRIORITY_SCORES.get(task.priority, 200)
    await redis.zadd(
        channels.ready_queue(task.project_id),
        {task.id: score},
    )


async def remove_from_ready_queue(redis: Redis, project_id: str, task_id: str) -> None:
    """Remove a task from the ready queue."""
    await redis.zrem(channels.ready_queue(project_id), task_id)


async def get_task(
    redis: Redis, pool: asyncpg.Pool, project_id: str, task_id: str
) -> Task:
    """Read task from Redis hash, falling back to Postgres on miss."""
    if _redis_cb.should_allow_request:
        try:
            key = channels.task_key(project_id, task_id)
            data = await redis.hgetall(key)
            _redis_cb.record_success()
            if data and data.get("id"):
                return _hash_to_task(data)
        except Exception:
            _redis_cb.record_failure()
            logger.warning("Redis read failed for task %s, falling back to Postgres", task_id)
    # Cache miss or circuit open — load from Postgres and sync
    task = await store.get_task(pool, task_id)
    try:
        await sync_task(redis, task)
    except Exception:
        pass  # Best-effort sync
    return task


async def get_ready_tasks(
    redis: Redis,
    pool: asyncpg.Pool,
    project_id: str,
    limit: int = 10,
    priority: str | None = None,
) -> list[Task]:
    """Get ready tasks from Redis sorted set, falling back to Postgres."""
    if not _redis_cb.should_allow_request:
        # Circuit open — go straight to Postgres
        tasks = await store.get_ready_tasks(pool, project_id, limit=limit)
        if priority:
            tasks = [t for t in tasks if t.priority == priority]
        return tasks[:limit]

    try:
        queue_key = channels.ready_queue(project_id)
        count = await redis.zcard(queue_key)
        _redis_cb.record_success()
    except Exception:
        _redis_cb.record_failure()
        logger.warning("Redis read failed for ready queue, falling back to Postgres")
        tasks = await store.get_ready_tasks(pool, project_id, limit=limit)
        if priority:
            tasks = [t for t in tasks if t.priority == priority]
        return tasks[:limit]

    if count == 0:
        # Fallback to Postgres
        tasks = await store.get_ready_tasks(pool, project_id, limit=limit)
        # Sync what we found
        for t in tasks:
            await sync_task(redis, t)
            await add_to_ready_queue(redis, t)
        if priority:
            tasks = [t for t in tasks if t.priority == priority]
        return tasks[:limit]

    # Read from sorted set (highest score first)
    task_ids = await redis.zrevrange(queue_key, 0, limit * 2 - 1)

    tasks = []
    for tid in task_ids:
        task = await get_task(redis, pool, project_id, tid)
        if priority and task.priority != priority:
            continue
        tasks.append(task)
        if len(tasks) >= limit:
            break
    return tasks


async def clear_project_cache(redis: Redis, project_id: str) -> None:
    """Clear all cached data for a project."""
    pattern = f"loom:{project_id}:*"
    cursor = 0
    while True:
        cursor, keys = await redis.scan(cursor, match=pattern, count=100)
        if keys:
            await redis.delete(*keys)
        if cursor == 0:
            break


async def set_task_progress(
    redis: Redis, project_id: str, task_id: str,
    progress: str | None = None, percent: int | None = None,
) -> None:
    """Store progress data for a task in Redis."""
    from datetime import datetime, timezone
    key = channels.task_progress_key(project_id, task_id)
    data = {
        "message": progress or "",
        "percent": str(percent) if percent is not None else "",
        "updated_at": datetime.now(timezone.utc).isoformat(),
    }
    await redis.hset(key, mapping=data)
    # Auto-expire after 1 hour to prevent stale data
    await redis.expire(key, 3600)


async def get_task_progress(
    redis: Redis, project_id: str, task_id: str,
) -> dict | None:
    """Read progress data for a task from Redis."""
    key = channels.task_progress_key(project_id, task_id)
    data = await redis.hgetall(key)
    if not data or not data.get("updated_at"):
        return None
    return {
        "message": data.get("message", ""),
        "percent": int(data["percent"]) if data.get("percent") else None,
        "updated_at": data.get("updated_at", ""),
    }


async def rebuild_cache(redis: Redis, pool: asyncpg.Pool, project_id: str) -> int:
    """Full cache rebuild from Postgres. Returns count of tasks synced."""
    active = await store.list_active_tasks(pool, project_id)
    ready = await store.get_ready_tasks(pool, project_id, limit=10000)
    ready_ids = {t.id for t in ready}

    # Clear existing ready queue
    await redis.delete(channels.ready_queue(project_id))

    for task in active:
        await sync_task(redis, task)
        if task.id in ready_ids:
            await add_to_ready_queue(redis, task)

    return len(active)


async def clear_project_cache(redis: Redis, project_id: str) -> None:
    """Clear all cached data for a project."""
    pattern = f"loom:{project_id}:*"
    cursor = 0
    while True:
        cursor, keys = await redis.scan(cursor, match=pattern, count=100)
        if keys:
            await redis.delete(*keys)
        if cursor == 0:
            break
