#!/usr/bin/env python3
"""Join PrizePicks lines to rows from a broad odds feed, and show what did not join.

Python 3.9+. Standard library only. Runs offline on the bundled samples, against the
keyless demo, or with a key on your server. Bounded: at most --pages requests, one page by
default, never more than 5. It makes no request to any other provider: export your feed's
rows to a file, or adapt normalize_feed_row and read them your own way.

There is no shared identifier between this API and another feed, so the join is on league,
event start, normalized player name, mapped stat and side. Anything ambiguous stays unmatched.

  python3 pair-with-odds-feed.py --lines-file samples/projections.demo.json --odds-file samples/broad-odds-feed.sample.json
  python3 pair-with-odds-feed.py --demo --odds-file samples/broad-odds-feed.sample.json
  PRIZEPICKS_API_KEY=... python3 pair-with-odds-feed.py --league NBA --odds-file my-feed.json
"""
import argparse
import json
import os
import re
import sys
import unicodedata
from collections import Counter
from datetime import datetime, timezone
from urllib.error import HTTPError
from urllib.parse import urlencode
from urllib.request import Request, urlopen

ORIGIN = "https://prizepicksapi.com"
PURPOSE = "Join PrizePicks lines to a broad odds feed for a comparison check"
MAX_PAGES = 5
MAX_ROWS = 5000
START_WINDOW_SECONDS = 15 * 60

# Extend from your provider's market list. An unmapped market stays unmatched rather than guessed.
FEED_MARKET_TO_STAT = {
    "player points": "Points",
    "player rebounds": "Rebounds",
    "player assists": "Assists",
    "player threes": "3PT Made",
}


def normalize_name(name):
    """Lowercase, strip accents, punctuation and suffixes. Never expands initials or nicknames."""
    plain = unicodedata.normalize("NFKD", name or "").encode("ascii", "ignore").decode().lower()
    plain = re.sub(r"[^a-z ]", " ", plain)
    words = [w for w in plain.split() if w not in {"jr", "sr", "ii", "iii", "iv"}]
    return " ".join(words)


def instant(value):
    if not value:
        return None
    try:
        parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
    except ValueError:
        return None
    return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)


def normalize_line(row):
    """Accepts a full or a compact projection row."""
    player = row.get("player")
    full = isinstance(player, dict)
    selections = row.get("selections") or []
    return {
        "id": row["id"],
        "league": row.get("league"),
        "player": player.get("name") if full else player,
        "stat": row["market"]["stat"] if full else row.get("stat"),
        "period": row["market"].get("period") if full else row.get("period"),
        "line": row.get("line"),
        "starts_at": row["event"].get("starts_at") if full else row.get("starts_at"),
        # Per side on the full view; the compact view carries the more side's tier.
        "tier": selections[0].get("tier") if full and selections else row.get("tier", "unknown"),
        "observed_at": row.get("observed_at"),
    }


def normalize_feed_row(row):
    """Adapt this one function to your provider's field names."""
    return {
        "league": row.get("league"),
        "player": row.get("player_name"),
        "stat": FEED_MARKET_TO_STAT.get(str(row.get("market", "")).strip().lower()),
        "market": row.get("market"),
        "side": row.get("selection_line"),
        "line": row.get("points"),
        "price": row.get("price"),
        "book": row.get("sportsbook"),
        "fixture": row.get("fixture_id"),
        "starts_at": row.get("start_date"),
        "observed_at": row.get("timestamp"),
    }


def load_rows(path):
    with open(path) as file:
        body = json.load(file)
    rows = body if isinstance(body, list) else body.get("rows", body.get("data", []))
    if len(rows) > MAX_ROWS:
        raise SystemExit(f"{path} has {len(rows)} rows; this example stops at {MAX_ROWS}.")
    return rows


