from __future__ import annotations

import asyncio
import logging
import os
import socket
import uuid
from datetime import UTC, datetime, timedelta

from sqlalchemy import or_, select
from sqlalchemy.orm import Session

from app.core.config import get_settings
from app.core.database import SessionLocal
from app.models.chat import TopicSession
from app.models.growth import TopicSummary
from app.models.user import User
from app.services.growth_profile_service import GrowthProfileService
from app.services.redis_client import get_sync_redis_client


logger = logging.getLogger(__name__)
WORKER_LOCK_KEY = "topic-settlement:worker:lock"
WORKER_LOCK_TTL_SECONDS = 900


class TopicSettlementWorker:
    """持久化主题沉淀队列。

    MySQL 保存任务、重试和执行状态，Redis 仅限制多进程同时执行数量。
    即使 Redis 暂时不可用或服务重启，已结束主题也不会丢失沉淀任务。
    """

    @classmethod
    async def run_forever(cls) -> None:
        settings = get_settings()
        if not settings.topic_settlement_worker_enabled:
            logger.info("topic settlement worker disabled")
            return
        worker_id = f"{socket.gethostname()}:{os.getpid()}:{uuid.uuid4().hex[:8]}"
        poll_seconds = max(1, settings.topic_settlement_poll_seconds)
        while True:
            try:
                processed = await asyncio.to_thread(cls.run_once, worker_id)
            except Exception:
                processed = False
                logger.exception("topic settlement worker iteration failed")
            await asyncio.sleep(0 if processed else poll_seconds)

    @classmethod
    def run_once(cls, worker_id: str) -> bool:
        redis, acquired = _acquire_worker_lock(worker_id)
        if not acquired:
            return False
        try:
            now = _now()
            with SessionLocal() as db:
                cls.recover_stale_jobs(db, now=now)
                db.commit()
            with SessionLocal() as db:
                summary_id = cls.claim_next(db, worker_id=worker_id, now=now)
            if summary_id is None:
                return False
            with SessionLocal() as db:
                cls.execute_claimed(db, summary_id=summary_id, worker_id=worker_id)
            return True
        finally:
            _release_worker_lock(redis, worker_id)

    @staticmethod
    def claim_next(db: Session, *, worker_id: str, now: datetime | None = None) -> int | None:
        current = now or _now()
        summary = db.scalar(
            select(TopicSummary)
            .where(
                TopicSummary.status == "pending",
                TopicSummary.attempt_count < TopicSummary.max_attempts,
                or_(TopicSummary.next_run_at.is_(None), TopicSummary.next_run_at <= current),
            )
            .order_by(TopicSummary.next_run_at.asc(), TopicSummary.id.asc())
            .with_for_update(skip_locked=True)
            .limit(1)
        )
        if summary is None:
            db.rollback()
            return None
        summary.status = "running"
        summary.attempt_count += 1
        summary.locked_at = current
        summary.locked_by = worker_id
        summary.last_started_at = current
        summary.finished_at = None
        db.add(summary)
        db.commit()
        return summary.id

    @staticmethod
    def execute_claimed(db: Session, *, summary_id: int, worker_id: str) -> TopicSummary | None:
        summary = db.get(TopicSummary, summary_id)
        if summary is None or summary.status != "running" or summary.locked_by != worker_id:
            return summary
        topic = db.get(TopicSession, summary.topic_session_id)
        user = db.get(User, summary.user_id)
        if topic is None or user is None or user.is_deleted:
            _finish_permanent_failure(summary, "主题或用户不存在")
            db.commit()
            return summary

        try:
            GrowthProfileService.process_topic_settlement(db, summary=summary)
            now = _now()
            summary.next_run_at = None
            summary.finished_at = now
            summary.locked_at = None
            summary.locked_by = None
            db.add(summary)
            db.commit()
        except Exception as exc:
            # 撤销本轮可能已经写入的半成品摘要、档案和修订记录，再单独更新任务状态。
            db.rollback()
            summary = db.get(TopicSummary, summary_id)
            if summary is None:
                return None
            summary.error_message = str(exc)[:2000]
            now = _now()
            if summary.attempt_count < summary.max_attempts:
                retry_seconds = min(300, 30 * (2 ** max(0, summary.attempt_count - 1)))
                summary.status = "pending"
                summary.next_run_at = now + timedelta(seconds=retry_seconds)
                summary.finished_at = None
            else:
                summary.status = "failed"
                summary.next_run_at = None
                summary.finished_at = now
            summary.locked_at = None
            summary.locked_by = None
            db.add(summary)
            db.commit()
        db.refresh(summary)
        return summary

    @staticmethod
    def recover_stale_jobs(db: Session, *, now: datetime | None = None) -> int:
        current = now or _now()
        stale_before = current - timedelta(minutes=max(5, get_settings().topic_settlement_stale_minutes))
        summaries = list(
            db.scalars(
                select(TopicSummary)
                .where(
                    TopicSummary.status == "running",
                    TopicSummary.locked_at.is_not(None),
                    TopicSummary.locked_at < stale_before,
                )
                .order_by(TopicSummary.id.asc())
                .limit(100)
                .with_for_update(skip_locked=True)
            )
        )
        for summary in summaries:
            if summary.attempt_count >= summary.max_attempts:
                summary.status = "failed"
                summary.finished_at = current
                summary.next_run_at = None
            else:
                summary.status = "pending"
                summary.next_run_at = current
                summary.finished_at = None
            summary.error_message = _append_error(summary.error_message, "任务执行进程中断，系统已自动恢复")
            summary.locked_at = None
            summary.locked_by = None
            db.add(summary)
        return len(summaries)


def _finish_permanent_failure(summary: TopicSummary, message: str) -> None:
    summary.status = "failed"
    summary.error_message = message
    summary.next_run_at = None
    summary.locked_at = None
    summary.locked_by = None
    summary.finished_at = _now()


def _append_error(current: str | None, message: str) -> str:
    if not current:
        return message
    return f"{current}\n{message}"[-2000:]


def _acquire_worker_lock(worker_id: str):
    redis = get_sync_redis_client()
    if redis is None:
        return None, True
    try:
        return redis, bool(redis.set(WORKER_LOCK_KEY, worker_id, nx=True, ex=WORKER_LOCK_TTL_SECONDS))
    except Exception:
        logger.warning("redis unavailable for topic settlement lock; falling back to database claim", exc_info=True)
        return None, True


def _release_worker_lock(redis, worker_id: str) -> None:
    if redis is None:
        return
    try:
        redis.eval(
            "if redis.call('get', KEYS[1]) == ARGV[1] then "
            "return redis.call('del', KEYS[1]) else return 0 end",
            1,
            WORKER_LOCK_KEY,
            worker_id,
        )
    except Exception:
        logger.warning("failed to release topic settlement worker lock", exc_info=True)


def _now() -> datetime:
    return datetime.now(UTC).replace(tzinfo=None)
