"""백그라운드 워커 — APScheduler (별도 컨테이너).

조사된 '필요한 시간들'을 모두 자동 처리하는 단일 프로세스:

  1) 상태 전이 (status_tick_seconds 주기, 기본 60초)
     scheduled → open(킥오프 D-2) → locked(킥오프 정각) — now 기준 자동 갱신.

  2) AI 예측 생성 (매일 KST ai_generate_hour:ai_generate_minute, 기본 00:05)
     남은(미종료) 경기에 대해 GPT/Claude/Gemini 실 API 호출 → ai_predictions 갱신.
     모델별 독립 처리(키 없거나 실패해도 나머지 모델은 진행).

  3) 결과 메일 발송 (status_tick 와 함께 점검)
     경기 종료(finished_at) 후 result_email_delay_minutes(기본 180=3시간) 경과 시,
     구독자(notify=True+email)에게 개인화 결과 메일 발송 → notified 표시.

실행: python -m app.worker
"""
from __future__ import annotations

import asyncio
import logging
from datetime import timedelta

from apscheduler.schedulers.asyncio import AsyncIOScheduler
from sqlalchemy import select
from sqlalchemy.orm import selectinload

from .config import settings
from .database import SessionLocal, init_db
from .domain import compute_phase, ensure_aware, now_utc
from .models import AIPrediction, Match, UserPrediction
from .scoring import load_scoring_data
from .services import football_data
from .services.ai import MatchContext, PROVIDERS, ProviderUnavailable
from .services.email import EmailUnavailable, build_result_email, send_email
from .services.grading import apply_result
from .services.schedule_fetch import fetch_results, fetch_schedule
from .services.schedule_sync import sync_schedule

logging.basicConfig(level=logging.INFO)
log = logging.getLogger("triplepick.worker")


# ── 0) 경기 일정 동기화 (외부 크롤링) ──────────────────────
async def sync_schedule_job() -> None:
    records = await fetch_schedule()
    if not records:
        return
    async with SessionLocal() as db:
        result = await sync_schedule(db, records)
    # 일정 변경 후 즉시 상태/투표시간 재평가
    await tick_status()
    # 새 경기가 삽입됐으면 그 경기 AI 예측을 바로 생성(다음 주기까지 비어있지 않게)
    if result.get("inserted"):
        log.info("schedule: 신규 %d경기 → AI 예측 즉시 생성", result["inserted"])
        await generate_ai_predictions()


# ── 1) 상태 전이 ────────────────────────────────────────────
async def tick_status() -> None:
    async with SessionLocal() as db:
        now = now_utc()
        matches = (await db.execute(select(Match))).scalars().all()
        changed = 0
        for m in matches:
            # 화면용 phase 와 동일 기준으로 status 저장(단일 기준):
            # scheduled → open → locked(투표종료) → live(경기중) → finished
            target = compute_phase(m, now)
            if m.status != target:
                m.status = target
                changed += 1
        if changed:
            await db.commit()
            log.info("status_tick: %d matches updated", changed)
    await maybe_send_result_emails()


# ── 2) AI 예측 생성 (실연동) ────────────────────────────────
async def generate_ai_predictions(only_missing: bool = True) -> None:
    """미종료 경기의 AI 예측 생성.

    only_missing=True(기본): 이미 있는 (경기×모델) 예측은 보존하고 **비어 있는 것만**
      생성한다(직전에 실패/누락된 모델만 채워짐 — API 비용↓, 예측 안정).
    only_missing=False: 전부 재생성(덮어쓰기) — 수동 재생성 스크립트용.
    """
    async with SessionLocal() as db:
        # 투표 오픈(킥오프 D-2)이 임박/도래한 미종료 경기 — 조 무관.
        # lookahead_hours 만큼 선행 생성해 다음 생성 주기 전에 오픈할 경기도 미리 채운다
        # (1일 1회 생성 → 오픈 시점에 AI 가 비어 보이지 않도록). 더 먼 미래 경기는 제외.
        horizon = now_utc() + timedelta(hours=settings.ai_generate_lookahead_hours)
        rows = (
            await db.execute(
                select(Match)
                .where(Match.result_outcome.is_(None))
                .options(selectinload(Match.predictions))
            )
        ).scalars().all()
        matches = [m for m in rows if ensure_aware(m.opens_at) <= horizon]

        for m in matches:
            # 실데이터 블록(폼·H2H·랭킹·WC결과) — 캐시만 읽음. 없으면 None(이름만).
            data_block = await football_data.build_data_block(db, m)
            ctx = MatchContext(
                team_a=m.team_a_name,
                team_b=m.team_b_name,
                venue=m.venue,
                kickoff=m.kickoff_at.isoformat(),
                data_block=data_block,
            )
            for model, fn in PROVIDERS.items():
                pred = next((p for p in m.predictions if p.model == model), None)
                if only_missing and pred is not None:
                    continue  # 이미 작성됨 — 보존, API 호출 안 함
                try:
                    data = await fn(ctx)
                except ProviderUnavailable as e:
                    log.warning("AI %s skipped (%s)", model, e)
                    continue
                except Exception as e:  # noqa: BLE001
                    log.error("AI %s failed for %s: %s", model, m.match_id, e)
                    continue

                if pred is None:  # 신규 — 위에서 못 찾았으면 생성
                    pred = AIPrediction(match_id=m.match_id, model=model)
                    db.add(pred)
                    m.predictions.append(pred)
                pred.outcome = data["outcome"]
                pred.score_a = data["scoreA"]
                pred.score_b = data["scoreB"]
                pred.confidence_pct = data["confidencePct"]
                pred.reason_ko = data["reasonKo"]
                pred.reason_en = data["reasonEn"]
                pred.generated_at = now_utc()
                pred.source = "llm"
                log.info("AI %s → %s %d-%d", model, m.match_id, data["scoreA"], data["scoreB"])
        await db.commit()


