"""Estimate Steam unit sales over time from public review timestamps.

This is the data-collection and estimation script used for Emergence
Interactive's supporting market analysis, which was run on March 1, 2026. It
can discover games by Steam tag or process an explicit list of Steam app IDs,
cache public metadata and review timestamps, and write per-game sales curves.
The companion ``rankGames.py`` script summarizes those per-game CSV files.

Model parameters can be supplied with ``--model-json``. The published analysis
uses the separately provided ``assumptions.json`` file. Estimates are not
reported sales: they apply configurable review-to-sales multipliers and should
be interpreted with the uncertainty and sensitivity analysis described on the
Supporting Market Analysis page.

Re-running the script may produce different inputs because Steam metadata,
prices, reviews, storefront HTML, and undocumented endpoints can change. The
dated data package is therefore the historical record of the published run.

Requirements: Python 3.10+, pandas, requests, beautifulsoup4, lxml, and
python-dateutil. Matplotlib is optional and is imported only when ``--plots``
is used.

Examples::

    python salesModel.py --model-json assumptions.json genre \
        --genre "Open World Survival Craft" --tag-id TAG_ID \
        --max-games 1200 --out analysis-output

    python salesModel.py --model-json assumptions.json appids \
        --appids "APP_ID_1,APP_ID_2" --out analysis-output
"""

from __future__ import annotations

import argparse
import dataclasses
import datetime as dt
import json
import random
import re
import sqlite3
import time
from pathlib import Path
from typing import Any, Optional
from urllib.parse import unquote

import pandas as pd
import requests
from bs4 import BeautifulSoup
from dateutil import parser as dateparser


# -----------------------------
# Assumptions / model parameters
# -----------------------------

DEFAULT_MODEL = {
    "base_multiplier": 40.0,
    # Multipliers below are illustrative defaults; override via --model-json.
    "genre_factors": {
        "visual novel": 0.6,
        "narrative": 0.7,
        "adventure": 0.8,
        "roguelike": 1.0,
        "city builder": 1.1,
        "strategy": 1.2,
        "4x": 1.25,
        "multiplayer": 1.3,
        "fps": 1.3,
        "casual": 1.4,
    },
    # Price factor is based on current USD price from appdetails (not sale-aware).
    "price_factors_usd": [
        {"max_price": 0.0, "factor": 0.4},   # free
        {"max_price": 9.99, "factor": 0.7},
        {"max_price": 19.99, "factor": 1.0},
        {"max_price": 39.99, "factor": 1.2},
        {"max_price": 1e9, "factor": 1.4},
    ],
    # Age since release (days) → factor. Piecewise, evaluated per day t.
    "age_factors_days": [
        {"max_days": 30, "factor": 0.6},
        {"max_days": 180, "factor": 0.8},
        {"max_days": 730, "factor": 1.0},
        {"max_days": 10_000_000, "factor": 1.2},
    ],
    # Confidence band scaling on multiplier
    "bands": {"low": 0.75, "mid": 1.0, "high": 1.30},
}

USER_AGENT = "steam-sales-diy/1.0 (+local analysis)"
STEAM_CC = "US"
STEAM_L = "english"


# -----------------------------
# Utilities
# -----------------------------

RETRIABLE_HTTP = {429, 500, 502, 503, 504}