def fetch_lines(args):
    """Reads at most --pages pages. A demo read sends no key; a live read sends the key in a header only."""
    key = None if args.demo else os.environ.get("PRIZEPICKS_API_KEY")
    if not args.demo and not key:
        raise SystemExit("Set PRIZEPICKS_API_KEY, or pass --demo for fictional rows with no key.")
    origin = args.origin if args.demo else ORIGIN
    path = "/api/v1/demo/projections" if args.demo else "/api/v1/projections"
    headers = {"Accept": "application/json"}
    if key:
        headers.update({"Authorization": "Bearer " + key, "X-User-Request": PURPOSE})
    rows, cursor, source_status = [], None, None
    for _ in range(min(args.pages, MAX_PAGES)):
        params = {"league": args.league, "limit": 100, "view": "compact"}
        if cursor:
            params["cursor"] = cursor
        request = Request(origin + path + "?" + urlencode(params), headers=headers)
        try:
            with urlopen(request, timeout=20) as response:
                page = json.load(response)
        except HTTPError as error:
            detail = error.read().decode("utf-8", errors="replace")[:500]
            raise SystemExit(f"HTTP {error.code}: {detail}") from None
        rows.extend(page["data"])
        source_status = page["meta"].get("source_status")
        cursor = page["meta"].get("next_cursor")
        if not cursor or page["meta"].get("plan_limit"):
            break
    if cursor:
        print(f"Partial board: stopped after {args.pages} page(s) with more rows available.", file=sys.stderr)
    return rows, source_status


def join(lines, feed_rows):
    """One output row per PrizePicks line. A feed row joins only when exactly one event fits."""
    by_key = {}
    unmapped = Counter()
    for row in feed_rows:
        if not row["stat"]:
            unmapped[str(row["market"])] += 1
            continue
        by_key.setdefault((row["league"], normalize_name(row["player"]), row["stat"], row["side"]), []).append(row)

    output = []
    for line in lines:
        candidates = by_key.get((line["league"], normalize_name(line["player"]), line["stat"], "over"), [])
        start = instant(line["starts_at"])
        fits = [c for c in candidates if start and instant(c["starts_at"]) and abs((instant(c["starts_at"]) - start).total_seconds()) <= START_WINDOW_SECONDS]
        fixtures = {c["fixture"] for c in fits}
        if not fits:
            status = "unmatched"
        elif len(fixtures) > 1:
            status = "ambiguous"
        elif any(c["line"] == line["line"] for c in fits):
            status = "same_line"
        else:
            status = "different_line"
        quotes = [] if status in {"unmatched", "ambiguous"} else [
            {"book": c["book"], "line": c["line"], "side": c["side"], "price": c["price"], "observed_at": c["observed_at"]}
            for c in fits
        ]
        output.append({"status": status, "projection_id": line["id"], "player": line["player"], "stat": line["stat"], "line": line["line"], "tier": line["tier"], "line_observed_at": line["observed_at"], "quotes": quotes})
    return output, unmapped


def main(argv=None):
    parser = argparse.ArgumentParser(description="Join PrizePicks lines to a broad odds feed, bounded and honest about misses.")
    parser.add_argument("--odds-file", required=True, help="JSON rows exported from your broad odds feed.")
    parser.add_argument("--lines-file", help="Saved API response, for a fully offline run.")
    parser.add_argument("--demo", action="store_true", help="Read fictional rows from the keyless demo.")
    parser.add_argument("--origin", default=ORIGIN, help="Demo mode only. Live reads always use prizepicksapi.com.")
    parser.add_argument("--league", default="NBA")
    parser.add_argument("--pages", type=int, default=1, help=f"Pages to read, at most {MAX_PAGES}.")
    args = parser.parse_args(argv)
    if args.pages < 1:
        parser.error("--pages must be at least 1")

    if args.lines_file:
        raw, source_status = load_rows(args.lines_file), "file"
    else:
        raw, source_status = fetch_lines(args)
    lines = [line for line in (normalize_line(row) for row in raw) if line["league"] == args.league]
    feed = [normalize_feed_row(row) for row in load_rows(args.odds_file)]
    output, unmapped = join(lines, feed)
    for row in output:
        print(json.dumps(row))

    counts = Counter(row["status"] for row in output)
    summary = {"lines_read": len(lines), "feed_rows_read": len(feed), "source_status": source_status, **{k: counts.get(k, 0) for k in ["same_line", "different_line", "ambiguous", "unmatched"]}, "unmapped_feed_markets": dict(unmapped)}
    print(json.dumps(summary), file=sys.stderr)
    print("Prices and lines were observed at different times. Read both timestamps before comparing them.", file=sys.stderr)


if __name__ == "__main__":
    main()
