"""
メイン監視ループ

DBの monitor_settings / media_settings を参照し、
設定された監視時間・間隔でスクレイピングスクリプトを呼び出す。

Usage:
    python monitor.py <client_id>
"""

import argparse
import atexit
import logging
import os
import random
import signal
import subprocess
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timedelta
from pathlib import Path

sys.path.append(str(Path(__file__).resolve().parents[1]))
from config.database import execute_query

# ---------------------------------------------------------------------------
# 定数
# ---------------------------------------------------------------------------
SCRAPING_DIR = Path(__file__).parent
INTERVAL_JITTER = 10  # ± 秒
WAIT_OUTSIDE_HOURS = 60  # 設定取得失敗時のリトライ間隔（秒）

MEDIA_SCRIPTS = {
    "girlsheaven": SCRAPING_DIR / "scrape_girlsheaven.py",
    "vanilla":      SCRAPING_DIR / "scrape_vanilla.py",
}

GH_ACCESS_SCRIPT = Path(__file__).resolve().parents[1] / "tools" / "get_access_girlsheaven.py"

# venv Python の固定パス（watchdog.py の PYTHON_EXE と同じ値）
# sys.executable を使うと、monitor 自身が AppData\Python 等で起動された場合に
# 子プロセスも venv 外の Python になりパッケージ不足で失敗するため、フルパスで固定する。
# サーバーの Python パスを変更した場合はここを合わせて修正すること。
PYTHON_EXE = r"C:\apps\ranking_system\venv\Scripts\python.exe"

# ロックファイルの置き場所（client_idごとに monitor_{client_id}.lock を作成）
# サーバーパスが変わる場合はここを修正すること
LOCK_DIR = Path(r"C:\apps\ranking_system")

# ---------------------------------------------------------------------------
# ロガー設定
# ---------------------------------------------------------------------------
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
)
logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# シャットダウン管理
# ---------------------------------------------------------------------------
class _ShutdownFlag:
    def __init__(self) -> None:
        self._flag = False

    def set(self) -> None:
        self._flag = True

    @property
    def is_set(self) -> bool:
        return self._flag


_shutdown = _ShutdownFlag()


def _handle_sigint(signum, frame) -> None:
    logger.info("Ctrl+C を受信しました。終了します...")
    _shutdown.set()


signal.signal(signal.SIGINT, _handle_sigint)


# ---------------------------------------------------------------------------
# 多重起動防止ロック（O_CREAT|O_EXCL による原子的排他）
# ---------------------------------------------------------------------------
_lock_path: Path | None = None  # 自分が取得したロックファイルのパス（取得前は None）
_LOCK_MAX_RETRIES = 3           # 残骸ロック奪取の最大リトライ回数
_LOCK_RETRY_WAIT  = (0.1, 0.5) # リトライ間のランダム待機範囲（秒）


def _pid_is_alive(pid: int) -> bool:
    """指定 PID のプロセスが生存しているか確認する（Windows tasklist 使用）。"""
    try:
        result = subprocess.run(
            ["tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"],
            capture_output=True, text=True, timeout=5,
        )
        return str(pid) in result.stdout
    except Exception:
        return False


def _acquire_lock(client_id: int) -> None:
    """
    O_CREAT|O_EXCL によるOSレベルの原子的排他ロック取得（read-back検証付き）。

    ① O_EXCL 作成成功
       → PID 書き込み → read-back で自分の PID が確認できれば確定。
         不一致（別プロセスが上書き）なら即終了。
    ② FileExistsError かつ PID 生存
       → 別の monitor が稼働中のため即終了（sys.exit(0)）。
    ③ FileExistsError かつ PID 消滅（残骸）
       → unlink → ランダム待機 → 再 O_EXCL（最大 _LOCK_MAX_RETRIES 回）。
         再試行で FileExistsError + PID 生存なら即終了。
    """
    global _lock_path
    lock_path = LOCK_DIR / f"monitor_{client_id}.lock"
    my_pid = os.getpid()

    for attempt in range(_LOCK_MAX_RETRIES + 1):  # 初回 + 最大3回リトライ
        try:
            fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
        except FileExistsError:
            # ── ロックファイルが既に存在する → PID を確認 ────────────────────
            try:
                raw = lock_path.read_text(encoding="utf-8").strip()
                existing_pid = int(raw) if raw else None
            except (ValueError, OSError):
                existing_pid = None

            if existing_pid and _pid_is_alive(existing_pid):
                # 生存プロセスがロックを保持 → 確実に終了（SystemExit は Exception に捕捉されない）
                logger.warning(
                    "ロック取得失敗: monitor.py (PID=%d) が稼働中のため終了します "
                    "(自分の PID=%d, client_id=%d)",
                    existing_pid, my_pid, client_id,
                )
                sys.exit(0)

            # PID が死んでいる残骸ロック → 削除して再 O_EXCL
            if attempt >= _LOCK_MAX_RETRIES:
                raise RuntimeError(
                    f"残骸ロック奪取リトライ上限 ({_LOCK_MAX_RETRIES} 回) に達しました"
                )
            logger.info(
                "残骸ロックを奪取します [試行 %d/%d] (古い PID=%s → 自分の PID=%d)",
                attempt + 1, _LOCK_MAX_RETRIES, existing_pid, my_pid,
            )
            try:
                lock_path.unlink()
            except OSError:
                pass  # 他プロセスが先に削除しても次ループの O_EXCL で決着する
            time.sleep(random.uniform(*_LOCK_RETRY_WAIT))
            continue

        # ── O_EXCL 作成成功 → PID を書き込んでクローズ ──────────────────────
        os.write(fd, str(my_pid).encode("utf-8"))
        os.close(fd)

        # ── read-back verification ────────────────────────────────────────────
        # 書き込み直後にファイルを読み返し、自分の PID が残っているか検証する。
        # 別プロセスが unlink→再作成で上書きしていた場合はここで検知して終了する。
        try:
            stored = lock_path.read_text(encoding="utf-8").strip()
        except OSError:
            stored = ""
        if stored != str(my_pid):
            logger.warning(
                "read-back 不一致のため終了します "
                "(期待 PID=%d, ファイル内容=%r, client_id=%d)",
                my_pid, stored, client_id,
            )
            sys.exit(0)
        # ─────────────────────────────────────────────────────────────────────

        _lock_path = lock_path
        logger.info("ロック取得成功 (PID=%d, path=%s)", my_pid, lock_path)
        return