def get_json_with_retries(
    session: requests.Session,
    url: str,
    params: dict[str, Any],
    timeout: int = 30,
    max_attempts: int = 8,
    base_sleep_s: float = 1.0,
    debug: bool = True,
) -> dict[str, Any]:
    """GET JSON with exponential backoff and jitter.

    Returns parsed JSON or raises RuntimeError after max_attempts.
    """
    last_err: str = ""
    for attempt in range(1, max_attempts + 1):
        try:
            r = session.get(
                url,
                params=params,
                timeout=timeout,
                headers={"User-Agent": USER_AGENT, "Accept": "application/json"},
            )

            # If Steam returns HTML error pages, .json() will fail; handle both cases
            if r.status_code in RETRIABLE_HTTP:
                last_err = f"HTTP {r.status_code} for {r.url}: {r.text[:200]}"
                if debug:
                    print(
                        f"[debug] transient {r.status_code} attempt "
                        f"{attempt}/{max_attempts} -> backoff"
                    )
                sleep = base_sleep_s * (2 ** min(attempt - 1, 6)) + random.uniform(0, 0.8)
                time.sleep(sleep)
                continue

            if r.status_code >= 400:
                raise RuntimeError(f"HTTP {r.status_code} for {r.url}: {r.text[:400]}")

            return r.json()

        except (requests.Timeout, requests.ConnectionError) as e:
            last_err = f"{type(e).__name__}: {e}"
            if debug:
                print(
                    f"[debug] network error attempt {attempt}/{max_attempts}: "
                    f"{last_err} -> backoff"
                )
            sleep = base_sleep_s * (2 ** min(attempt - 1, 6)) + random.uniform(0, 0.8)
            time.sleep(sleep)
            continue
        except ValueError as e:
            # JSON decode error (often HTML returned). Treat as transient once or twice.
            last_err = f"JSON decode error: {e}"
            if debug:
                print(f"[debug] {last_err} attempt {attempt}/{max_attempts} -> backoff")
            sleep = base_sleep_s * (2 ** min(attempt - 1, 6)) + random.uniform(0, 0.8)
            time.sleep(sleep)
            continue

    raise RuntimeError(f"Failed after {max_attempts} attempts: {last_err}")


def http_get(
    session: requests.Session,
    url: str,
    params: dict[str, Any] | None = None,
    timeout: int = 30,
) -> requests.Response:
    r = session.get(url, params=params or {}, timeout=timeout, headers={"User-Agent": USER_AGENT})
    if r.status_code >= 400:
        raise RuntimeError(f"HTTP {r.status_code} for {url}: {r.text[:400]}")
    return r


def safe_filename(name: str) -> str:
    name = re.sub(r"[^\w\-\. ]+", "", name, flags=re.UNICODE).strip()
    name = name.replace(" ", "_")
    return name[:200] if name else "unknown"


def utc_date_from_timestamp(timestamp: int) -> dt.date:
    """Convert a Unix timestamp to its UTC calendar date."""
    return dt.datetime.fromtimestamp(
        timestamp,
        tz=dt.timezone.utc,
    ).date()


def parse_steam_release_date(value: str) -> Optional[dt.date]:
    # Steam returns inconsistent formats: "14 Dec, 2020", "Dec 2020", "Coming Soon"
    try:
        parsed_date = dateparser.parse(
            value,
            fuzzy=True,
            default=dt.datetime(2000, 1, 1),
        )
        # If only month/year, dateutil may set day=1. That's acceptable for age bucketing.
        return parsed_date.date()
    except Exception:
        return None


# -----------------------------
# Steam discovery (tags and appids)
# -----------------------------

def lookup_tag_id(session: requests.Session, genre_term: str) -> Optional[int]:
    """
    Uses the Steam store "search/suggest" endpoint (HTML) to find a tag id.
    This endpoint is not formally documented; may change.

    It often returns elements containing data-ds-tagid="####".
    """
    url = "https://store.steampowered.com/search/suggest"
    params = {
        "term": genre_term,
        "f": "tags",
        "cc": STEAM_CC,
        "l": STEAM_L,
        "v": "1",
    }
    r = http_get(session, url, params=params)
    text = r.text
    m = re.search(r'data-ds-tagid="(\d+)"', text)
    if m:
        return int(m.group(1))
    return None


