"""
順位データ解析スクリプト

指定日（省略時は前日）のスクレイピング結果を解析し、最適な更新時間を提案する。

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 anthropic
import pandas as pd
from dotenv import load_dotenv

load_dotenv(Path(__file__).resolve().parents[2] / ".env")

ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")

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__)

if not ANTHROPIC_API_KEY:
    logger.warning("ANTHROPIC_API_KEY が未設定のため reason_text 生成をスキップします")

MAX_SUGGESTIONS = 5


# ---------------------------------------------------------------------------
# DB 取得ヘルパー
# ---------------------------------------------------------------------------

def get_update_mode(client_id: int) -> int:
    """update_timing_settingsからupdate_modeを取得する。
    1=モードA（しきい値あり）、2=モードB（しきい値なし）
    デフォルトは1（モードA）
    """
    rows = execute_query(
        "SELECT update_mode FROM update_timing_settings WHERE client_id = %s LIMIT 1",
        (client_id,),
        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) -> list[tuple[int, int]]:
    """カゴヤDBから除外時間帯（start_time〜end_timeの範囲）を取得する。"""
    rows = execute_query(
        "SELECT start_time, end_time FROM excluded_times WHERE client_id = %s",
        (client_id,),
    )
    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) -> list[int]:
    """カゴヤDBから絶対更新時間を分単位スロットで取得する。
    例：15:02:00 → 902分（15*60+2）
    """
    rows = execute_query(
        "SELECT update_time FROM fixed_update_times WHERE client_id = %s",
        (client_id,),
        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_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, `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,
) -> tuple[list[int], dict]:
    """ガールズヘブン用解析（2ステップアルゴリズム）。

    ステップ①: gh_access_statsのアクセス割合に応じてmax_count回を30分枠に配分
    ステップ②: 各30分枠内で競合動向スコアが高い3分スロットを選出
      スコアA: 競合更新直後スコア（+3分スロットに加点）
      スコアB: 競合静穏スコア（競合更新が少ないスロットに加点）
    """
    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))

    # ── ステップ①: 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)]

    # モードAはしきい値あり（平均の50%未満をスキップ）、モードBはしきい値なし（全枠対象）
    avg_access = sum(access_by_30min[s] for s in valid_30min) / len(valid_30min) if valid_30min else 0.0
    if update_mode == 1:
        target_30min = [s for s in valid_30min if access_by_30min[s] >= avg_access * 0.5]
    else:
        target_30min = valid_30min

    raw_allocations = {s30: (access_by_30min[s30] / total_access) * max_count for s30 in target_30min}

    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("[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

    # 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,
) -> tuple[list[int], dict]:
    """バニラ用解析（2ステップアルゴリズム）。

    ステップ①: gh_access_statsのアクセス割合に応じてmax_count回を30分枠に配分
    ステップ②: 各30分枠内で競合動向＋1ページ目滞在スコアが高い3分スロットを選出
      スコアA: 競合更新直後スコア（+3分スロットに加点）
      スコアB: 競合静穏スコア（競合更新が少ないスロットに加点）
      スコアC: 1ページ目滞在スコア（自店がrank1〜20に滞在した時間に加点）
    """
    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)]

    # モードAはしきい値あり（平均の50%未満をスキップ）、モードBはしきい値なし（全枠対象）
    avg_access = sum(access_by_30min[s] for s in valid_30min) / len(valid_30min) if valid_30min else 0.0
    if update_mode == 1:
        target_30min = [s for s in valid_30min if access_by_30min[s] >= avg_access * 0.5]
    else:
        target_30min = valid_30min

    raw_allocations = {s30: (access_by_30min[s30] / total_access) * max_count for s30 in target_30min}

    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ページ目滞在スコア ─────────────────────────
    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)

    # 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"] <= 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
            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

    # 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(
    media_name: str,
    suggest_times: list[str],
    peak_scores_dict: dict,
    df: pd.DataFrame,
    own_shop_name: str,
    target_rank: int,
    analyzed_date: date,
) -> str:
    """Claude APIを使って指定日の解析データを総括するサマリーコメント（200〜300文字）を生成する。
    API未設定・エラー時は時刻羅列のフォールバック文を返す。
    """
    def _fallback(times: list[str]) -> str:
        if not times:
            return "データを解析しましたが、最適な更新時間を特定できませんでした。"
        return f"データを解析した結果、{'・'.join(times)}の更新が最も効果的でした。"

    if not ANTHROPIC_API_KEY:
        logger.warning("[%s] ANTHROPIC_API_KEY 未設定のため summary 生成をスキップ", media_name)
        return _fallback(suggest_times)

    rank_limit = target_rank if media_name == "girlsheaven" else 20
    media_df   = df[df["media_name"] == media_name].copy()

    # ── 自店の analyzed_date データ ──────────────────────────────────────
    own_df = media_df[
        media_df["is_own_shop"] &
        (media_df["fetched_at"].dt.date == analyzed_date)
    ].sort_values("fetched_at").reset_index(drop=True)

    # 上位N位以内滞在時間
    top_minutes = 0
    for i in range(len(own_df) - 1):
        if own_df.iloc[i]["rank"] <= rank_limit:
            top_minutes += (own_df.iloc[i + 1]["fetched_at"] - own_df.iloc[i]["fetched_at"]).total_seconds() / 60
    top_minutes = int(top_minutes)

    # 前日比滞在時間
    prev_date = analyzed_date - timedelta(days=1)
    prev_own_df = media_df[
        media_df["is_own_shop"] &
        (media_df["fetched_at"].dt.date == prev_date)
    ].sort_values("fetched_at").reset_index(drop=True)
    prev_top = 0
    for i in range(len(prev_own_df) - 1):
        if prev_own_df.iloc[i]["rank"] <= rank_limit:
            prev_top += (prev_own_df.iloc[i + 1]["fetched_at"] - prev_own_df.iloc[i]["fetched_at"]).total_seconds() / 60
    diff_from_yesterday = top_minutes - int(prev_top)

    # 時間帯別順位変動（1時間ごと平均・上位3・下位3）
    if not own_df.empty:
        own_df["hour"] = own_df["fetched_at"].dt.hour
        hourly = own_df.groupby("hour")["rank"].mean().round(1)
        best3  = hourly.nsmallest(3)
        worst3 = hourly.nlargest(3)
        own_rank_changes = (
            "良好な時間帯: " + "・".join(f"{h}時({r}位)" for h, r in best3.items()) +
            " / 苦戦した時間帯: " + "・".join(f"{h}時({r}位)" for h, r in worst3.items())
        )
    else:
        own_rank_changes = "データなし"

    # ── アクセスピーク時間帯（上位3枠）────────────────────────────────────
    slot_30_scores: dict[int, float] = {}
    for s3 in range(0, 1440, 3):
        s30 = (s3 // 30) * 30
        if s30 not in slot_30_scores:
            slot_30_scores[s30] = peak_scores_dict.get(s30, 0.0)
    top_peak = sorted(slot_30_scores.items(), key=lambda x: x[1], reverse=True)[:3]
    peak_hours = "・".join(slot_to_hhmm(s) for s, _ in sorted(top_peak))

    # ── 競合店別平均順位（上位5店・対象日のデータ）──────────────────────────
    rival_df = media_df[
        ~media_df["is_own_shop"] &
        (media_df["fetched_at"].dt.date == analyzed_date)
    ].copy()
    competitor_lines = []
    if not rival_df.empty:
        for shop, grp in rival_df.groupby("shop_name"):
            grp = grp.sort_values("fetched_at")
            avg_r  = round(grp["rank"].mean(), 1)
            best_r = int(grp["rank"].min())
            competitor_lines.append((avg_r, f"{shop}（平均{avg_r}位・最高{best_r}位）"))
        competitor_lines.sort(key=lambda x: x[0])
    competitor_rankings = "、".join(t for _, t in competitor_lines[:5]) if competitor_lines else "データなし"

    diff_sign = f"+{diff_from_yesterday}" if diff_from_yesterday >= 0 else str(diff_from_yesterday)

    prompt = f"""あなたは風俗求人サイトの順位最適化の専門家です。