def _release_lock() -> None:
    """ロックファイルを解放する（自分が取得したものだけ削除）。"""
    global _lock_path
    if _lock_path is None:
        return
    try:
        if _lock_path.exists():
            stored = _lock_path.read_text(encoding="utf-8").strip()
            if stored == str(os.getpid()):
                _lock_path.unlink()
                logger.info("ロックファイルを解放しました: %s", _lock_path)
            else:
                logger.warning(
                    "ロックの PID が一致しないため削除しません (stored=%s)", stored
                )
    except Exception as e:
        logger.error("ロックファイル解放エラー: %s", e)
    finally:
        _lock_path = None


# ---------------------------------------------------------------------------
# DB 取得
# ---------------------------------------------------------------------------
def get_monitor_settings(client_id: int) -> dict:
    """monitor_settings テーブルから監視設定を取得する。"""
    rows = execute_query(
        """
        SELECT interval_min
        FROM monitor_settings
        WHERE client_id = %s
        LIMIT 1
        """,
        (client_id,),
    )
    if not rows:
        raise ValueError(f"monitor_settings に client_id={client_id} の設定が見つかりません")
    return rows[0]


def get_active_media_settings(client_id: int) -> list[dict]:
    """
    media_settings テーブルから有効な媒体設定を取得する。
    返値例: [
        {"media_name": "girlsheaven", "monitor_url": "...", "target_rank": 10},
        {"media_name": "vanilla",     "monitor_url": "...", "target_rank": None},
    ]
    """
    return execute_query(
        """
        SELECT media_name, monitor_url
        FROM media_settings
        WHERE client_id = %s
          AND is_active = 1
        """,
        (client_id,),
    )


# ---------------------------------------------------------------------------
# スクリプト呼び出し
# ---------------------------------------------------------------------------
def call_scraper(client_id: int, media: dict, slot_time: datetime = None) -> None:
    """
    媒体に応じたスクレイピングスクリプトをサブプロセスで呼び出す。
    """
    media_name  = media["media_name"]
    monitor_url = media["monitor_url"]

    script = MEDIA_SCRIPTS.get(media_name)
    if script is None:
        logger.warning("未対応の媒体: %s", media_name)
        return

    slot_str = slot_time.strftime('%Y-%m-%d %H:%M:%S') if slot_time else ''

    if media_name == "girlsheaven":
        cmd = [PYTHON_EXE, str(script), str(client_id), monitor_url, slot_str]

    elif media_name == "vanilla":
        cmd = [PYTHON_EXE, str(script), str(client_id), monitor_url, slot_str]

    else:
        logger.warning("未対応の媒体: %s", media_name)
        return

    logger.info("実行: %s", " ".join(cmd))
    try:
        result = subprocess.run(cmd, check=True, text=True, capture_output=False)
        logger.info("%s 完了 (returncode=%d)", media_name, result.returncode)
    except subprocess.CalledProcessError as e:
        logger.error("%s 失敗 (returncode=%d)", media_name, e.returncode)
    except Exception as e:
        logger.error("%s 呼び出しエラー: %s", media_name, e)


def _should_fetch_gh_access(client_id: int) -> bool:
    """00:00〜04:00の間のランダムな時刻にGHアクセス統計を取得すべきか判定する。
    1日1回だけ実行する。
    """
    now = datetime.now()
    if not (0 <= now.hour < 4):
        return False

    # 本日分がすでに取得済みかチェック
    try:
        from config.database import execute_query as _eq
        rows = _eq(
            "SELECT COUNT(*) as cnt FROM gh_access_stats WHERE client_id = %s AND stat_date = %s",
            (client_id, now.date()),
            local=True,
        )
        if rows and rows[0]["cnt"] > 0:
            return False
    except Exception:
        return False

    # ランダムな確率で実行（1ループあたり約3分、4時間=80ループで1回実行）
    # 確率 = 1/80 ≈ 1.25%
    return random.random() < (1 / 80)