def discover_appids_by_tag(
    session: requests.Session,
    tag_id: int,
    max_games: int,
    min_reviews: int,
    sleep_s: float = 0.8,
) -> list[int]:
    """Pull app IDs from Steam search pages for a tag, on a best-effort basis.

    Steam does not reliably expose review counts in these result rows, so
    ``min_reviews`` is enforced later, after review timestamps are fetched.
    """
    appids: list[int] = []
    page = 1
    seen = set()

    while len(appids) < max_games:
        url = "https://store.steampowered.com/search/"
        params = {
            "tags": str(tag_id),
            "category1": "998",     # games
            "supportedlang": STEAM_L,
            "cc": STEAM_CC,
            "l": STEAM_L,
            "page": str(page),
        }
        r = http_get(session, url, params=params)
        soup = BeautifulSoup(r.text, "lxml")

        rows = soup.select("a.search_result_row")
        if not rows:
            break

        # Steam embeds appid in data-ds-appid, sometimes comma-separated for packages/bundles.
        for a in rows:
            ds_appid = a.get("data-ds-appid") or ""
            # Take first numeric appid
            m = re.search(r"(\d+)", ds_appid)
            if not m:
                # fallback: /app/<id>/
                href = a.get("href") or ""
                m2 = re.search(r"/app/(\d+)/", href)
                if not m2:
                    continue
                appid = int(m2.group(1))
            else:
                appid = int(m.group(1))

            if appid in seen:
                continue
            seen.add(appid)

            appids.append(appid)
            if len(appids) >= max_games:
                break

        page += 1
        time.sleep(sleep_s)

    return appids


# -----------------------------
# Steam metadata (appdetails)
# -----------------------------

@dataclasses.dataclass(frozen=True)
class AppMeta:
    appid: int
    name: str
    release_date: Optional[dt.date]
    price_usd: Optional[float]
    is_free: bool
    type: str  # "game", etc.


def fetch_appdetails(
    session: requests.Session,
    appid: int,
    sleep_s: float = 0.4,
) -> Optional[AppMeta]:
    url = "https://store.steampowered.com/api/appdetails"
    params = {"appids": str(appid), "cc": STEAM_CC, "l": STEAM_L}
    r = http_get(session, url, params=params)
    j = r.json()
    time.sleep(sleep_s)

    block = j.get(str(appid), {})
    if not block.get("success"):
        return None
    data = block.get("data") or {}
    name = data.get("name") or f"appid_{appid}"
    app_type = data.get("type") or "unknown"

    rd = None
    rd_block = data.get("release_date") or {}
    if isinstance(rd_block, dict):
        rd_str = rd_block.get("date") or ""
        rd = parse_steam_release_date(rd_str)

    is_free = bool(data.get("is_free", False))
    price_usd: Optional[float] = None
    if is_free:
        price_usd = 0.0
    else:
        price_overview = data.get("price_overview") or {}
        # final is in cents (in the specified country currency); in US it's USD cents
        if isinstance(price_overview, dict) and "final" in price_overview:
            try:
                price_usd = float(price_overview["final"]) / 100.0
            except Exception:
                price_usd = None

    return AppMeta(
        appid=appid,
        name=name,
        release_date=rd,
        price_usd=price_usd,
        is_free=is_free,
        type=app_type,
    )


# -----------------------------
# Steam reviews (appreviews cursor pagination)
# -----------------------------

