"""
順位データ解析スクリプト

指定日（省略時は前日）のスクレイピング結果を解析し、最適な更新時間を提案する。

Usage:
    python analyze.py <client_id> [date]

Examples:
    python analyze.py 1              # 前日のデータを解析
    python analyze.py 1 2026-03-19  # 指定日のデータを解析
"""

import argparse
import json
import logging
import os
import sys
from datetime import date, datetime, timedelta
from pathlib import Path

import pandas as pd
from dotenv import load_dotenv

load_dotenv(Path(__file__).resolve().parents[2] / ".env")

sys.path.append(str(Path(__file__).resolve().parents[1]))
from config.database import execute_query, execute_update

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
)
logger = logging.getLogger(__name__)

MAX_SUGGESTIONS = 5

# ---------------------------------------------------------------------------
# 配分重みパラメータ（アクセス vs 弱点度）
# ---------------------------------------------------------------------------
# 重要度 = アクセス正規化値 × W_ACCESS + 弱点度 × W_WEAKNESS
# 後で調整する場合はここの値を変更する。

# 順位キープ型（update_mode=1）: アクセスを重視しつつ弱点時間帯も確保
W_ACCESS_CONCENTRATED   = 0.7
W_WEAKNESS_CONCENTRATED = 0.3

# 旧・分散型の重みパラメータ（参照のみ保持。mode=2 は指定店追随型に変更されたため未使用）
W_ACCESS_DISPERSED   = 0.3
W_WEAKNESS_DISPERSED = 0.7

# モード → (w_acc, w_weak) 辞書
# mode=1（順位キープ型）のみ使用。
# mode=2（指定店追随型）は target_bump_times ベースの早期リターンで処理するため未使用。
# mode=3（アクセス統計連動更新型）はアクセスのみ・別処理のため含まず。
_ALLOC_WEIGHTS: dict[int, tuple[float, float]] = {
    1: (W_ACCESS_CONCENTRATED,  W_WEAKNESS_CONCENTRATED),
    2: (W_ACCESS_DISPERSED,     W_WEAKNESS_DISPERSED),   # 未使用（mode=2 は早期リターン）
}


# ---------------------------------------------------------------------------
# DB 取得ヘルパー
# ---------------------------------------------------------------------------

def get_update_mode(client_id: int, media_name: str) -> int:
    """update_timing_settingsから媒体別のupdate_modeを取得する。
    1=モードA（集中型）、2=モードB（分散型）、3=モードC（アクセス統計連動）
    デフォルトは1（モードA）
    """
    rows = execute_query(
        "SELECT update_mode FROM update_timing_settings "
        "WHERE client_id = %s AND media_name = %s LIMIT 1",
        (client_id, media_name),
        local=False,
    )
    if not rows:
        return 1
    return rows[0]["update_mode"]


def get_max_update_count(client_id: int, media_name: str) -> int:
    """media_settingsからmax_update_countを取得する。
    未設定（NULL）の場合はMAX_SUGGESTIONSをデフォルト値として返す。
    """
    rows = execute_query(
        "SELECT max_update_count FROM media_settings WHERE client_id = %s AND media_name = %s AND is_active = 1 LIMIT 1",
        (client_id, media_name),
        local=False,
    )
    if not rows or rows[0]["max_update_count"] is None:
        logger.info("[%s] max_update_count未設定 → デフォルト値 %d を使用", media_name, MAX_SUGGESTIONS)
        return MAX_SUGGESTIONS
    count = rows[0]["max_update_count"]
    logger.info("[%s] max_update_count取得: %d件", media_name, count)
    return count


def get_active_media(client_id: int) -> list[str]:
    """カゴヤDBからクライアントの有効な媒体一覧を取得する。"""
    rows = execute_query(
        "SELECT media_name FROM media_settings WHERE client_id = %s AND is_active = 1",
        (client_id,),
        local=False,
    )
    return [r["media_name"] for r in rows]


def get_own_shop_name(client_id: int) -> str | None:
    """カゴヤDBのclientsテーブルからクライアントの自店shop_nameを取得する。"""
    rows = execute_query(
        "SELECT shop_name FROM clients WHERE id = %s LIMIT 1",
        (client_id,),
        local=False,
    )
    if not rows:
        return None
    return rows[0]["shop_name"]


def _time_to_hour(t) -> int:
    """MySQL TIME型（timedelta or datetime.time）をhour整数に変換する。"""
    if hasattr(t, "seconds"):  # pymysql は TIME を timedelta で返す
        return t.seconds // 3600
    return t.hour  # datetime.time の場合


def get_excluded_times(client_id: int, media_name: str) -> list[tuple[int, int]]:
    """カゴヤDBから媒体別の除外時間帯（start_time〜end_timeの範囲）を取得する。"""
    rows = execute_query(
        "SELECT start_time, end_time FROM excluded_times "
        "WHERE client_id = %s AND media_name = %s",
        (client_id, media_name),
        local=False,
    )
    return [(_time_to_hour(r["start_time"]), _time_to_hour(r["end_time"])) for r in rows]


def get_gh_target_rank(client_id: int) -> int:
    """GHのtarget_rankをmedia_settingsから取得する。未設定時は10を返す。"""
    rows = execute_query(
        "SELECT target_rank FROM media_settings "
        "WHERE client_id = %s AND media_name = 'girlsheaven' AND is_active = 1 LIMIT 1",
        (client_id,),
        local=False,
    )
    if not rows or rows[0]["target_rank"] is None:
        return 10
    return int(rows[0]["target_rank"])


def get_fixed_update_times(client_id: int, media_name: str) -> list[int]:
    """カゴヤDBから媒体別の絶対更新時間を分単位スロットで取得する。
    例：15:02:00 → 902分（15*60+2）
    """
    rows = execute_query(
        "SELECT update_time FROM fixed_update_times "
        "WHERE client_id = %s AND media_name = %s",
        (client_id, media_name),
        local=False,
    )
    result = []
    for r in rows:
        t = r["update_time"]
        if hasattr(t, "seconds"):
            # timedelta型の場合
            total_minutes = t.seconds // 60
        else:
            # datetime.time型の場合
            total_minutes = t.hour * 60 + t.minute
        result.append(total_minutes)
    return result