# ── 2.1) 축구 데이터 수집 (예측 생성 전에 캐시를 채워둔다) ──
async def refresh_football_data() -> None:
    """전 팀의 팀/H2H 축구 데이터를 캐시에 한 번씩 선수집.

    예측이 데이터를 쓰려면 미리 캐시에 있어야 하므로, 임박 경기만 기다리지 않고
    모든 미종료 경기의 팀을 대상으로 한다. 팀당 1회만 받고(fetch-once), 무료 한도
    (100/일)를 넘지 않게 하루 호출 예산만큼만 받아 며칠에 걸쳐 누적한다. 전 팀이
    캐시되면 이후 잡은 호출 0(자동 무동작). 키 미설정이면 no-op.
    """
    if not football_data.enabled():
        return
    async with SessionLocal() as db:
        rows = (
            await db.execute(select(Match).where(Match.result_outcome.is_(None)))
        ).scalars().all()
        # 가까운 경기부터 우선 수집(하루 예산 소진 시 먼 경기 팀은 다음 잡에서).
        matches = sorted(rows, key=lambda m: ensure_aware(m.kickoff_at))
        await football_data.refresh(db, matches)


# ── 2.5) 결과 자동 정산 (관리자 입력 불필요) ────────────────
async def settle_matches() -> None:
    """킥오프가 지난 미정산 경기를, 소스가 FINISHED 로 보고하는 즉시 채점·종료.

    킥오프 이후의 미정산 경기를 폴링하되, 외부 소스(football-data)가 FINISHED 로
    확정 스코어를 줄 때만 apply_result → 채점·유저 포인트 집계·finished_at·메일.
    경기 중(IN_PLAY)에는 FINISHED 가 아니라 결과에 안 잡혀 자동 스킵된다(조기 종료 없음).
    아직 FINISHED 가 아니면 다음 틱(5분)에 재시도 → 종료 후 최대 1틱 내 반영.
    """
    now = now_utc()
    async with SessionLocal() as db:
        pending = (
            await db.execute(select(Match).where(Match.result_outcome.is_(None)))
        ).scalars().all()
        # 킥오프가 지난 경기만 (소스가 FINISHED 줄 수 있는 시점)
        due = [m.match_id for m in pending if ensure_aware(m.kickoff_at) <= now]

    if due:
        results = await fetch_results()
        # 정방향/역방향 키 모두 등록 — 소스의 홈/원정 순서가 우리와 달라도 매칭.
        by_pair: dict[tuple, tuple[int, int]] = {}
        for r in results:
            by_pair[(r["teamA"], r["teamB"])] = (r["scoreA"], r["scoreB"])
            by_pair[(r["teamB"], r["teamA"])] = (r["scoreB"], r["scoreA"])  # 뒤집어 저장
        settled = 0
        async with SessionLocal() as db:
            for mid in due:
                m = await db.get(Match, mid)
                if m is None or m.result_outcome is not None:
                    continue
                sc = by_pair.get((m.team_a_code, m.team_b_code))
                if not sc:
                    continue
                await apply_result(db, m, sc[0], sc[1])  # 우리 팀A/팀B 기준으로 채점+집계+commit
                settled += 1
                log.info("settle: %s 자동 정산 %d-%d", mid, sc[0], sc[1])
        if settled == 0:
            log.info("settle: %d경기 진행/대기 — 아직 FINISHED 아님", len(due))

    await maybe_send_result_emails()