def fetch_all_review_timestamps(
    session: requests.Session,
    appid: int,
    max_pages: int = 50000,
    num_per_page: int = 100,
    sleep_s: float = 0.6,
    debug: bool = True,
    empty_page_retries: int = 10,
) -> list[int]:
    url = f"https://store.steampowered.com/appreviews/{appid}"
    cursor = "*"
    out_ts: list[int] = []
    pages = 0
    seen_cursors: set[str] = set()
    empty_streak = 0

    while pages < max_pages:
        params = {
            "json": "1",
            "filter": "recent",
            "language": "all",
            "review_type": "all",
            "purchase_type": "all",
            "num_per_page": str(num_per_page),
            "cursor": cursor if cursor == "*" else unquote(cursor),
            "filter_offtopic_activity": "0",
        }

        j = get_json_with_retries(
            session=session,
            url=url,
            params=params,
            timeout=30,
            max_attempts=8,
            base_sleep_s=1.0,
            debug=debug,
        )

        reviews = j.get("reviews") or []
        next_cursor = j.get("cursor") or ""

        # Retry transient empty pages (rate limits / hiccups)
        if not reviews:
            empty_streak += 1
            if empty_streak <= empty_page_retries:
                if debug:
                    print(f"[debug] appid={appid}: empty page at pages={pages}; retrying "
                          f"(empty_streak={empty_streak}/{empty_page_retries})")
                time.sleep(max(2.0, sleep_s) * (2 ** min(empty_streak, 5)))
                continue
            if debug:
                print(f"[debug] appid={appid}: empty page at pages={pages}; stopping after "
                      f"{empty_streak} consecutive empty pages")
            break

        # Reset streak after a successful page
        empty_streak = 0

        # Cursor loop protection
        if next_cursor in seen_cursors:
            if debug:
                print(
                    f"[debug] appid={appid}: cursor repeated at page={pages}; "
                    "stopping to avoid loop"
                )
            break
        if next_cursor:
            seen_cursors.add(next_cursor)
        else:
            if debug:
                print(f"[debug] appid={appid}: missing cursor at page={pages}; stopping")
            break

        for rev in reviews:
            ts = rev.get("timestamp_created")
            if isinstance(ts, int):
                out_ts.append(ts)

        cursor = next_cursor
        if debug and pages % 50 == 0:
            oldest = utc_date_from_timestamp(min(out_ts))
            print(f"[debug] page={pages} total_reviews={len(out_ts)} oldest={oldest}")

        pages += 1
        time.sleep(sleep_s)

    if debug and out_ts:
        oldest = utc_date_from_timestamp(min(out_ts))
        newest = utc_date_from_timestamp(max(out_ts))
        print(f"[debug] appid={appid} reviews fetched: {len(out_ts)} range: {oldest} .. {newest}")

    return out_ts


# -----------------------------
# Model: multiplier components
# -----------------------------

def genre_factor(model: dict[str, Any], genre_name: str) -> float:
    # Use substring match against keys for convenience (e.g. "Roguelike" hits "roguelike")
    g = (genre_name or "").strip().lower()
    factors = model.get("genre_factors", {})
    for k, v in factors.items():
        if k.lower() in g:
            return float(v)
    # default: neutral
    return 1.0


def price_factor(model: dict[str, Any], price_usd: Optional[float]) -> float:
    pf = model.get("price_factors_usd", [])
    if price_usd is None:
        return 1.0
    for band in pf:
        if price_usd <= float(band["max_price"]):
            return float(band["factor"])
    return 1.0


def age_factor(model: dict[str, Any], days_since_release: Optional[int]) -> float:
    af = model.get("age_factors_days", [])
    if days_since_release is None:
        return 1.0
    for band in af:
        if days_since_release <= int(band["max_days"]):
            return float(band["factor"])
    return 1.0


# -----------------------------
# Storage (SQLite cache + outputs)
# -----------------------------

def init_db(db_path: Path) -> None:
    with sqlite3.connect(db_path) as con:
        con.execute(
            """
            CREATE TABLE IF NOT EXISTS reviews_cache (
                appid INTEGER PRIMARY KEY,
                fetched_at TEXT NOT NULL,
                timestamps_json TEXT NOT NULL
            )
            """
        )
        con.execute(
            """
            CREATE TABLE IF NOT EXISTS meta_cache (
                appid INTEGER PRIMARY KEY,
                fetched_at TEXT NOT NULL,
                meta_json TEXT NOT NULL
            )
            """
        )
        con.commit()


def cache_put(con: sqlite3.Connection, table: str, appid: int, payload: dict[str, Any]) -> None:
    now = dt.datetime.now(dt.timezone.utc).isoformat()
    con.execute(
        f"INSERT OR REPLACE INTO {table} (appid, fetched_at, timestamps_json) VALUES (?, ?, ?)"
        if table == "reviews_cache"
        else f"INSERT OR REPLACE INTO {table} (appid, fetched_at, meta_json) VALUES (?, ?, ?)",
        (appid, now, json.dumps(payload, separators=(",", ":"))),
    )