def get_target_shop_name(client_id: int, media_name: str) -> str | None:
    """カゴヤDBのmedia_settingsからターゲット店名を取得する（指定店追随型用）。
    target_shop_name が NULL または空の場合は None を返す。
    """
    rows = execute_query(
        "SELECT target_shop_name FROM media_settings "
        "WHERE client_id = %s AND media_name = %s AND is_active = 1 LIMIT 1",
        (client_id, media_name),
        local=False,
    )
    if not rows or not rows[0]["target_shop_name"]:
        return None
    return str(rows[0]["target_shop_name"])


def get_target_bump_times(
    client_id: int,
    media_name: str,
    target_shop_name: str,
    analyzed_date: date,
) -> list[int]:
    """VPS内DBのcompetitor_update_patterns(window_days=7)から、ターゲット店の
    更新時刻（0〜1439分）リストを取得して返す。

    - window_days=7 のデータを使用（daily パターン固定）。
    - pattern_type="daily": bump_times_json の全時刻を返す。
    - pattern_type="weekday": analyzed_date の曜日に対応する時刻を返す。
    - pattern_type="irregular" / データなし: 空リストを返しログ警告を出す。
    - バニラは複数 sub_area があるため、全 sub_area の時刻を合算する。
    """
    _WEEKDAY_NAMES = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]

    rows = execute_query(
        "SELECT pattern_type, bump_times_json FROM competitor_update_patterns "
        "WHERE client_id = %s AND media_name = %s AND shop_name = %s AND window_days = 7",
        (client_id, media_name, target_shop_name),
        local=True,
    )

    if not rows:
        logger.warning(
            "[%s] ターゲット店「%s」の competitor_update_patterns データが見つかりません "
            "(window_days=7)。analyze_competitor_patterns.py が先に実行されているか確認してください。",
            media_name, target_shop_name,
        )
        return []

    wd_key = _WEEKDAY_NAMES[analyzed_date.weekday()]
    all_times: set[int] = set()
    found_non_irregular = False

    for row in rows:
        pattern_type = str(row.get("pattern_type", ""))
        bump_json    = row.get("bump_times_json") or ""

        if pattern_type == "irregular" or not bump_json:
            continue

        try:
            bump_data = json.loads(bump_json)
        except (json.JSONDecodeError, TypeError):
            logger.warning(
                "[%s] ターゲット店「%s」の bump_times_json パースに失敗",
                media_name, target_shop_name,
            )
            continue

        found_non_irregular = True
        entries: list[dict] = []
        if pattern_type == "daily":
            entries = bump_data if isinstance(bump_data, list) else []
        elif pattern_type == "weekday":
            entries = bump_data.get(wd_key, []) if isinstance(bump_data, dict) else []

        for item in entries:
            hhmm = item.get("time", "")
            if ":" in hhmm:
                try:
                    h, m = map(int, hhmm.split(":"))
                    all_times.add(h * 60 + m)
                except ValueError:
                    pass

    if not all_times:
        if not found_non_irregular:
            logger.warning(
                "[%s] ターゲット店「%s」のパターンが全て irregular です。"
                "規則的な更新時刻が検出されていません。",
                media_name, target_shop_name,
            )
        else:
            logger.warning(
                "[%s] ターゲット店「%s」の更新時刻を取得できませんでした（bump_times が空）。",
                media_name, target_shop_name,
            )
        return []

    times = sorted(all_times)
    logger.info(
        "[%s] ターゲット店「%s」の更新時刻（%d件・%s曜日）: %s",
        media_name, target_shop_name, len(times), wd_key,
        [f"{t // 60:02d}:{t % 60:02d}" for t in times],
    )
    return times