以下のデータを深く分析し、その日特有の洞察を含む総括文章を200〜300文字で作成してください。

【重要な指示】
- テンプレート的な文章は避けること
- その日のデータに固有の特徴・異常・パターンを見つけること
- 競合店の動向を具体的に分析すること（店名を出してよい）
- 単なる事実の羅列ではなく、因果関係や戦略的な洞察を含めること
- 「昨日は」「当日は」などの表現は使わないこと
- 毎回異なる切り口で分析すること

【データ】
・対象日：{analyzed_date}
・推奨更新時刻：{"・".join(suggest_times)}
・上位{rank_limit}位以内滞在時間：{top_minutes}分
・アクセスピーク時間帯（上位3つ）：{peak_hours}
・競合店別平均順位：{competitor_rankings}
・自店の時間帯別順位変動：{own_rank_changes}
・前日比滞在時間：{diff_sign}分

【分析の切り口例（毎回異なるものを選ぶ）】
- 競合の更新パターンの変化
- アクセスピークと順位の相関
- 時間帯別の競争激化状況
- 自店の強い時間帯・弱い時間帯
- 前日からの改善点・悪化点
- 特定の競合店との優位性比較

200〜300文字の総括文章のみを返してください。マークダウンや記号は使わず自然な日本語で書いてください。"""

    try:
        ai_client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
        message = ai_client.messages.create(
            model="claude-haiku-4-5",
            max_tokens=600,
            messages=[{"role": "user", "content": prompt}],
        )
        summary = message.content[0].text.strip()
        logger.info("[%s] summary_comment 生成完了（%d文字）", media_name, len(summary))
        return summary
    except Exception as e:
        logger.error("[%s] summary_comment 生成エラー: %s", media_name, e)
        return _fallback(suggest_times)


def save_analysis_results(
    client_id: int,
    media_name: str,
    analyzed_date: date,
    best_hours: list[int],
    summary_comment: str,
) -> None:
    """解析結果を analysis_results テーブルに保存する（媒体ごとに1行）。"""
    sql = """
        INSERT INTO analysis_results
            (client_id, media_name, analyzed_date, best_times, summary_comment)
        VALUES (%s, %s, %s, %s, %s)
        ON DUPLICATE KEY UPDATE
            best_times      = VALUES(best_times),
            summary_comment = VALUES(summary_comment)
    """
    execute_update(
        sql,
        (client_id, media_name, analyzed_date, json.dumps(best_hours), summary_comment),
        local=True,
    )
    logger.info("[%s] analysis_results 保存完了 | best_times=%s", media_name, best_hours)


def generate_reason_text(
    client_id: int,
    media_name: str,
    suggest_times_hhmm: list[str],
    slot_scores: dict,
    df: pd.DataFrame,
    own_shop_name: str,
) -> dict:
    """Claude Haiku APIを使って各更新時間の推奨理由をJSON形式で一括生成する。"""

    if not ANTHROPIC_API_KEY:
        logger.warning("[%s] ANTHROPIC_API_KEY が未設定のため reason_text 生成をスキップ", media_name)
        return {}

    media_df = df[df["media_name"] == media_name].copy()
    own_df   = media_df[media_df["is_own_shop"]].sort_values("fetched_at").copy()
    rival_df = media_df[~media_df["is_own_shop"]].copy()

    # 時間帯ごとの詳細データを集計
    slot_details: dict = {}
    for hhmm in suggest_times_hhmm:
        h, m = int(hhmm[:2]), int(hhmm[3:])
        center_min = h * 60 + m  # 分単位の中心値

        # ±30分ウィンドウ内の分値セット（0〜1439 に wrap）
        def in_window(slot_val: int) -> bool:
            diff = (slot_val - center_min) % 1440
            return diff <= 30 or diff >= 1410  # ±30分

        # 自店の該当時間帯の平均順位
        own_window = own_df[own_df["slot"].apply(in_window)]
        own_avg = round(own_window["rank"].mean(), 1) if not own_window.empty else None

        # 自店の更新直後（次スクレイピング）の順位変動
        own_sorted = own_df.reset_index(drop=True)
        own_sorted["next_rank"] = own_sorted["rank"].shift(-1)
        own_sorted["rank_diff"] = own_sorted["next_rank"] - own_sorted["rank"]
        own_update_window = own_sorted[own_sorted["slot"].apply(in_window)]
        own_rank_after = (
            round(own_update_window["next_rank"].mean(), 1)
            if not own_update_window.empty and own_update_window["next_rank"].notna().any()
            else None
        )

        # 競合店ごとの該当時間帯の更新回数と平均順位
        rivals: dict = {}
        for shop, group in rival_df.groupby("shop_name"):
            group = group.sort_values("fetched_at").copy()
            window_rows = group[group["slot"].apply(in_window)]
            if window_rows.empty:
                continue
            update_count = int(group["rank"].diff().lt(0).sum())
            avg_rank = round(window_rows["rank"].mean(), 1)
            entry: dict = {"update_count": update_count, "avg_rank": avg_rank}
            if media_name == "vanilla":
                page1_count = int((window_rows["rank"] <= 20).sum())
                entry["page1_count"] = page1_count
            rivals[str(shop)] = entry

        detail: dict = {
            "own_avg_rank":          own_avg,
            "own_rank_after_update": own_rank_after,
            "rivals":                rivals,
        }
        if media_name == "vanilla":
            own_page1_minutes = 0.0
            win = own_window.reset_index(drop=True)
            for i in range(len(win) - 1):
                if win.iloc[i]["rank"] <= 20:
                    gap = (win.iloc[i + 1]["fetched_at"] - win.iloc[i]["fetched_at"]).total_seconds() / 60
                    own_page1_minutes += gap
            detail["own_page1_minutes"] = round(own_page1_minutes, 1)

        slot_details[hhmm] = detail

    slot_details_json = json.dumps(slot_details, ensure_ascii=False, indent=2)

    num_rivals = len(rival_df["shop_name"].unique()) if not rival_df.empty else 0
    rival_expression_rule = (
        "競合店が3店舗以下のため、店舗名を明記してください（例：「BABYDOLLとキャンディはこの時間帯の更新が少ない」）。"
        if num_rivals <= 3
        else "競合店が4店舗以上のため、「主要競合店」などの総称を使ってください（例：「主要競合店の多くはこの時間帯の更新が少ない」）。"
    )

    prompt = f"""あなたは風俗求人メディアの順位最適化の専門家です。