def cache_get(con: sqlite3.Connection, table: str, appid: int) -> Optional[dict[str, Any]]:
    col = "timestamps_json" if table == "reviews_cache" else "meta_json"
    cur = con.execute(f"SELECT {col} FROM {table} WHERE appid = ?", (appid,))
    row = cur.fetchone()
    if not row:
        return None
    try:
        return json.loads(row[0])
    except Exception:
        return None


# -----------------------------
# Curve construction
# -----------------------------

def build_daily_reviews_df(timestamps: list[int]) -> pd.DataFrame:
    if not timestamps:
        return pd.DataFrame(columns=["date", "reviews"]).astype(
            {"date": "datetime64[ns]", "reviews": "int64"}
        )
    dates = (
        pd.to_datetime(
            pd.Series(timestamps, dtype="int64"),
            unit="s",
            utc=True,
        )
        .dt.tz_convert(None)
        .dt.date
    )
    df = pd.DataFrame({"date": dates})
    out = df.groupby("date").size().reset_index(name="reviews")
    out["date"] = pd.to_datetime(out["date"])
    return out.sort_values("date").reset_index(drop=True)


def estimate_sales_curve(
    model: dict[str, Any],
    meta: AppMeta,
    genre_name_for_factor: str,
    daily_reviews: pd.DataFrame,
) -> pd.DataFrame:
    """
    Returns daily + cumulative estimates with low/mid/high bands.
    """
    base = float(model["base_multiplier"])
    gf = genre_factor(model, genre_name_for_factor)
    pf = price_factor(model, meta.price_usd)
    bands = model.get("bands", {"low": 0.75, "mid": 1.0, "high": 1.30})

    df = daily_reviews.copy()
    if df.empty:
        return pd.DataFrame(columns=[
            "date", "reviews",
            "mult_low", "mult_mid", "mult_high",
            "sales_low", "sales_mid", "sales_high",
            "cum_sales_low", "cum_sales_mid", "cum_sales_high",
        ])

    # days since release for each date
    if meta.release_date is not None:
        rel = pd.to_datetime(meta.release_date)
        df["days_since_release"] = (df["date"] - rel).dt.days
    else:
        df["days_since_release"] = pd.NA

    # compute age factor per row
    def row_age_factor(x: Any) -> float:
        try:
            if pd.isna(x):
                return 1.0
            return age_factor(model, int(x))
        except Exception:
            return 1.0

    df["age_factor"] = df["days_since_release"].apply(row_age_factor)

    # multipliers
    df["mult_mid"] = base * gf * pf * df["age_factor"]
    df["mult_low"] = df["mult_mid"] * float(bands["low"])
    df["mult_high"] = df["mult_mid"] * float(bands["high"])

    # daily sales
    df["sales_low"] = df["reviews"] * df["mult_low"]
    df["sales_mid"] = df["reviews"] * df["mult_mid"]
    df["sales_high"] = df["reviews"] * df["mult_high"]

    # cumulative
    df["cum_sales_low"] = df["sales_low"].cumsum()
    df["cum_sales_mid"] = df["sales_mid"].cumsum()
    df["cum_sales_high"] = df["sales_high"].cumsum()

    return df[[
        "date", "reviews",
        "mult_low", "mult_mid", "mult_high",
        "sales_low", "sales_mid", "sales_high",
        "cum_sales_low", "cum_sales_mid", "cum_sales_high",
    ]].copy()


