"""Public odds-api.net HTTP client and racing state. Python 3.10+."""
from __future__ import annotations

from copy import deepcopy
import json
import math
import re
import time
from urllib.parse import quote, urlencode, urlsplit

import requests

COUNTRIES = {"AU": "Australia", "NZ": "New Zealand", "GB": "Great Britain", "IE": "Ireland"}
RACE_TYPES = {"horse-racing": "Horse", "harness-racing": "Harness", "greyhound-racing": "Greyhound"}
UNAVAILABLE = {"suspended", "closed", "off", "unavailable", "removed", "scratched"}
FINISHED = {"finished", "closed", "settled", "abandoned", "cancelled", "resulted"}


class APIError(Exception):
    def __init__(self, message, status=502, retry_after=30):
        super().__init__(message)
        self.status = status
        self.retry_after = retry_after


def timestamp(value):
    try:
        number = float(value)
        return number / 1000 if number > 1e12 else number
    except (ValueError, TypeError):
        return 0


def scratched(runner):
    flag = str(runner.get("is_scratched", "")).lower()
    status = str(runner.get("runner_status") or "").lower().replace("-", "_").replace(" ", "_")
    return flag in {"true", "1", "yes"} or status in {"scratched", "withdrawn", "removed", "non_runner"} or timestamp(runner.get("removal_date")) > 0


def price(value):
    try:
        number = float(value)
        return number if math.isfinite(number) and number > 1 else None
    except (TypeError, ValueError):
        return None


def runner_key(runner):
    # Never merge different horses by display name, or by array position.
    return str(runner.get("runner_number") or "").strip()


class RacingAPI:
    demo = False

    def __init__(self, key, base_url="https://api.odds-api.net/v1"):
        parsed = urlsplit(base_url)
        if parsed.scheme != "https" or parsed.username or parsed.password or parsed.query or parsed.fragment:
            raise ValueError("ODDS_API_BASE_URL must be an HTTPS API base URL.")
        if not key:
            raise ValueError("Set ODDS_API_KEY, or run python app.py --demo.")
        self.key, self.base_url = key, base_url.rstrip("/")

    def open(self, path, params=None, stream=False):
        # Per-request sessions avoid sharing mutable requests.Session state across Flask threads.
        try:
            response = requests.get(self.base_url + path, params=params, headers={"X-API-Key": self.key, "Accept": "text/event-stream" if stream else "application/json"}, timeout=(5, 25), stream=stream, allow_redirects=False)
        except requests.RequestException:
            raise APIError("Cannot reach the Odds API. Check your connection.") from None
        if response.status_code != 200:
            code = response.status_code
            try:
                delay = max(5, min(int(response.headers.get("Retry-After", "30")), 300))
            except ValueError:
                delay = 30
            response.close()
            messages = {401: "API key rejected. Check ODDS_API_KEY.", 403: "This key does not have racing odds access. Check its plan and entitlements.", 404: "This race is no longer available.", 429: "API limit reached. Check monthly credits, request rate and stream allowance."}
            raise APIError(messages.get(code, "The Odds API is temporarily unavailable."), code if code in messages else 502, delay)
        return response

    def get(self, path, params=None):
        with self.open(path, params) as response:
            try:
                data = response.json()
            except ValueError:
                raise APIError("The Odds API returned invalid JSON.") from None
        if not isinstance(data, dict):
            raise APIError("The Odds API returned an unexpected response.")
        return data

    def events(self, country, race_type, now=None):
        now = int(time.time() if now is None else now)
        params = {"race_country": country, "race_type": race_type, "start_from": now, "start_to": now + 86400, "limit": 100, "include_links": "false"}
        found, seen_cursors = {}, set()
        for _ in range(20):
            data = self.get("/racing/events", params)
            for row in data.get("items", []):
                if not isinstance(row, dict):
                    continue
                if timestamp(row.get("race_start_time")) >= now and str(row.get("status", "")).lower() not in FINISHED and row.get("event_id"):
                    found[row["event_id"]] = row
            cursor = data.get("next_cursor")
            if not cursor or len(found) >= 40:
                break
            if cursor in seen_cursors:
                raise APIError("Race-list pagination repeated a cursor. Try again later.")
            seen_cursors.add(cursor)
            params["cursor"] = cursor
        else:
            raise APIError("The race list exceeded the example's pagination limit.")
        return sorted(found.values(), key=lambda r: (timestamp(r.get("race_start_time")), r["event_id"]))[:40]

    def detail(self, event_id):
        return self.get("/racing/events/" + quote(event_id, safe=""), {"include_raw_payload": "false"}).get("data", {})

    def snapshot(self, event_id):
        return self.get("/racing/events/" + quote(event_id, safe="") + "/odds", {"include_raw_payload": "false", "include_source": "true", "include_unavailable": "true"})

    def stream(self, event_id, resume):
        params = {"since": resume or "$", "catchup": "true", "heartbeat_sec": 10, "include_raw_payload": "false", "include_source": "true", "include_unavailable": "true"}
        with self.open("/racing/events/" + quote(event_id, safe="") + "/odds/stream", params, stream=True) as response:
            if "text/event-stream" not in response.headers.get("Content-Type", ""):
                raise APIError("The Odds API did not return an event stream.")
            yield from decode_sse(response.iter_lines(chunk_size=1, decode_unicode=True))