以下のデータを元に、各更新時間ごとになぜその時間帯に更新を推奨するのか説明してください。

【媒体】{media_name}
【自店舗名】{own_shop_name}
【各時間帯の詳細データ（過去7日間）】
{slot_details_json}

各時間帯の理由文は以下のルールに従って生成してください：

【前提】
・これらの時間帯はすべてアルゴリズムが最適と判断した更新時間です。
・各時間帯について「なぜこの時間に更新すると効果的か」をポジティブな表現で説明してください。
・「優先度が低い」「効果が限定的」「推奨されない」「避けるべき」などのネガティブな表現は絶対に使わないでください。
・全件省略せず生成してください。

【禁止事項】
・「〇〇回更新」「更新回数」など更新回数の表現は一切使わないでください。
・「平均〇〇位」「スコア〇〇」など順位・スコアの数値表現は使わないでください。

【推奨表現】
・1ページ目に滞在できる時間を使った表現にしてください。
  例：「この時間に更新すると約XX分間1ページ目に表示され続けます」

【競合動向の書き方】
{rival_expression_rule}

【フォーマット】
「自店状況：〇〇。競合動向：〇〇。結論：〇〇。」の3部構成で各80文字以内で簡潔にまとめてください。
マークダウン記法は使わず自然な日本語の文章にしてください。
全件省略せず生成してください。