def aggregate_genre_curve(game_curves: list[tuple[AppMeta, pd.DataFrame]]) -> pd.DataFrame:
    """Sum cumulative game curves across the union of their dates.

    Each game's cumulative totals are carried forward before summation so a
    game remains represented on dates when it received no new reviews.
    """
    if not game_curves:
        return pd.DataFrame()

    columns = ["cum_sales_low", "cum_sales_mid", "cum_sales_high"]
    frames: list[pd.DataFrame] = []
    for _meta, curve in game_curves:
        if curve.empty:
            continue
        frame = curve[["date", *columns]].copy()
        frame["date"] = pd.to_datetime(frame["date"])
        frames.append(frame.set_index("date").sort_index())

    if not frames:
        return pd.DataFrame()

    all_dates = frames[0].index
    for frame in frames[1:]:
        all_dates = all_dates.union(frame.index)
    all_dates = all_dates.sort_values()

    aggregate = pd.DataFrame(0.0, index=all_dates, columns=columns)
    for frame in frames:
        aligned = frame.reindex(all_dates).ffill().fillna(0.0)
        aggregate = aggregate.add(aligned, fill_value=0.0)

    aggregate.index.name = "date"
    return aggregate.reset_index()


# -----------------------------
# Optional plotting
# -----------------------------

def plot_game_curve(meta: AppMeta, df: pd.DataFrame, out_png: Path) -> None:
    import matplotlib.pyplot as plt

    if df.empty:
        return
    plt.figure()
    plt.plot(df["date"], df["cum_sales_mid"])
    plt.fill_between(df["date"], df["cum_sales_low"], df["cum_sales_high"], alpha=0.2)
    plt.title(meta.name)
    plt.xlabel("Date")
    plt.ylabel("Estimated cumulative copies sold")
    plt.tight_layout()
    plt.savefig(out_png, dpi=200)
    plt.close()


def plot_genre_curve(genre: str, df: pd.DataFrame, out_png: Path) -> None:
    import matplotlib.pyplot as plt

    if df.empty:
        return
    plt.figure()
    plt.plot(df["date"], df["cum_sales_mid"])
    plt.fill_between(df["date"], df["cum_sales_low"], df["cum_sales_high"], alpha=0.2)
    plt.title(f"Genre aggregate: {genre}")
    plt.xlabel("Date")
    plt.ylabel("Estimated cumulative copies sold (sum across games)")
    plt.tight_layout()
    plt.savefig(out_png, dpi=200)
    plt.close()


# -----------------------------
# Main workflows
# -----------------------------

def load_model(path: Optional[str]) -> dict[str, Any]:
    model = json.loads(json.dumps(DEFAULT_MODEL))  # deep copy
    if path:
        with open(path, "r", encoding="utf-8") as f:
            override = json.load(f)
        # shallow merge keys
        for k, v in override.items():
            model[k] = v
    return model


