"""0 点金币→现金自动兑换的进程内定时任务。

替代原 systemd timer(deploy/daily-exchange.*):App 启动即自带,每
`AUTO_EXCHANGE_CHECK_INTERVAL_SEC` 醒一次,跨进北京新的一天(0 点后)就跑一轮
`wallet.daily_auto_exchange`。

健壮性:
- **逐用户幂等**:当天已有 exchange_in 流水的用户跳过(见 wallet._has_exchange_in_on),
  故启动补跑 / 多次唤醒 / 进程重启都安全,不会重复兑。
- **当天首跑即补**:进程起来时若当天还没兑过,立即兑一轮(等价原 timer 的 Persistent 补跑)。
- **同机多进程互斥**:文件锁保证多 worker 只有一个实际跑(防跨进程并发导致 TOCTOU 双兑)。
- **开关**:settings.AUTO_EXCHANGE_ENABLED=false 时不启动(与脚本/原 timer 同一开关)。
"""
from __future__ import annotations

import asyncio
import contextlib
import logging
import os
import time
from collections.abc import Iterator
from datetime import date
from pathlib import Path

from sqlalchemy.exc import SQLAlchemyError

from app.core import rewards
from app.core.config import settings
from app.db.session import SessionLocal
from app.repositories import wallet as wallet_repo

logger = logging.getLogger("shagua.daily_exchange")
_LOCK_PATH = Path(__file__).resolve().parents[2] / "data" / "daily_exchange.lock"


def _touch_lock() -> None:
    with contextlib.suppress(FileNotFoundError):
        os.utime(_LOCK_PATH, None)


@contextlib.contextmanager
def _single_instance_lock(stale_after_sec: int) -> Iterator[bool]:
    """同机多进程保护:同一时间只允许一个自动兑换 worker 运行。"""
    _LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
    fd: int | None = None
    try:
        try:
            fd = os.open(str(_LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
        except FileExistsError:
            try:
                age = time.time() - _LOCK_PATH.stat().st_mtime
            except FileNotFoundError:
                age = stale_after_sec + 1
            if age > stale_after_sec:
                with contextlib.suppress(FileNotFoundError):
                    _LOCK_PATH.unlink()
                try:
                    fd = os.open(str(_LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
                except FileExistsError:
                    fd = None

        if fd is None:
            yield False
            return

        os.write(fd, f"pid={os.getpid()} started_at={int(time.time())}\n".encode("ascii"))
        yield True
    finally:
        if fd is not None:
            os.close(fd)
            with contextlib.suppress(FileNotFoundError):
                _LOCK_PATH.unlink()


def _exchange_once() -> dict:
    with SessionLocal() as db:
        return wallet_repo.daily_auto_exchange(db)


async def _run_loop() -> None:
    interval = max(60, int(settings.AUTO_EXCHANGE_CHECK_INTERVAL_SEC))
    lock_stale_after = max(interval * 3, 1800)
    with _single_instance_lock(lock_stale_after) as lock_acquired:
        if not lock_acquired:
            logger.warning("daily auto-exchange skipped: another worker owns lock")
            return
        await _run_locked_loop(interval)


async def _run_locked_loop(interval: int) -> None:
    logger.info("daily auto-exchange worker started interval=%ss", interval)
    # 本进程上次跑过的北京日;None=尚未跑过本进程(启动即补当天)。
    last_run: date | None = None
    try:
        while True:
            try:
                _touch_lock()
                today = rewards.cn_today()
                if last_run != today:
                    result = await asyncio.to_thread(_exchange_once)
                    last_run = today
                    logger.info("daily auto-exchange done date=%s result=%s", today, result)
            except SQLAlchemyError:
                logger.exception("daily auto-exchange db error")
            except Exception:  # noqa: BLE001 - 后台任务不能因单次异常退出
                logger.exception("daily auto-exchange unexpected error")
            await asyncio.sleep(interval)
    except asyncio.CancelledError:
        logger.info("daily auto-exchange worker stopped")
        raise


def start_daily_exchange_worker() -> asyncio.Task | None:
    if not settings.AUTO_EXCHANGE_ENABLED:
        logger.info("daily auto-exchange disabled (AUTO_EXCHANGE_ENABLED=false)")
        return None
    return asyncio.create_task(_run_loop(), name="daily-auto-exchange")


async def stop_daily_exchange_worker(task: asyncio.Task | None) -> None:
    if task is None:
        return
    task.cancel()
    with contextlib.suppress(asyncio.CancelledError):
        await task
