"""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
from datetime import datetime, timezone

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._state == "half-open":
            self._state = "open"
            logger.warning("Circuit breaker re-opening after failed probe")
        elif 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 task.dead_letter:
            await remove_from_ready_queue(redis, project_id, task.id)
            continue
        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 = channels.project_key_pattern(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,
    ttl: int = 3600,
) -> None:
    """Store progress data for a task in Redis."""
    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)
    await redis.expire(key, ttl)


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)