def run_for_appids(
    appids: list[int],
    genre_name_for_factor: str,
    out_dir: Path,
    model: dict[str, Any],
    min_reviews: int,
    plots: bool,
    refresh_reviews: bool = False,
) -> None:
    out_dir.mkdir(parents=True, exist_ok=True)
    (out_dir / "games").mkdir(parents=True, exist_ok=True)
    if plots:
        (out_dir / "plots").mkdir(parents=True, exist_ok=True)
        (out_dir / "plots" / "games").mkdir(parents=True, exist_ok=True)

    db_path = out_dir / "cache.sqlite"
    init_db(db_path)

    session = requests.Session()

    game_curves: list[tuple[AppMeta, pd.DataFrame]] = []

    with sqlite3.connect(db_path) as con:
        for i, appid in enumerate(appids, start=1):
            # Skip already-processed games unless refresh is requested
            existing_csv = None
            if not refresh_reviews:
                # We don't know the name yet, so check by appid suffix
                games_dir = out_dir / "games"
                matches = list(games_dir.glob(f"*_{appid}.csv"))
                if matches:
                    existing_csv = matches[0]

            if existing_csv is not None:
                existing_curve = pd.read_csv(existing_csv, parse_dates=["date"])
                existing_meta = AppMeta(
                    appid=appid,
                    name=existing_csv.stem,
                    release_date=None,
                    price_usd=None,
                    is_free=False,
                    type="game",
                )
                game_curves.append((existing_meta, existing_curve))
                print(
                    f"[{i}/{len(appids)}] appid={appid}: output exists "
                    f"({existing_csv.name}); reusing it"
                )
                continue

            # metadata cache
            meta_payload = cache_get(con, "meta_cache", appid)
            meta: Optional[AppMeta] = None
            if meta_payload:
                try:
                    meta = AppMeta(
                        appid=appid,
                        name=meta_payload["name"],
                        release_date=(
                            dt.date.fromisoformat(meta_payload["release_date"])
                            if meta_payload.get("release_date")
                            else None
                        ),
                        price_usd=(
                            float(meta_payload["price_usd"])
                            if meta_payload.get("price_usd") is not None
                            else None
                        ),
                        is_free=bool(meta_payload.get("is_free", False)),
                        type=meta_payload.get("type", "unknown"),
                    )
                except Exception:
                    meta = None

            if meta is None:
                meta = fetch_appdetails(session, appid)
                if meta is None:
                    print(f"[{i}/{len(appids)}] appid={appid}: no appdetails; skipping")
                    continue
                cache_put(con, "meta_cache", appid, {
                    "name": meta.name,
                    "release_date": meta.release_date.isoformat() if meta.release_date else None,
                    "price_usd": meta.price_usd,
                    "is_free": meta.is_free,
                    "type": meta.type,
                })
                con.commit()

            if meta.type != "game":
                print(
                    f"[{i}/{len(appids)}] {meta.name} ({appid}): "
                    f"type={meta.type}; skipping"
                )
                continue

            # review cache
            rev_payload = (
                None
                if refresh_reviews
                else cache_get(con, "reviews_cache", appid)
            )
            timestamps: list[int] = []
            if rev_payload and "timestamps" in rev_payload:
                timestamps = list(map(int, rev_payload["timestamps"]))
            else:
                print(f"[{i}/{len(appids)}] Fetching reviews: {meta.name} ({appid})")
                try:
                    timestamps = fetch_all_review_timestamps(session, appid)
                except Exception as error:
                    print(
                        f"[{i}/{len(appids)}] ERROR fetching reviews for "
                        f"{meta.name} ({appid}): {error}"
                    )
                    continue

                cache_put(con, "reviews_cache", appid, {"timestamps": timestamps})
                con.commit()

            if len(timestamps) < min_reviews:
                print(
                    f"[{i}/{len(appids)}] {meta.name} ({appid}): "
                    f"{len(timestamps)} reviews < min {min_reviews}; skipping"
                )
                continue

            daily = build_daily_reviews_df(timestamps)
            curve = estimate_sales_curve(model, meta, genre_name_for_factor, daily)

            # write per-game csv
            game_slug = safe_filename(meta.name)
            out_csv = out_dir / "games" / f"{game_slug}_{appid}.csv"
            curve.to_csv(out_csv, index=False)

            if plots:
                out_png = out_dir / "plots" / "games" / f"{game_slug}_{appid}.png"
                plot_game_curve(meta, curve, out_png)

            game_curves.append((meta, curve))
            print(f"[{i}/{len(appids)}] Wrote: {out_csv}")

    # aggregate genre curve
    agg = aggregate_genre_curve(game_curves)
    agg_csv = out_dir / "genre_aggregate.csv"
    agg.to_csv(agg_csv, index=False)
    print(f"Wrote: {agg_csv}")

    if plots:
        genre_png = out_dir / "plots" / "genre_aggregate.png"
        plot_genre_curve(genre_name_for_factor, agg, genre_png)
        print(f"Wrote: {genre_png}")

    # assumptions snapshot
    assumptions_path = out_dir / "assumptions.json"
    with open(assumptions_path, "w", encoding="utf-8") as f:
        json.dump(model, f, indent=2)
    print(f"Wrote: {assumptions_path}")