def _run_gh_access(client_id: int) -> None:
    """get_access_girlsheaven.pyを実行する。"""
    cmd = [PYTHON_EXE, str(GH_ACCESS_SCRIPT), str(client_id)]
    logger.info("GHアクセス統計取得を実行します")
    try:
        result = subprocess.run(cmd, check=True, text=True, capture_output=False)
        logger.info("GHアクセス統計取得完了 (returncode=%d)", result.returncode)
    except Exception as e:
        logger.error("GHアクセス統計取得エラー: %s", e)


# ---------------------------------------------------------------------------
# メインループ
# ---------------------------------------------------------------------------
def run_loop(client_id: int) -> None:
    logger.info("監視ループ開始 | client_id=%d", client_id)

    while not _shutdown.is_set:
        # GHアクセス統計の自動取得（00:00〜04:00のランダムな時刻に1回）
        if _should_fetch_gh_access(client_id):
            _run_gh_access(client_id)

        # ── 設定を毎ループ再取得（DB変更をリアルタイム反映）──────────────────
        try:
            settings = get_monitor_settings(client_id)
            media_list = get_active_media_settings(client_id)
        except Exception as e:
            logger.error("設定取得エラー: %s", e)
            time.sleep(WAIT_OUTSIDE_HOURS)
            continue

        interval_min = int(settings["interval_min"])

        # ── 各媒体を並列実行 ──────────────────────────────────────────────────
        if not media_list:
            logger.warning("有効な媒体設定がありません (client_id=%d)", client_id)
        else:
            if _shutdown.is_set:
                break
            # 定刻スロット時刻を記録
            slot_time = _get_slot_time(interval_min)
            logger.info("定刻スロット: %s", slot_time.strftime('%H:%M:%S'))

            # ランダム遅延（0〜20秒）でブラウザ起動
            random_delay = random.randint(0, 20)
            logger.info("ブラウザ起動まで %d秒 待機します", random_delay)
            _interruptible_sleep(random_delay)

            with ThreadPoolExecutor(max_workers=2) as executor:
                futures = {
                    executor.submit(call_scraper, client_id, media, slot_time): media.get("media_name")
                    for media in media_list
                }
                for future in as_completed(futures):
                    media_name = futures[future]
                    try:
                        future.result()
                    except Exception as e:
                        logger.error("[%s] スクレイピングエラー: %s", media_name, e)

        if _shutdown.is_set:
            break

        # ── 次の定刻スロットまで待機 ──────────────────────────────────────────
        wait_sec = _next_slot_wait(interval_min)
        logger.info(
            "次の定刻スロットまで %d秒 待機します（次回実行: %s）",
            wait_sec,
            (datetime.now() + timedelta(seconds=wait_sec)).strftime('%H:%M:%S'),
        )
        _interruptible_sleep(wait_sec)

    logger.info("監視ループを終了しました。")


def _get_slot_time(interval_min: int) -> datetime:
    """現在時刻を切り捨てて定刻スロット時刻を返す。
    例：09:01:30に呼ばれた場合、interval_min=3なら09:00:00を返す。
    """
    now = datetime.now()
    slot_min = (now.minute // interval_min) * interval_min
    return now.replace(minute=slot_min, second=0, microsecond=0)


def _next_slot_wait(interval_min: int) -> int:
    """次のinterval_min刻みの定刻までの待機秒数を返す。
    例：interval_min=3の場合、00,03,06,09...分に実行されるよう待機秒数を計算する。
    """
    now = datetime.now()
    current_total_sec = now.minute * 60 + now.second
    slot_sec = interval_min * 60
    next_slot = (current_total_sec // slot_sec + 1) * slot_sec
    wait_sec = next_slot - current_total_sec
    return wait_sec


def _interruptible_sleep(seconds: int) -> None:
    """Ctrl+C を即時に受け付けながら指定秒数スリープする。"""
    end = time.monotonic() + seconds
    while not _shutdown.is_set and time.monotonic() < end:
        time.sleep(1)


# ---------------------------------------------------------------------------
# エントリポイント
# ---------------------------------------------------------------------------
def main() -> None:
    parser = argparse.ArgumentParser(description="順位監視メインループ")
    parser.add_argument("client_id", type=int, help="クライアントID")
    args = parser.parse_args()

    # 多重起動防止ロックを取得（別 monitor が稼働中なら sys.exit(0) で即終了）
    _acquire_lock(args.client_id)
    # atexit でも解放を試みる（kill -9 等の強制終了は除くが、通常の異常終了はカバー）
    atexit.register(_release_lock)

    try:
        run_loop(args.client_id)
    except Exception as e:
        logger.critical("予期せぬエラーで終了: %s", e)
        sys.exit(1)
    finally:
        _release_lock()


if __name__ == "__main__":
    main()