以下のJSON形式のみで回答してください。
マークダウン記法、コードブロック、説明文は一切不要です。
JSONのみを返してください。
提案時刻は必ず全件含めてください。1件も省略しないでください。

{{
  "HH:MM": "自店状況：〇〇。競合動向：〇〇。結論：〇〇。",
  ...
}}

提案時刻（全件必ず含めること）：
{", ".join(suggest_times_hhmm)}
"""

    try:
        client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
        max_tokens = 8192
        message = client.messages.create(
            model="claude-haiku-4-5",
            max_tokens=max_tokens,
            messages=[{"role": "user", "content": prompt}]
        )
        response_text = message.content[0].text.strip()
        logger.info("[%s] reason_text JSON生成完了", media_name)

        # コードブロック記法を除去
        response_text = response_text.replace("```json", "").replace("```", "").strip()

        reason_dict = json.loads(response_text)
        return reason_dict

    except json.JSONDecodeError as e:
        # 部分的なJSONを救済する処理
        try:
            text = response_text
            last_complete = text.rfind('",\n  "')
            if last_complete > 0:
                partial = text[:last_complete] + '"\n}'
                reason_dict = json.loads(partial)
                logger.warning("[%s] 部分的なJSONを救済: %d件", media_name, len(reason_dict))
                return reason_dict
        except:
            pass
        logger.error("[%s] JSONパースエラー: %s", media_name, e)
        return {}
    except Exception as e:
        logger.error("[%s] Claude API呼び出しエラー: %s", media_name, e)
        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)

    # 除外時間・絶対更新時間を取得（カゴヤDB）
    excluded = get_excluded_times(client_id)
    fixed = get_fixed_update_times(client_id)
    fixed_hhmm = [slot_to_hhmm(s) for s in fixed]
    logger.info("除外時間: %s | 絶対更新時間: %s", excluded, fixed_hhmm)

    # 過去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))

    update_mode = get_update_mode(client_id)
    logger.info("更新モード: %s", "モードA（しきい値あり）" if update_mode == 1 else "モードB（しきい値なし）")

    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)
        max_count = get_max_update_count(client_id, media_name)

        if media_name == "girlsheaven":
            best_slots, slot_scores = analyze_girlsheaven(
                df, excluded, fixed, max_count, peak_scores_dict, update_mode
            )
        elif media_name == "vanilla":
            best_slots, slot_scores = analyze_vanilla(
                df, excluded, fixed, max_count, peak_scores_dict, update_mode
            )
        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]
        summary = generate_summary(
            media_name, best_hhmm, peak_scores_dict, df,
            own_shop_name or "", gh_target_rank, end_date,
        )
        logger.info("[%s] 最適更新時間候補: %s | サマリー: %s", media_name, best_hhmm, summary)

        reason_dict = generate_reason_text(client_id, media_name, best_hhmm, slot_scores, df, own_shop_name)

        save_analysis_results(client_id, media_name, end_date, best_hhmm, summary)
        save_analysis_suggestions(client_id, media_name, best_hhmm, slot_scores, reason_dict)

        # 過去7日分（start_date〜end_date-1日）の summary_comment を新フォーマットで再生成
        logger.info("[%s] 過去7日分 summary_comment 再生成開始", media_name)
        for past_date in [end_date - timedelta(days=i) for i in range(1, 7)]:
            past_rows = execute_query(
                "SELECT best_times FROM analysis_results "
                "WHERE client_id = %s AND media_name = %s AND analyzed_date = %s LIMIT 1",
                (client_id, media_name, past_date),
                local=True,
            )
            if not past_rows or not past_rows[0]["best_times"]:
                continue
            try:
                past_best_hhmm = json.loads(past_rows[0]["best_times"])
                # best_timesがスロット整数リストの場合はHH:MMに変換
                if past_best_hhmm and isinstance(past_best_hhmm[0], int):
                    past_best_hhmm = [slot_to_hhmm(s) for s in past_best_hhmm]
            except (json.JSONDecodeError, TypeError):
                continue
            past_summary = generate_summary(
                media_name, past_best_hhmm, peak_scores_dict, df,
                own_shop_name or "", gh_target_rank, past_date,
            )
            execute_update(
                "UPDATE analysis_results SET summary_comment = %s "
                "WHERE client_id = %s AND media_name = %s AND analyzed_date = %s",
                (past_summary, client_id, media_name, past_date),
                local=True,
            )
            logger.info("[%s] %s summary_comment 更新完了", media_name, past_date)

    logger.info("解析完了")


if __name__ == "__main__":
    main()