def cmd_genre(args: argparse.Namespace) -> None:
    out_dir = Path(args.out).resolve()
    model = load_model(args.model_json)

    session = requests.Session()

    tag_id = args.tag_id
    if tag_id is None:
        tag_id = lookup_tag_id(session, args.genre)
        if tag_id is None:
            raise SystemExit(
                "Could not resolve a Steam tag id for that genre string.\n"
                "Try a different term, or provide --tag-id explicitly.\n"
                "Tip: Steam tags are often capitalized like 'Roguelike', 'City Builder', etc."
            )

    print(f"Genre '{args.genre}' resolved to tag_id={tag_id}")
    appids = discover_appids_by_tag(
        session,
        tag_id=int(tag_id),
        max_games=args.max_games,
        min_reviews=args.min_reviews,
    )
    if not appids:
        raise SystemExit(
            "No app IDs discovered for that tag. "
            "Try a different tag ID or genre term."
        )

    run_for_appids(
        appids=appids,
        genre_name_for_factor=args.genre,
        out_dir=out_dir,
        model=model,
        min_reviews=args.min_reviews,
        plots=args.plots,
        refresh_reviews=args.refresh_reviews,
    )


def cmd_appids(args: argparse.Namespace) -> None:
    out_dir = Path(args.out).resolve()
    model = load_model(args.model_json)
    appids = [
        int(value)
        for value in re.split(r"[,\s]+", args.appids.strip())
        if value.strip()
    ]
    if not appids:
        raise SystemExit("No appids provided.")
    run_for_appids(
        appids=appids,
        genre_name_for_factor=args.genre_for_factor or "unknown",
        out_dir=out_dir,
        model=model,
        min_reviews=args.min_reviews,
        plots=args.plots,
        refresh_reviews=args.refresh_reviews,
    )


def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(
        description=(
            "Estimate Steam sales over time from public review timestamps "
            "using a configurable Boxleiter-style model."
        )
    )
    p.add_argument(
        "--model-json",
        default=None,
        help="Optional JSON override of model parameters.",
    )
    sub = p.add_subparsers(dest="cmd", required=True)

    g = sub.add_parser(
        "genre",
        help="Discover games by Steam tag/genre, then estimate curves.",
    )
    g.add_argument(
        "--genre",
        required=True,
        help="Genre/tag term (e.g., 'Roguelike', 'City Builder').",
    )
    g.add_argument(
        "--tag-id",
        type=int,
        default=None,
        help="Optional Steam tag ID (bypass lookup).",
    )
    g.add_argument(
        "--min-reviews",
        type=int,
        default=200,
        help="Minimum total reviews to include a game.",
    )
    g.add_argument(
        "--max-games",
        type=int,
        default=50,
        help="Maximum games to process from discovery.",
    )
    g.add_argument("--out", required=True, help="Output directory.")
    g.add_argument("--plots", action="store_true", help="Generate plots (optional).")
    g.add_argument(
        "--refresh-reviews",
        action="store_true",
        help="Ignore cached reviews and refetch.",
    )
    g.set_defaults(func=cmd_genre)

    a = sub.add_parser(
        "appids",
        help="Estimate curves for a comma-separated list of Steam app IDs.",
    )
    a.add_argument("--appids", required=True, help="Comma/space-separated app IDs.")
    a.add_argument(
        "--genre-for-factor",
        default=None,
        help="Genre label used for the genre factor (optional).",
    )
    a.add_argument(
        "--min-reviews",
        type=int,
        default=0,
        help="Minimum total reviews to include a game.",
    )
    a.add_argument("--out", required=True, help="Output directory.")
    a.add_argument("--plots", action="store_true", help="Generate plots (optional).")
    a.add_argument(
        "--refresh-reviews",
        action="store_true",
        help="Ignore cached reviews and refetch.",
    )
    a.set_defaults(func=cmd_appids)

    return p


def main() -> int:
    args = build_parser().parse_args()
    args.func(args)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