# ── 3) 결과 메일 ────────────────────────────────────────────
async def maybe_send_result_emails() -> None:
    async with SessionLocal() as db:
        cutoff = now_utc() - timedelta(minutes=settings.result_email_delay_minutes)
        matches = (
            await db.execute(
                select(Match)
                .where(
                    Match.result_outcome.is_not(None),
                    Match.finished_at.is_not(None),
                    Match.results_emailed_at.is_(None),
                )
                .options(selectinload(Match.predictions))
            )
        ).scalars().all()

        for m in matches:
            if m.finished_at and ensure_aware(m.finished_at) > cutoff:
                continue  # 아직 지연시간(3시간) 미경과

            conds = [
                UserPrediction.match_id == m.match_id,
                UserPrediction.notify.is_(True),
                UserPrediction.email.is_not(None),
                UserPrediction.notified.is_(False),
            ]
            # '맞춘 사람만' 옵션: 승패(outcome) 적중자에게만 발송
            if settings.result_email_correct_only:
                conds.append(UserPrediction.outcome == m.result_outcome)
            picks = (
                await db.execute(select(UserPrediction).where(*conds))
            ).scalars().all()

            ai_lines = [(p.model, p.outcome == m.result_outcome) for p in m.predictions]
            match_url = f"{settings.public_origin}/match/{m.match_id}"
            sent_any = False
            for pk in picks:
                subject, html, text = build_result_email(
                    team_a=m.team_a_short,
                    team_b=m.team_b_short,
                    result_a=m.result_score_a or 0,
                    result_b=m.result_score_b or 0,
                    my_a=pk.score_a,
                    my_b=pk.score_b,
                    my_points=pk.points or 0,
                    ai_lines=ai_lines,
                    match_url=match_url,
                )
                try:
                    await send_email(pk.email, subject, html, text)  # type: ignore[arg-type]
                    pk.notified = True
                    sent_any = True
                except EmailUnavailable as e:
                    log.warning("result email skipped (%s)", e)
                    break  # SMTP 미설정 — 이 경기는 다음 틱에 재시도
                except Exception as e:  # noqa: BLE001
                    log.error("result email failed → %s: %s", pk.email, e)

            # 모든 구독자에게 시도 완료(또는 구독자 없음)면 발송완료 표시
            still_pending = any(
                (not pk.notified) for pk in picks
            )
            if not still_pending:
                m.results_emailed_at = now_utc()
        await db.commit()


def build_scheduler() -> AsyncIOScheduler:
    sched = AsyncIOScheduler(timezone=settings.timezone)
    # 경기 일정: 매일 KST 09:00 외부 크롤링 → 경기·투표시간 갱신
    sched.add_job(
        sync_schedule_job,
        "cron",
        hour=settings.schedule_sync_hour,
        minute=settings.schedule_sync_minute,
        id="schedule_sync",
    )
    # 투표시간 상태 전이 (open/locked)
    sched.add_job(
        tick_status,
        "interval",
        seconds=settings.status_tick_seconds,
        id="status_tick",
        next_run_time=now_utc(),
    )
    # 축구 데이터 수집: 매일 KST 00:00 (예측 생성보다 먼저 캐시를 채움)
    sched.add_job(
        refresh_football_data,
        "cron",
        hour=settings.football_refresh_hour,
        minute=settings.football_refresh_minute,
        id="football_refresh",
    )
    # AI 예측 생성: 매일 KST 00:05 (실 API)
    sched.add_job(
        generate_ai_predictions,
        "cron",
        hour=settings.ai_generate_hour,
        minute=settings.ai_generate_minute,
        id="ai_generate",
    )
    # 결과 자동 정산 + 메일: 킥오프+Nh 지난 경기 스코어 수집·채점·발송
    sched.add_job(
        settle_matches,
        "interval",
        seconds=settings.settle_tick_seconds,
        id="settle",
        next_run_time=now_utc(),
    )
    return sched


async def main() -> None:
    load_scoring_data()  # data/scoring.json → 배점·배제 대상 (채점·결과메일에 사용)
    await init_db()  # 테이블 보장 (idempotent). 시드는 API 가 담당.
    # 기동 시 1회: 일정 동기화 → AI 예측 생성 → 밀린 경기 자동 정산
    await sync_schedule_job()
    await refresh_football_data()  # 축구 데이터 캐시 선채움 (키 없으면 no-op)
    await generate_ai_predictions()
    await settle_matches()

    sched = build_scheduler()
    sched.start()
    log.info(
        "scheduling-server started — schedule sync daily %02d:%02d · status every %ss · "
        "AI gen daily %02d:%02d %s · auto-settle on FINISHED (poll every %ss) · 맞춘사람만=%s",
        settings.schedule_sync_hour,
        settings.schedule_sync_minute,
        settings.status_tick_seconds,
        settings.ai_generate_hour,
        settings.ai_generate_minute,
        settings.timezone,
        settings.settle_tick_seconds,
        settings.result_email_correct_only,
    )
    # 영구 대기
    stop = asyncio.Event()
    try:
        await stop.wait()
    finally:
        sched.shutdown()


if __name__ == "__main__":
    asyncio.run(main())