def decode_sse(lines):
    """Decode SSE records, including comments, CRLF and multiline JSON data."""
    event, data = "message", []
    size = 0
    for line in lines:
        if isinstance(line, bytes):
            line = line.decode("utf-8")
        line = line.rstrip("\r")
        if not line:
            if data:
                try:
                    payload = json.loads("\n".join(data))
                except ValueError:
                    raise APIError("Invalid stream data. Reconnect to reload a snapshot.") from None
                if not isinstance(payload, dict):
                    raise APIError("Unexpected stream data. Reconnect to reload a snapshot.")
                yield event, payload
            event, data, size = "message", [], 0
        elif not line.startswith(":"):
            field, _, value = line.partition(":")
            value = value[1:] if value.startswith(" ") else value
            if field == "event":
                event = value
            elif field == "data":
                data.append(value)
                size += len(value)
                if size > 2_000_000:
                    raise APIError("Stream message exceeded the example's size limit.")


class OddsState:
    def __init__(self, detail, snapshot):
        self.detail = detail
        self.books = {str(x["bookmaker_name"]): deepcopy(x) for x in snapshot.get("items", []) if isinstance(x, dict) and x.get("bookmaker_name")}
        self.resume = str(snapshot.get("resume") or "$")
        self.as_of = timestamp(snapshot.get("as_of_ts_ms"))
        self.ttl = snapshot.get("ttl_seconds")

    def apply(self, payload):
        # An upsert contains a complete bookmaker snapshot, not a runner-price patch.
        if not isinstance(payload.get("changes"), list):
            raise APIError("Unexpected delta. Reconnect to reload a snapshot.")
        for change in payload["changes"]:
            name = str(change.get("bookmaker_name") or "")
            if not name:
                raise APIError("Delta has no bookmaker. Reconnect to reload a snapshot.")
            if change.get("op") == "remove":
                self.books.pop(name, None)
            elif change.get("op") == "upsert" and isinstance(change.get("snapshot"), dict):
                self.books[name] = {**change["snapshot"], "bookmaker_name": name}
            else:
                raise APIError("Unknown delta operation. Reconnect to reload a snapshot.")
        self.resume = str(payload.get("resume") or self.resume)

    def view(self):
        card = self.detail.get("racecard") or {}
        runners = {}
        for runner in card.get("runners", []) if isinstance(card, dict) else []:
            if isinstance(runner, dict) and runner_key(runner):
                runners[runner_key(runner)] = {**runner, "prices": {}}
        for name, book in self.books.items():
            for runner in book.get("runners", []):
                key = runner_key(runner)
                if not key:
                    continue
                row = runners.setdefault(key, {**runner, "prices": {}})
                if scratched(runner):
                    row["is_scratched"] = True
                usable = str(book.get("status") or "").lower() not in UNAVAILABLE and not scratched(runner)
                row["prices"][name] = {"win": price(runner.get("win_odds")) if usable else None, "place": price(runner.get("place_odds")) if usable else None, "as_of": timestamp(book.get("updated_at_ts")) or self.as_of}
        for row in runners.values():
            row["scratched"] = scratched(row)
            if row["scratched"]:
                row["prices"] = {}
        def sort_key(row):
            match = re.match(r"\d+", runner_key(row))
            return (int(match.group()) if match else 9999, runner_key(row))
        # Explicit projection prevents upstream fields or links leaking into the browser.
        fields = ("runner_number", "runner_name", "jockey_name", "trainer_name", "scratched", "prices")
        return {"event_id": self.detail.get("event_id"), "bookmakers": sorted(self.books), "runners": [{k: row.get(k) for k in fields} for row in sorted(runners.values(), key=sort_key)], "field_complete": card.get("field_complete") is True if isinstance(card, dict) else False, "as_of": self.as_of, "ttl_seconds": self.ttl, "received_at": time.time()}


class DemoAPI:
    """Fictional names and prices, generated explicitly for the tutorial."""
    demo = True

    def __init__(self):
        self.start = int(time.time())

    def events(self, country, race_type, now=None):
        return [{"event_id": f"demo-{country}-{i}", "race_country": country, "race_type": race_type, "race_venue": venue, "race_number": str(i), "race_start_time": self.start + i * 420, "status": "open", "race_distance": "1200m"} for i, venue in enumerate(["Example Park", "Demo Valley", "Sample Downs"], 1)]

    def detail(self, event_id):
        return {"event_id": event_id, "racecard": {"field_complete": True, "runners": [{"runner_number": str(i), "runner_name": name, "jockey_name": "Demo Jockey", "trainer_name": "Demo Trainer", "is_scratched": i == 3} for i, name in enumerate(["Example Runner", "Sample Star", "Withdrawn Runner", "Unpriced Runner"], 1)]}}

    def snapshot(self, event_id):
        tick = int(time.time()) // 4
        return {"event_id": event_id, "resume": f"{tick}-0", "as_of_ts_ms": int(time.time() * 1000), "ttl_seconds": 60, "items": [{"bookmaker_name": name, "updated_at_ts": int(time.time()), "runners": [{"runner_number": "1", "runner_name": "Example Runner", "win_odds": round(3.4 + (tick % 3) * .05 + offset, 2), "place_odds": 1.65 + offset}, {"runner_number": "2", "runner_name": "Sample Star", "win_odds": 5.2 - offset, "place_odds": 2.05}]} for name, offset in [("example_book_a", 0), ("example_book_b", .1)]]}

    def stream(self, event_id, resume):
        for _ in range(15):
            time.sleep(4)
            snapshot = self.snapshot(event_id)
            yield "delta", {"event_id": event_id, "resume": snapshot["resume"], "changes": [{"op": "upsert", "bookmaker_name": row["bookmaker_name"], "snapshot": row} for row in snapshot["items"]]}
        yield "resync", {"reason": "demo_refresh"}