def get_scraping_data(
    client_id: int,
    media_names: list[str],
    start_date: date,
    end_date: date,
    own_shop_name: str | None = None,
) -> pd.DataFrame:
    """指定期間のスクレイピング結果をVPS内DBから取得する。
    own_shop_name が指定された場合は is_own_shop カラムを付加する。
    """
    placeholders = ", ".join(["%s"] * len(media_names))
    sql = f"""
        SELECT media_name, shop_name, sub_area, `rank`, fetched_at
        FROM scraping_results
        WHERE client_id = %s
          AND DATE(fetched_at) BETWEEN %s AND %s
          AND media_name IN ({placeholders})
        ORDER BY fetched_at
    """
    rows = execute_query(sql, (client_id, start_date, end_date, *media_names), local=True)
    if not rows:
        return pd.DataFrame()

    df = pd.DataFrame(rows)
    df["fetched_at"] = pd.to_datetime(df["fetched_at"])
    df["slot"] = df["fetched_at"].dt.hour * 60 + (df["fetched_at"].dt.minute // 3) * 3
    df["is_own_shop"] = (df["shop_name"] == own_shop_name) if own_shop_name else False
    return df


# ---------------------------------------------------------------------------
# 共通ヘルパー
# ---------------------------------------------------------------------------

def slot_to_hhmm(slot: int) -> str:
    """3分単位スロット（分値）をHH:MM形式の文字列に変換する。"""
    h = slot // 60
    m = slot % 60
    return f"{h:02d}:{m:02d}"


def _normalize(series: pd.Series) -> pd.Series:
    """0〜1に正規化する。全値が同じ場合は0.0を返す。"""
    mn, mx = series.min(), series.max()
    if mx == mn:
        return pd.Series([0.0] * len(series), index=series.index)
    return (series - mn) / (mx - mn)


def get_peak_scores_from_access(client_id: int, target_date: date) -> dict | None:
    """gh_access_stats から30分刻みのアクセスデータを取得し、
    3分スロット単位のピークスコア辞書を返す。
    データが不足している場合は None を返す。

    戻り値: {slot(分): peak_score(0.0〜1.0)} or None
    """
    rows = execute_query(
        """
        SELECT hour_slot, total_access
        FROM gh_access_stats
        WHERE client_id = %s
          AND stat_date = %s
        ORDER BY hour_slot
        """,
        (client_id, target_date),
        local=True,
    )

    if not rows or len(rows) < 48:
        # 当日データがなければ直近7日分の平均を使用
        rows = execute_query(
            """
            SELECT hour_slot, AVG(total_access) as total_access
            FROM gh_access_stats
            WHERE client_id = %s
              AND stat_date >= %s
            GROUP BY hour_slot
            ORDER BY hour_slot
            """,
            (client_id, target_date - timedelta(days=7)),
            local=True,
        )

    if not rows or len(rows) < 48:
        logger.error("gh_access_statsデータが不足しています。解析をスキップします。")
        return None

    max_access = max(float(r["total_access"]) for r in rows)
    if max_access == 0:
        return None

    access_by_30min: dict[int, float] = {}
    for r in rows:
        h, m = map(int, r["hour_slot"].split(":"))
        slot_30 = h * 60 + m
        access_by_30min[slot_30] = float(r["total_access"]) / max_access

    peak_scores: dict[int, float] = {}
    for slot3 in range(0, 1440, 3):
        slot_30 = (slot3 // 30) * 30
        peak_scores[slot3] = access_by_30min.get(slot_30, 0.0)

    return peak_scores


def is_excluded_slot(slot: int, excluded: list[tuple[int, int]]) -> bool:
    """スロット（分単位）が除外時間帯に該当するか判定する（girlsheaven用）。
    日をまたぐ除外時間帯（例：23時〜4時）にも対応。
    """
    h = slot // 60
    for s, e in excluded:
        if s > e:
            # 日をまたぐ場合（例：23時〜4時）
            if h >= s or h <= e:
                return True
        else:
            if s <= h < e:
                return True
    return False


def is_excluded_slot_vanilla(slot: int, excluded: list[tuple[int, int]]) -> bool:
    """スロット（分単位）が除外時間帯に該当するか判定する（vanilla用）。
    バニラの更新ツールは05:00〜05:59に実行できないため除外。
    日をまたぐ除外時間帯（例：23時〜4時）にも対応。
    """
    h = slot // 60
    # バニラの更新ツールは05:00〜05:59に実行できないため除外
    if h == 5:
        return True
    for s, e in excluded:
        if s > e:
            # 日をまたぐ場合（例：23時〜4時）
            if h >= s or h <= e:
                return True
        else:
            if s <= h < e:
                return True
    return False


def _apply_excluded(hours: list[int], excluded: list[tuple[int, int]]) -> list[int]:
    """除外時間帯に該当しない時間のみ返す。"""
    return [h for h in hours if not is_excluded_slot(h * 60, excluded)]


def _select_with_fixed(
    scored_slots: list[tuple[int, float]],
    excluded: list[tuple[int, int]],
    fixed: list[int],
    max_count: int,
) -> list[int]:
    """スコア付きスロットリストから最適スロットを選出する（3分単位）。
    fixed（分単位スロット）は除外対象でも必ず含める。残り枠はスコア降順で埋める。
    """
    selected: set[int] = set(fixed)

    def is_excluded(slot: int) -> bool:
        h = slot // 60
        return any(s <= h < e for s, e in excluded)

    for slot, _ in sorted(scored_slots, key=lambda x: x[1], reverse=True):
        if len(selected) >= max_count:
            break
        if slot not in selected and not is_excluded(slot):
            selected.add(slot)

    return sorted(selected)[:max_count]


# ---------------------------------------------------------------------------
# ガールズヘブン解析
# ---------------------------------------------------------------------------

def analyze_girlsheaven(
    df: pd.DataFrame,
    excluded: list[tuple[int, int]],
    fixed: list[int],
    max_count: int = MAX_SUGGESTIONS,
    peak_scores_dict: dict = {},
    update_mode: int = 1,
    gh_target_rank: int = 10,
    target_bump_times: list[int] | None = None,
    target_shop_name: str | None = None,
) -> tuple[list[int], dict]:
    """ガールズヘブン用解析（2ステップアルゴリズム）。

    ステップ①: gh_access_statsのアクセス割合に応じてmax_count回を30分枠に配分
    ステップ②: 各30分枠内で競合動向スコアが高い3分スロットを選出
      スコアA: 競合更新直後スコア（+3分スロットに加点）
      スコアB: 競合静穏スコア（競合更新が少ないスロットに加点）

    mode=2（指定店追随型）はスクレイピングデータ不要で早期リターンする。
    """
    # ── mode=2（指定店追随型）: スクレイピングデータ不要・早期リターン ──────────
    if update_mode == 2:
        bump_times = list(target_bump_times or [])

        if not bump_times:
            logger.warning(
                "[girlsheaven] mode=2（指定店追随型）ですが、ターゲット店「%s」の"
                "更新時刻がありません。fixed のみを使用します。",
                target_shop_name or "(未設定)",
            )
            return sorted(fixed)[:max_count], {}

        # 30分枠アクセススコア（max_count超過時の優先順位付けに使用）
        access_by_30min = {s30: peak_scores_dict.get(s30, 0.0) for s30 in range(0, 1440, 30)}

        # ターゲット更新時刻 +1分 を候補生成（0〜1439分の範囲で折り返す）
        candidates = sorted(set((t + 1) % 1440 for t in bump_times))

        # 除外時間帯フィルタ
        non_excl = [s for s in candidates if not is_excluded_slot(s, excluded)]

        # max_count を超える場合はアクセス数の多い30分枠を優先して絞り込む
        if len(non_excl) > max_count:
            non_excl = sorted(
                sorted(non_excl,
                       key=lambda s: access_by_30min.get((s // 30) * 30, 0.0),
                       reverse=True)[:max_count]
            )

        # fixed を必ず含め、max_count に収める
        fixed_set = set(fixed)
        fixed_selected     = sorted(s for s in fixed_set)
        non_fixed_selected = sorted(s for s in non_excl if s not in fixed_set)
        best_slots = sorted(
            fixed_selected + non_fixed_selected[:max(0, max_count - len(fixed_selected))]
        )

        logger.info(
            "[girlsheaven] mode=2（指定店追随型）ターゲット「%s」 "
            "更新時刻（%d件）: %s → +1分候補（除外後%d件）: %s → 選出: %s",
            target_shop_name or "(未設定)",
            len(bump_times),
            [f"{t // 60:02d}:{t % 60:02d}" for t in bump_times],
            len(non_excl),
            [slot_to_hhmm(s) for s in non_excl],
            [slot_to_hhmm(s) for s in best_slots],
        )
        return best_slots, {}

    # ── 以下は mode=1（順位キープ型）・mode=3（アクセス統計連動更新型）共通 ──────
    media_df = df[df["media_name"] == "girlsheaven"].copy()
    if media_df.empty:
        logger.warning("[girlsheaven] データなし")
        return sorted(fixed)[:max_count], {}

    slots = list(range(0, 1440, 3))

    # ── 弱点度スコアを事前計算（全モード共通・ステップ①配分で使用）────────────
    # 弱点度 = 1.0 - (その30分枠で target_rank 以内だった割合)  0.0〜1.0
    # データがない枠は中立値 0.5 とする。
    weakness_scores: dict[int, float] = {}
    own_df_w = media_df[media_df["is_own_shop"]].copy()
    if not own_df_w.empty:
        own_df_w["in_target"] = (own_df_w["rank"] > 0) & (own_df_w["rank"] <= gh_target_rank)
        for s30 in range(0, 1440, 30):
            in_frame = own_df_w[(own_df_w["slot"] >= s30) & (own_df_w["slot"] < s30 + 30)]
            weakness_scores[s30] = (
                1.0 - float(in_frame["in_target"].mean())
            ) if len(in_frame) > 0 else 0.5
    else:
        weakness_scores = {s30: 0.5 for s30 in range(0, 1440, 30)}
    logger.info("[girlsheaven] 弱点スコア（弱い枠 上位5）: %s",
                [(slot_to_hhmm(s), round(v, 3))
                 for s, v in sorted(weakness_scores.items(), key=lambda x: x[1], reverse=True)[:5]])

    # ── ステップ①: 重要度（アクセス + 弱点度）による30分枠への配分 ─────────────
    slot_30min = list(range(0, 1440, 30))
    access_by_30min = {s30: peak_scores_dict.get(s30, 0.0) for s30 in slot_30min}

    total_access = sum(access_by_30min.values())
    if total_access == 0:
        logger.error("[girlsheaven] アクセスデータが全て0です")
        return sorted(fixed)[:max_count], {}

    sorted_30min = sorted(slot_30min, key=lambda s: access_by_30min[s], reverse=True)
    valid_30min = [s for s in sorted_30min if not is_excluded_slot(s, excluded)]

    # モードC（アクセス統計連動）: アクセスのみで配分して早期リターン（変更なし）
    if update_mode == 3:
        total_access = sum(access_by_30min[s] for s in valid_30min)
        if total_access == 0:
            return sorted(fixed)[:max_count], {}
        raw_allocations = {s30: (access_by_30min[s30] / total_access) * max_count for s30 in valid_30min}
        allocation: dict[int, int] = {}
        remaining = max_count
        for s30 in sorted(valid_30min, key=lambda s: access_by_30min[s], reverse=True):
            if remaining <= 0:
                break
            alloc = min(max(1, round(raw_allocations[s30])), remaining)
            allocation[s30] = alloc
            remaining -= alloc
        selected: set[int] = set(fixed)
        for s30, alloc in allocation.items():
            frame_slots = [s for s in range(s30, s30 + 30, 3)
                           if not is_excluded_slot(s, excluded) and s not in selected]
            for s in frame_slots[:alloc]:
                selected.add(s)
        best_slots = sorted(selected)[:max_count]
        logger.info("[girlsheaven] mode=3 選出スロット: %s", [slot_to_hhmm(s) for s in best_slots])
        return best_slots, {}

    # 順位キープ型（mode=1）のみここに到達（mode=2 は早期リターン済み、mode=3 は上で返却）。
    # アクセス50%閾値によるスキップを廃止。弱点時間帯が配分ゼロにならないよう
    # 全有効枠を対象とし、重要度スコアで自然に配分量を決める。
    #   重要度 = アクセス正規化値 × W_ACCESS + 弱点度 × W_WEAKNESS
    #   集中型(mode=1): W_ACCESS=0.7 → 高アクセス枠に自然集中しつつ弱点も確保
    target_30min = valid_30min  # 全有効枠（弱点枠がスキップされないよう閾値なし）

    w_acc, w_weak = _ALLOC_WEIGHTS.get(update_mode, (W_ACCESS_CONCENTRATED, W_WEAKNESS_CONCENTRATED))
    importance = {
        s30: access_by_30min[s30] * w_acc + weakness_scores.get(s30, 0.5) * w_weak
        for s30 in target_30min
    }
    total_importance = sum(importance.values())
    if total_importance == 0:
        raw_allocations = {s30: max_count / len(target_30min) for s30 in target_30min}
    else:
        raw_allocations = {
            s30: (importance[s30] / total_importance) * max_count
            for s30 in target_30min
        }

    # デバッグ: 重要度上位5枠の内訳ログ
    top5_imp = sorted(importance.items(), key=lambda x: x[1], reverse=True)[:5]
    logger.info(
        "[girlsheaven] 配分重要度 上位5枠 [w_acc=%.1f, w_weak=%.1f]: %s",
        w_acc, w_weak,
        [(slot_to_hhmm(s),
          f"imp={importance[s]:.3f}(acc={access_by_30min[s]:.3f}, weak={weakness_scores.get(s, 0.5):.3f})")
         for s, _ in top5_imp],
    )

    # 重要度の高い枠から順に配分（アクセス順ではなく importance 降順で反復する）
    # target_30min はアクセス降順だが、配分はあくまで重要度ベースで行う必要がある。
    # アクセス順のまま反復すると max(1,...) により高アクセス昼間枠が先に remaining を
    # 消費し、高 importance な深夜枠が remaining=0 で弾かれるバグを防ぐ。
    importance_sorted_30min = sorted(target_30min, key=lambda s: importance[s], reverse=True)

    allocation: dict[int, int] = {}
    remaining = max_count
    for s30 in importance_sorted_30min:
        if remaining <= 0:
            break
        alloc = min(max(1, round(raw_allocations[s30])), remaining)
        allocation[s30] = alloc
        remaining -= alloc

    # 残りがあれば重要度上位枠に追加
    for s30 in importance_sorted_30min:
        if remaining <= 0:
            break
        if s30 in allocation:
            allocation[s30] += 1
            remaining -= 1

    logger.info("[girlsheaven] 30分枠配分: %s",
                {slot_to_hhmm(k): v for k, v in sorted(allocation.items()) if v > 0})

    # ── ステップ②: 各30分枠内でスロット選出 ─────────────────────────────────
    rival_df = media_df[~media_df["is_own_shop"]].copy()
    rival_df = rival_df.sort_values(["shop_name", "fetched_at"])
    rival_df["prev_rank"] = rival_df.groupby("shop_name")["rank"].shift(1)
    rival_df["rank_improved"] = (rival_df["prev_rank"] - rival_df["rank"]) >= 5
    rival_update_df = rival_df[rival_df["rank_improved"] & rival_df["prev_rank"].notna()]

    rival_counts_raw = rival_update_df.groupby("slot").size()
    rival_next_slot = rival_update_df.copy()
    rival_next_slot["slot"] = (rival_next_slot["slot"] + 3) % 1440
    rival_next_counts = rival_next_slot.groupby("slot").size()

    competitor_update_score = pd.Series(0.0, index=slots)
    for s, cnt in rival_next_counts.items():
        if s in competitor_update_score.index:
            competitor_update_score[s] = float(cnt)

    competitor_noise = pd.Series(0.0, index=slots)
    for s, cnt in rival_counts_raw.items():
        if s in competitor_noise.index:
            competitor_noise[s] = float(cnt)
    competitor_quiet_score = competitor_noise.max() - competitor_noise

    n_rival_update = _normalize(competitor_update_score)
    n_rival_quiet = _normalize(competitor_quiet_score)
    rival_score = n_rival_update + n_rival_quiet

    # weakness_scores はステップ①の重要度配分で使用済み。
    # ステップ②（mode=1）ではスロット選出に競合スコアのみを使用（弱点スコアは不要）。

    # fixed時間は必ず追加（除外時間帯は除く）
    selected: set[int] = set()
    for slot in fixed:
        if not is_excluded_slot(slot, excluded):
            selected.add(slot)

    for s30, alloc in allocation.items():
        frame_slots = [s for s in range(s30, s30 + 30, 3)
                       if not is_excluded_slot(s, excluded) and s not in selected]
        frame_slots_scored = sorted(
            frame_slots,
            key=lambda s: rival_score.get(s, 0.0),
            reverse=True,
        )
        for s in frame_slots_scored[:alloc]:
            selected.add(s)

    # fixedを優先してmax_countに収める
    fixed_slots_set = set(fixed)
    fixed_selected = sorted(s for s in selected if s in fixed_slots_set)
    non_fixed_selected = sorted(s for s in selected if s not in fixed_slots_set)
    best_slots = sorted(
        fixed_selected + non_fixed_selected[:max(0, max_count - len(fixed_selected))]
    )

    # slot_scoresを生成
    slot_scores: dict = {}
    for slot in slots:
        hhmm = slot_to_hhmm(slot)
        peak = peak_scores_dict.get(slot, 0.0)
        slot_scores[hhmm] = {
            "peak":         round(float(peak), 3),
            "rival_update": round(float(n_rival_update.get(slot, 0.0)), 3),
            "rival_quiet":  round(float(n_rival_quiet.get(slot, 0.0)), 3),
            "total":        round(float(peak) + float(rival_score.get(slot, 0.0)), 3),
        }

    scored = [(s, slot_scores[slot_to_hhmm(s)]["total"]) for s in slots]
    logger.info("[girlsheaven] スロットスコア（上位5）: %s",
                [(slot_to_hhmm(s), round(v, 3))
                 for s, v in sorted(scored, key=lambda x: x[1], reverse=True)[:5]])
    logger.info("[girlsheaven] 選出スロット: %s", [slot_to_hhmm(s) for s in best_slots])
    return best_slots, slot_scores


# ---------------------------------------------------------------------------
# バニラ解析
# ---------------------------------------------------------------------------

def analyze_vanilla(
    df: pd.DataFrame,
    excluded: list[tuple[int, int]],
    fixed: list[int],
    max_count: int = MAX_SUGGESTIONS,
    peak_scores_dict: dict = {},
    update_mode: int = 1,
    target_bump_times: list[int] | None = None,
    target_shop_name: str | None = None,
) -> tuple[list[int], dict]:
    """バニラ用解析（2ステップアルゴリズム）。

    ステップ①: gh_access_statsのアクセス割合に応じてmax_count回を30分枠に配分
    ステップ②: 各30分枠内で競合動向＋1ページ目滞在スコアが高い3分スロットを選出
      スコアA: 競合更新直後スコア（+3分スロットに加点）
      スコアB: 競合静穏スコア（競合更新が少ないスロットに加点）
      スコアC: 1ページ目滞在スコア（自店がrank1〜20に滞在した時間に加点）

    mode=2（指定店追随型）はスクレイピングデータ不要で早期リターンする。
    """
    # ── mode=2（指定店追随型）: スクレイピングデータ不要・早期リターン ──────────
    if update_mode == 2:
        bump_times = list(target_bump_times or [])

        if not bump_times:
            logger.warning(
                "[vanilla] mode=2（指定店追随型）ですが、ターゲット店「%s」の"
                "更新時刻がありません。fixed のみを使用します。",
                target_shop_name or "(未設定)",
            )
            return sorted(fixed)[:max_count], {}

        # 30分枠アクセススコア（max_count超過時の優先順位付けに使用）
        access_by_30min = {s30: peak_scores_dict.get(s30, 0.0) for s30 in range(0, 1440, 30)}

        # ターゲット更新時刻 +1分 を候補生成（0〜1439分の範囲で折り返す）
        candidates = sorted(set((t + 1) % 1440 for t in bump_times))

        # 除外時間帯フィルタ（バニラは05時台も除外）
        non_excl = [s for s in candidates if not is_excluded_slot_vanilla(s, excluded)]

        # max_count を超える場合はアクセス数の多い30分枠を優先して絞り込む
        if len(non_excl) > max_count:
            non_excl = sorted(
                sorted(non_excl,
                       key=lambda s: access_by_30min.get((s // 30) * 30, 0.0),
                       reverse=True)[:max_count]
            )

        # fixed を必ず含め、max_count に収める
        fixed_set = set(fixed)
        fixed_selected     = sorted(s for s in fixed_set)
        non_fixed_selected = sorted(s for s in non_excl if s not in fixed_set)
        best_slots = sorted(
            fixed_selected + non_fixed_selected[:max(0, max_count - len(fixed_selected))]
        )

        logger.info(
            "[vanilla] mode=2（指定店追随型）ターゲット「%s」 "
            "更新時刻（%d件）: %s → +1分候補（除外後%d件）: %s → 選出: %s",
            target_shop_name or "(未設定)",
            len(bump_times),
            [f"{t // 60:02d}:{t % 60:02d}" for t in bump_times],
            len(non_excl),
            [slot_to_hhmm(s) for s in non_excl],
            [slot_to_hhmm(s) for s in best_slots],
        )
        return best_slots, {}

    # ── 以下は mode=1（順位キープ型）・mode=3（アクセス統計連動更新型）共通 ──────
    media_df = df[df["media_name"] == "vanilla"].copy()
    if media_df.empty:
        logger.warning("[vanilla] データなし")
        return sorted(fixed)[:max_count], {}

    slots = list(range(0, 1440, 3))

    # ── ステップ①: 30分枠へのアクセス割合による回数配分 ─────────────────────
    slot_30min = list(range(0, 1440, 30))
    access_by_30min = {s30: peak_scores_dict.get(s30, 0.0) for s30 in slot_30min}

    total_access = sum(access_by_30min.values())
    if total_access == 0:
        logger.error("[vanilla] アクセスデータが全て0です")
        return sorted(fixed)[:max_count], {}

    sorted_30min = sorted(slot_30min, key=lambda s: access_by_30min[s], reverse=True)
    valid_30min = [s for s in sorted_30min if not is_excluded_slot_vanilla(s, excluded)]

    # モードC（アクセス統計連動）: スコアリングなし・アクセス数のみで配分して早期リターン
    if update_mode == 3:
        total_access = sum(access_by_30min[s] for s in valid_30min)
        if total_access == 0:
            return sorted(fixed)[:max_count], {}
        raw_allocations = {s30: (access_by_30min[s30] / total_access) * max_count for s30 in valid_30min}
        allocation: dict[int, int] = {}
        remaining = max_count
        for s30 in sorted(valid_30min, key=lambda s: access_by_30min[s], reverse=True):
            if remaining <= 0:
                break
            alloc = min(max(1, round(raw_allocations[s30])), remaining)
            allocation[s30] = alloc
            remaining -= alloc
        selected: set[int] = set(fixed)
        for s30, alloc in allocation.items():
            frame_slots = [s for s in range(s30, s30 + 30, 3)
                           if not is_excluded_slot_vanilla(s, excluded) and s not in selected]
            for s in frame_slots[:alloc]:
                selected.add(s)
        best_slots = sorted(selected)[:max_count]
        logger.info("[vanilla] mode=3 選出スロット: %s", [slot_to_hhmm(s) for s in best_slots])
        return best_slots, {}

    # ── 弱点度スコア（ステップ①の重要度配分で使用）────────────────────────────
    # 自店がバニラ20位以内に入れていない時間帯を「弱い時間帯」と判定
    weakness_scores: dict[int, float] = {}
    own_df_w = media_df[media_df["is_own_shop"]].copy()
    if not own_df_w.empty:
        own_df_w["in_target"] = (own_df_w["rank"] > 0) & (own_df_w["rank"] <= 20)
        for s30 in range(0, 1440, 30):
            in_frame = own_df_w[(own_df_w["slot"] >= s30) & (own_df_w["slot"] < s30 + 30)]
            weakness_scores[s30] = (
                1.0 - float(in_frame["in_target"].mean())
            ) if len(in_frame) > 0 else 0.5
    else:
        weakness_scores = {s30: 0.5 for s30 in range(0, 1440, 30)}
    logger.info("[vanilla] 弱い時間帯スコア（上位5）: %s",
                [(slot_to_hhmm(s), round(v, 3))
                 for s, v in sorted(weakness_scores.items(), key=lambda x: x[1], reverse=True)[:5]])

    # ── ステップ①重要度ベース配分（mode=1 のみここに到達）────────────────────────
    # 重要度 = アクセス正規化値 × w_acc + 弱点度 × w_weak
    # 集中型(1): w_acc=0.7 でアクセス寄り
    target_30min = valid_30min  # 全有効枠（しきい値なし）
    w_acc, w_weak = _ALLOC_WEIGHTS.get(update_mode, (W_ACCESS_CONCENTRATED, W_WEAKNESS_CONCENTRATED))
    importance = {
        s30: access_by_30min[s30] * w_acc + weakness_scores.get(s30, 0.5) * w_weak
        for s30 in target_30min
    }
    total_importance = sum(importance.values())
    if total_importance == 0:
        raw_allocations = {s30: max_count / len(target_30min) for s30 in target_30min}
    else:
        raw_allocations = {
            s30: (importance[s30] / total_importance) * max_count
            for s30 in target_30min
        }
    logger.info("[vanilla] 重要度上位5枠: %s",
                [(slot_to_hhmm(s), round(v, 3))
                 for s, v in sorted(importance.items(), key=lambda x: x[1], reverse=True)[:5]])

    allocation: dict[int, int] = {}
    remaining = max_count
    for s30 in target_30min:
        if remaining <= 0:
            break
        alloc = min(max(1, round(raw_allocations[s30])), remaining)
        allocation[s30] = alloc
        remaining -= alloc

    # 残りがあれば上位枠に追加
    for s30 in target_30min:
        if remaining <= 0:
            break
        if s30 in allocation:
            allocation[s30] += 1
            remaining -= 1

    logger.info("[vanilla] 30分枠配分: %s",
                {slot_to_hhmm(k): v for k, v in sorted(allocation.items()) if v > 0})

    # ── ステップ②: 競合スコア + 1ページ目滞在スコア ─────────────────────────
    # バニラは (shop_name, sub_area) 単位で識別（同一店名の別エリアを別店舗として扱う）
    rival_df = media_df[~media_df["is_own_shop"]].copy()
    rival_df = rival_df[rival_df["rank"] > 0].copy()
    rival_df["sub_area"] = rival_df["sub_area"].fillna("")
    rival_df = rival_df.sort_values(["shop_name", "sub_area", "fetched_at"])
    rival_df["prev_rank"] = rival_df.groupby(["shop_name", "sub_area"])["rank"].shift(1)
    rival_df["rank_improved"] = (rival_df["prev_rank"] - rival_df["rank"]) >= 5
    rival_update_df = rival_df[rival_df["rank_improved"] & rival_df["prev_rank"].notna()]

    rival_counts_raw = rival_update_df.groupby("slot").size()
    rival_next_slot = rival_update_df.copy()
    rival_next_slot["slot"] = (rival_next_slot["slot"] + 3) % 1440
    rival_next_counts = rival_next_slot.groupby("slot").size()

    competitor_update_score = pd.Series(0.0, index=slots)
    for s, cnt in rival_next_counts.items():
        if s in competitor_update_score.index:
            competitor_update_score[s] = float(cnt)

    competitor_noise = pd.Series(0.0, index=slots)
    for s, cnt in rival_counts_raw.items():
        if s in competitor_noise.index:
            competitor_noise[s] = float(cnt)
    competitor_quiet_score = competitor_noise.max() - competitor_noise

    n_rival_update = _normalize(competitor_update_score)
    n_rival_quiet = _normalize(competitor_quiet_score)

    # 1ページ目滞在スコア
    own_df = media_df[media_df["is_own_shop"]].sort_values("fetched_at").copy()
    if own_df.empty:
        logger.warning("[vanilla] 自店データなし。is_own_shopフラグを確認してください")
        own_df = media_df.sort_values("fetched_at").copy()
    own_df["on_page1"] = (own_df["rank"] > 0) & (own_df["rank"] <= 20)
    page1_minutes: dict[int, float] = {s: 0.0 for s in slots}
    own_df = own_df.reset_index(drop=True)
    for i in range(len(own_df) - 1):
        row = own_df.iloc[i]
        next_row = own_df.iloc[i + 1]
        if row["on_page1"]:
            duration_min = (next_row["fetched_at"] - row["fetched_at"]).total_seconds() / 60
            if duration_min <= 5:  # 5分以上の gap はスキップ
                s = int(row["slot"])
                page1_minutes[s] += duration_min
    page1_score = pd.Series(page1_minutes)
    n_page1 = _normalize(page1_score)

    rival_score = n_rival_update + n_rival_quiet + n_page1

    # weakness_scores はステップ①の重要度配分で使用済み。
    # ステップ②（mode=1）ではスロット選出に競合スコア + 1ページ目滞在スコアのみを使用。

    # fixed時間は必ず追加（除外時間帯は除く）
    selected: set[int] = set()
    for slot in fixed:
        if not is_excluded_slot_vanilla(slot, excluded):
            selected.add(slot)

    for s30, alloc in allocation.items():
        frame_slots = [s for s in range(s30, s30 + 30, 3)
                       if not is_excluded_slot_vanilla(s, excluded) and s not in selected]
        frame_slots_scored = sorted(
            frame_slots,
            key=lambda s: rival_score.get(s, 0.0),
            reverse=True,
        )
        for s in frame_slots_scored[:alloc]:
            selected.add(s)

    # fixedを優先してmax_countに収める
    fixed_slots_set = set(fixed)
    fixed_selected = sorted(s for s in selected if s in fixed_slots_set)
    non_fixed_selected = sorted(s for s in selected if s not in fixed_slots_set)
    best_slots = sorted(
        fixed_selected + non_fixed_selected[:max(0, max_count - len(fixed_selected))]
    )

    # slot_scoresを生成
    slot_scores: dict = {}
    for slot in slots:
        hhmm = slot_to_hhmm(slot)
        peak = peak_scores_dict.get(slot, 0.0)
        slot_scores[hhmm] = {
            "peak":         round(float(peak), 3),
            "rival_update": round(float(n_rival_update.get(slot, 0.0)), 3),
            "rival_quiet":  round(float(n_rival_quiet.get(slot, 0.0)), 3),
            "page1":        round(float(n_page1.get(slot, 0.0)), 3),
            "total":        round(float(peak) + float(rival_score.get(slot, 0.0)), 3),
        }

    scored = [(s, slot_scores[slot_to_hhmm(s)]["total"]) for s in slots]
    logger.info("[vanilla] スロットスコア（上位5）: %s",
                [(slot_to_hhmm(s), round(v, 3))
                 for s, v in sorted(scored, key=lambda x: x[1], reverse=True)[:5]])
    logger.info("[vanilla] 選出スロット: %s", [slot_to_hhmm(s) for s in best_slots])
    return best_slots, slot_scores


# ---------------------------------------------------------------------------
# 結果保存
# ---------------------------------------------------------------------------

def generate_summary(*args, **kwargs) -> str:
    """summary_comment 生成（Anthropic API 呼び出しを廃止。空文字を返す）。
    DBカラムは残すが、どの画面にも表示されていないため常に空文字とする。
    """
    return ""


def save_analysis_results(
    client_id: int,
    media_name: str,
    analyzed_date: date,
    best_hours: list[int],
    summary_comment: str,
    update_mode: int = 1,
) -> None:
    """解析結果を analysis_results テーブルに保存する（媒体ごとに1行）。"""
    sql = """
        INSERT INTO analysis_results
            (client_id, media_name, analyzed_date, best_times, summary_comment, update_mode)
        VALUES (%s, %s, %s, %s, %s, %s)
        ON DUPLICATE KEY UPDATE
            best_times      = VALUES(best_times),
            summary_comment = VALUES(summary_comment),
            update_mode     = VALUES(update_mode)
    """
    execute_update(
        sql,
        (client_id, media_name, analyzed_date, json.dumps(best_hours), summary_comment, update_mode),
        local=True,
    )
    logger.info("[%s] analysis_results 保存完了 | best_times=%s | update_mode=%d", media_name, best_hours, update_mode)


def generate_reason_text(*args, **kwargs) -> dict:
    """reason_text 生成（Anthropic API 呼び出しを廃止。空辞書を返す）。
    history.php で表示されるが空文字でも崩れないため、常に空辞書を返す。
    呼び出し元は reason_dict.get(t, "") で参照するので reason_text は空文字で保存される。
    """
    return {}


def save_analysis_suggestions(
    client_id: int,
    media_name: str,
    suggest_times_hhmm: list[str],
    slot_scores: dict,
    reason_dict: dict = {},
) -> None:
    """解析提案を analysis_suggestions テーブルに保存する（時間ごとに1行）。"""
    # 当日分を削除
    delete_sql = """
        DELETE FROM analysis_suggestions
        WHERE client_id = %s
        AND media_name = %s
        AND DATE(suggested_at) = CURDATE()
    """
    execute_update(delete_sql, (client_id, media_name), local=True)

    # 各時間ごとに1行INSERTする
    sql = """
        INSERT INTO analysis_suggestions
            (client_id, media_name, suggest_time, slot_score, reason_text, suggested_at)
        VALUES (%s, %s, %s, %s, %s, NOW())
    """
    for t in suggest_times_hhmm:
        score = slot_scores.get(t, {})
        reason = reason_dict.get(t, "")
        execute_update(
            sql,
            (
                client_id,
                media_name,
                t,
                json.dumps(score, ensure_ascii=False),
                reason,
            ),
            local=True,
        )
    logger.info("[%s] analysis_suggestions 保存完了 | %d件", media_name, len(suggest_times_hhmm))


# ---------------------------------------------------------------------------
# エントリポイント
# ---------------------------------------------------------------------------

def main() -> None:
    parser = argparse.ArgumentParser(description="順位データ解析スクリプト")
    parser.add_argument("client_id", type=int, help="クライアントID")
    parser.add_argument(
        "date",
        nargs="?",
        default=None,
        help="解析対象日 (YYYY-MM-DD)。省略時は前日",
    )
    args = parser.parse_args()

    client_id = args.client_id
    if args.date:
        end_date = date.fromisoformat(args.date)
    else:
        end_date = date.today() - timedelta(days=1)
    start_date = end_date - timedelta(days=6)

    logger.info("解析開始 | client_id=%d | 対象期間=%s〜%s", client_id, start_date, end_date)

    # 有効な媒体を取得（カゴヤDB）
    media_names = get_active_media(client_id)
    if not media_names:
        logger.error("有効な媒体が見つかりません (client_id=%d)", client_id)
        sys.exit(1)
    logger.info("有効な媒体: %s", media_names)

    # 自店名を取得（カゴヤDB）
    own_shop_name = get_own_shop_name(client_id)
    logger.info("自店名: %s", own_shop_name)

    # 過去7日分データを取得（VPS内DB）
    df = get_scraping_data(client_id, media_names, start_date, end_date, own_shop_name)
    if df.empty:
        logger.warning("スクレイピングデータがありません (client_id=%d, %s〜%s)", client_id, start_date, end_date)
        sys.exit(1)

    # gh_access_stats からピークスコアを取得
    peak_scores_dict = get_peak_scores_from_access(client_id, end_date)
    if peak_scores_dict is None:
        logger.error("gh_access_statsデータがないため全媒体の解析をスキップします")
        sys.exit(1)
    logger.info("実アクセスデータからピークスコアを取得しました（%d件）", len(peak_scores_dict))

    gh_target_rank = get_gh_target_rank(client_id)
    logger.info("GH target_rank: %d", gh_target_rank)

    # 媒体ごとに解析・保存
    for media_name in media_names:
        logger.info("解析中: %s", media_name)

        # 媒体別に設定値を取得（カゴヤDB）
        excluded    = get_excluded_times(client_id, media_name)
        fixed       = get_fixed_update_times(client_id, media_name)
        fixed_hhmm  = [slot_to_hhmm(s) for s in fixed]
        update_mode = get_update_mode(client_id, media_name)
        logger.info("[%s] 更新モード=%s / 除外=%s / 絶対更新=%s", media_name, update_mode, excluded, fixed_hhmm)

        max_count = get_max_update_count(client_id, media_name)

        # mode=2（指定店追随型）のみ: カゴヤDBからターゲット店名、VPS内DBから更新時刻を取得
        media_target_shop  = None
        media_target_times: list[int] = []
        if update_mode == 2:
            media_target_shop = get_target_shop_name(client_id, media_name)
            if media_target_shop:
                media_target_times = get_target_bump_times(
                    client_id, media_name, media_target_shop, end_date
                )
                logger.info(
                    "[%s] mode=2 ターゲット店: %s / 取得した更新時刻数: %d",
                    media_name, media_target_shop, len(media_target_times),
                )
            else:
                logger.warning(
                    "[%s] mode=2（指定店追随型）ですが target_shop_name が未設定です。"
                    "fixed のみを使用します。",
                    media_name,
                )

        if media_name == "girlsheaven":
            best_slots, slot_scores = analyze_girlsheaven(
                df, excluded, fixed, max_count, peak_scores_dict, update_mode, gh_target_rank,
                target_bump_times=media_target_times,
                target_shop_name=media_target_shop,
            )
        elif media_name == "vanilla":
            best_slots, slot_scores = analyze_vanilla(
                df, excluded, fixed, max_count, peak_scores_dict, update_mode,
                target_bump_times=media_target_times,
                target_shop_name=media_target_shop,
            )
        else:
            logger.warning("未対応の媒体: %s、スキップ", media_name)
            continue

        if not best_slots:
            best_slots = sorted(fixed)[:max_count]

        best_hhmm = [slot_to_hhmm(s) for s in best_slots]
        logger.info("[%s] 最適更新時間候補: %s", media_name, best_hhmm)

        # reason_text / summary_comment は Anthropic API 廃止のため空で保存
        save_analysis_results(client_id, media_name, end_date, best_hhmm, "", update_mode)
        save_analysis_suggestions(client_id, media_name, best_hhmm, slot_scores, {})

    logger.info("解析完了")


if __name__ == "__main__":
    main()
