from fastapi import FastAPI, APIRouter, HTTPException, Query, Depends, Header, Response
from dotenv import load_dotenv
from starlette.middleware.cors import CORSMiddleware
from motor.motor_asyncio import AsyncIOMotorClient
import os
import logging
from pathlib import Path
from pydantic import BaseModel, Field
from typing import List, Optional, Dict, Any
import uuid
from datetime import datetime, timedelta
import random
import httpx
import jwt as pyjwt
from passlib.context import CryptContext
import asyncio

ROOT_DIR = Path(__file__).parent
load_dotenv(ROOT_DIR / ".env")

# MongoDB connection
mongo_url = os.environ["MONGO_URL"]
client = AsyncIOMotorClient(mongo_url)
db = client[os.environ["DB_NAME"]]

# Create the main app
app = FastAPI()

# Create a router with the /api prefix
api_router = APIRouter(prefix="/api")

# Configure logging
logging.basicConfig(
    level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)

# ==================== JWT CONFIGURATION ====================

SECRET_KEY = os.environ.get("JWT_SECRET_KEY", "dev-secret-key-change-in-production")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7  # 7 days

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")


def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
    """Create a JWT access token with the given data payload."""
    to_encode = data.copy()
    expire = datetime.utcnow() + (
        expires_delta
        if expires_delta
        else timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    )
    to_encode.update({"exp": expire})
    return pyjwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)


def verify_password(plain_password: str, hashed_password: str) -> bool:
    """Verify a plain text password against a bcrypt hash."""
    return pwd_context.verify(plain_password, hashed_password)


def get_password_hash(password: str) -> str:
    """Hash a plain text password using bcrypt."""
    return pwd_context.hash(password)


# ==================== MODELS ====================


class Runner(BaseModel):
    """Generic runner - can be horse, greyhound, or harness horse"""

    id: str = Field(default_factory=lambda: str(uuid.uuid4()))
    number: int
    name: str
    rider: str  # Jockey for thoroughbred, Driver for harness, empty for greyhound
    trainer: str
    weight: Optional[float] = None  # Not applicable for greyhounds
    barrier: int  # Box number for greyhounds
    form: str  # e.g., "1-2-3-4" last 4 races
    win_probability: float = 0.0
    place_probability: float = 0.0
    badges: List[str] = []  # "Value", "Consistent", "Front-runner"


class Race(BaseModel):
    id: str = Field(default_factory=lambda: str(uuid.uuid4()))
    racing_type: str  # "thoroughbred", "harness", "greyhound"
    track: str
    race_number: int
    start_time: datetime
    distance: int  # meters
    race_class: str
    conditions: str
    prize_money: int
    runners: List[Runner] = []


class RaceListItem(BaseModel):
    id: str
    racing_type: str
    track: str
    race_number: int
    start_time: datetime
    distance: int
    race_class: str
    conditions: str
    runner_count: int


class TipReason(BaseModel):
    text: str
    type: str  # "positive", "neutral", "caution"


class Tip(BaseModel):
    id: str = Field(default_factory=lambda: str(uuid.uuid4()))
    race_id: str
    bet_type: str  # "win", "place", "best_bet", "quinella", "trifecta", "exacta"
    recommended_bet: str  # e.g., "WIN on #4 Silver Comet"
    confidence: str  # "low", "medium", "high"
    runners: List[Runner]
    reasons: List[TipReason]
    created_at: datetime = Field(default_factory=datetime.utcnow)
    race_info: Optional[Dict[str, Any]] = None


class TipCreate(BaseModel):
    race_id: str
    bet_type: str = "best_bet"


class SavedTip(BaseModel):
    id: str = Field(default_factory=lambda: str(uuid.uuid4()))
    user_id: str = "default_user"
    tip: Tip
    saved_at: datetime = Field(default_factory=datetime.utcnow)


class Schedule(BaseModel):
    id: str = Field(default_factory=lambda: str(uuid.uuid4()))
    user_id: str = "default_user"
    race_id: str
    bet_type: str
    minutes_before: int  # 5, 15, 30, 60
    channels: List[str]  # ["push", "sms", "email"]
    status: str = "active"  # "active", "sent", "cancelled", "failed"
    created_at: datetime = Field(default_factory=datetime.utcnow)
    scheduled_time: Optional[datetime] = None
    race_info: Optional[Dict[str, Any]] = None


class ScheduleCreate(BaseModel):
    race_id: str
    bet_type: str = "best_bet"
    minutes_before: int = 15
    channels: List[str] = ["push"]


class Notification(BaseModel):
    id: str = Field(default_factory=lambda: str(uuid.uuid4()))
    user_id: str = "default_user"
    type: str  # "tip_sent", "reminder", "error"
    title: str
    body: str
    to: Optional[str] = None
    race_id: Optional[str] = None
    schedule_id: Optional[str] = None
    channel: str = "push"
    status: str = "sent"  # "sent", "failed", "read"
    created_at: datetime = Field(default_factory=datetime.utcnow)


class UserPreferences(BaseModel):
    id: str = "default_user"
    default_bet_type: str = "best_bet"
    default_lead_time: int = 15
    favorite_tracks: List[str] = []
    notification_channels: Dict[str, bool] = {
        "push": True,
        "sms": False,
        "email": False,
    }
    quiet_hours_start: Optional[str] = None  # "22:00"
    quiet_hours_end: Optional[str] = None  # "07:00"
    onboarding_complete: bool = False
    # Race preferences
    preferred_racing_types: List[str] = (
        []
    )  # "thoroughbred", "harness", "greyhound" - empty means all
    preferred_race_classes: List[str] = []  # Empty means all classes
    preferred_distances: List[str] = []  # "sprint", "middle", "staying"
    preferred_conditions: List[str] = []  # "Good", "Soft", "Heavy", "Firm", "Synthetic"
    min_prize_money: Optional[int] = None  # Minimum prize money filter


class UserPreferencesUpdate(BaseModel):
    default_bet_type: Optional[str] = None
    default_lead_time: Optional[int] = None
    favorite_tracks: Optional[List[str]] = None
    notification_channels: Optional[Dict[str, bool]] = None
    quiet_hours_start: Optional[str] = None
    quiet_hours_end: Optional[str] = None
    onboarding_complete: Optional[bool] = None
    preferred_racing_types: Optional[List[str]] = None
    preferred_race_classes: Optional[List[str]] = None
    preferred_distances: Optional[List[str]] = None
    preferred_conditions: Optional[List[str]] = None
    min_prize_money: Optional[int] = None


# ==================== AUTH MODELS ====================


class UserRegister(BaseModel):
    email: str
    password: str
    name: Optional[str] = None


class UserLogin(BaseModel):
    email: str
    password: str


class UserResponse(BaseModel):
    id: str
    email: str
    name: Optional[str] = None
    created_at: datetime


class TokenResponse(BaseModel):
    access_token: str
    token_type: str = "bearer"


# ==================== NOTIFICATION MODELS ====================


class SendNotificationRequest(BaseModel):
    to: str
    subject: str
    body: str
    channel: str  # "sms"|"email"|"push"
    user_id: Optional[str] = None


class ScheduleNotificationRequest(BaseModel):
    to: str
    subject: str
    body: str
    channel: str
    scheduled_time: datetime
    race_id: Optional[str] = None


class NotificationResponse(BaseModel):
    id: str
    status: str  # "sent"|"failed"|"scheduled"
    channel: str
    created_at: datetime
    error: Optional[str] = None


# ==================== MOCK DATA GENERATION ====================

# Racing type specific data
RACING_TYPES = ["thoroughbred", "harness", "greyhound"]

# Thoroughbred tracks
THOROUGHBRED_TRACKS = [
    "Flemington",
    "Randwick",
    "Moonee Valley",
    "Caulfield",
    "Eagle Farm",
    "Rosehill",
    "Doomben",
    "Sandown",
    "Morphettville",
    "Ascot",
]

# Harness tracks
HARNESS_TRACKS = [
    "Menangle Park",
    "Tabcorp Park Melton",
    "Albion Park",
    "Gloucester Park",
    "Alexandra Park",
    "Hobart",
    "Launceston",
    "Bendigo",
    "Ballarat",
    "Cranbourne",
]

# Greyhound tracks
GREYHOUND_TRACKS = [
    "Wentworth Park",
    "The Meadows",
    "Sandown Park",
    "Albion Park",
    "Cannington",
    "Dapto",
    "Richmond",
    "Angle Park",
    "Ipswich",
    "Bendigo",
]

# Thoroughbred horse names
THOROUGHBRED_NAMES = [
    "Silver Comet",
    "Thunder Strike",
    "Golden Arrow",
    "Midnight Star",
    "Phoenix Rising",
    "Storm Chaser",
    "Lucky Fortune",
    "Wild Spirit",
    "Iron Will",
    "Shadow Dancer",
    "Blazing Glory",
    "Crystal Dream",
    "Noble Knight",
    "Speed Demon",
    "Royal Flash",
    "Ocean Breeze",
    "Mountain King",
    "Desert Rose",
    "Flying Eagle",
    "Black Diamond",
    "Red Baron",
    "White Lightning",
    "Blue Thunder",
    "Green Machine",
    "Purple Haze",
]

# Harness horse names (standardbreds)
HARNESS_NAMES = [
    "Lochinvar Art",
    "King Of Swing",
    "Copy That",
    "Self Assured",
    "Bettor's Delight",
    "American Ideal",
    "Tiger Tara",
    "Lazarus",
    "Christen Me",
    "Alta Orlando",
    "Spirit Of St Louis",
    "Poster Boy",
    "Better Eclipse",
    "Sky Major",
    "Tornado Valley",
    "Majestic Son",
    "Centurion ATM",
    "Captain Crunch",
    "Beach Music",
    "Mach Alert",
]

# Greyhound names
GREYHOUND_NAMES = [
    "Fernando Bale",
    "Wow She's Fast",
    "Tornado Tears",
    "Zambora Brockie",
    "Koblenz",
    "Orson Allen",
    "Simon Told Helen",
    "Shima Shine",
    "Fanta Bale",
    "Aston Rupee",
    "Good Odds Harada",
    "Dyna Patty",
    "Up Hill Jill",
    "Barcia Bale",
    "Raw Ability",
    "Beach Box",
    "Cosmic Rumble",
    "Flying Penske",
    "Jarick Bale",
    "Zipping Kyrgios",
]

# Jockeys (Thoroughbred)
JOCKEYS = [
    "J. McDonald",
    "D. Oliver",
    "H. Bowman",
    "C. Williams",
    "D. Lane",
    "J. Kah",
    "M. Zahra",
    "B. Melham",
    "L. Currie",
    "C. Newitt",
]

# Drivers (Harness)
DRIVERS = [
    "L. McCarthy",
    "D. Moran",
    "G. Dixon",
    "A. Herlihy",
    "C. Alford",
    "K. Gath",
    "J. Caldow",
    "N. Purdon",
    "D. Hancock",
    "M. Purdon",
]

# Trainers (all types share)
TRAINERS = [
    "C. Waller",
    "G. Waterhouse",
    "A. Cummings",
    "C. Maher",
    "D. Payne",
    "M. Price",
    "L. Freedman",
    "J. O'Brien",
    "P. Moody",
    "D. Hayes",
]

HARNESS_TRAINERS = [
    "E. Buter",
    "A. Herlihy",
    "B. Purdon",
    "M. Purdon",
    "C. Alford",
    "K. Manning",
    "A. Turnbull",
    "G. Sugars",
    "D. Aiken",
    "S. Alcorn",
]

GREYHOUND_TRAINERS = [
    "J. Britton",
    "A. Azzopardi",
    "R. Borda",
    "K. Greenough",
    "A. Lord",
    "M. Kavanagh",
    "C. Halse",
    "G. Hall",
    "D. Irwin",
    "J. Sanderson",
]

# Race classes by type
THOROUGHBRED_CLASSES = [
    "Group 1",
    "Group 2",
    "Group 3",
    "Listed",
    "Open",
    "Benchmark 88",
    "Benchmark 78",
    "Maiden",
]
HARNESS_CLASSES = [
    "Group 1",
    "Group 2",
    "Group 3",
    "Free For All",
    "Pacers",
    "Trotters",
    "C0-C1",
    "Maiden",
]
GREYHOUND_CLASSES = [
    "Group 1",
    "Group 2",
    "Group 3",
    "Free For All",
    "Grade 5",
    "Maiden",
    "Novice",
    "Mixed",
]

CONDITIONS = ["Good", "Soft", "Heavy", "Firm", "Synthetic"]


def generate_form():
    return "-".join([str(random.randint(1, 12)) for _ in range(4)])


def calculate_probabilities(runners: List[dict]) -> List[dict]:
    """Calculate win/place probabilities based on form and random factors"""
    total_score = 0
    scores = []

    for runner in runners:
        # Parse form - lower positions are better
        form_positions = [int(x) for x in runner["form"].split("-")]
        avg_position = sum(form_positions) / len(form_positions)

        # Base score (inverse of average position)
        base_score = max(0, 15 - avg_position) + random.uniform(0, 5)

        # Barrier factor (middle barriers slightly better)
        barrier = runner["barrier"]
        barrier_factor = 1.0 - abs(barrier - 6) * 0.03

        # Weight factor (lighter is better, only for horses)
        weight = runner.get("weight")
        weight_factor = 1.0
        if weight:
            weight_factor = 1.0 - (weight - 54) * 0.01

        score = base_score * barrier_factor * weight_factor
        scores.append(score)
        total_score += score

    # Normalize to probabilities
    for i, runner in enumerate(runners):
        win_prob = (scores[i] / total_score) * 100 if total_score > 0 else 10
        runner["win_probability"] = round(min(95, max(2, win_prob)), 1)
        # Place probability is roughly 2.5x win probability (for top 3)
        runner["place_probability"] = round(min(95, runner["win_probability"] * 2.5), 1)

        # Add badges based on performance
        runner["badges"] = []
        form_positions = [int(x) for x in runner["form"].split("-")]
        if all(p <= 3 for p in form_positions):
            runner["badges"].append("Consistent")
        if form_positions[0] == 1:
            runner["badges"].append("Last Start Winner")
        if runner["win_probability"] > 20 and random.random() > 0.5:
            runner["badges"].append("Value")
        if form_positions[0] <= 2 and form_positions[1] <= 3:
            runner["badges"].append("Front-runner")

    return runners


def generate_mock_runners(count: int, racing_type: str) -> List[dict]:
    """Generate mock runners for a race based on racing type"""

    if racing_type == "thoroughbred":
        names = THOROUGHBRED_NAMES
        riders = JOCKEYS
        trainers_list = TRAINERS
        has_weight = True
    elif racing_type == "harness":
        names = HARNESS_NAMES
        riders = DRIVERS
        trainers_list = HARNESS_TRAINERS
        has_weight = False
    else:  # greyhound
        names = GREYHOUND_NAMES
        riders = [""] * 10  # No rider for greyhounds
        trainers_list = GREYHOUND_TRAINERS
        has_weight = False

    available_names = random.sample(names, min(count, len(names)))
    runners = []

    for i in range(count):
        runner = {
            "id": str(uuid.uuid4()),
            "number": i + 1,
            "name": (
                available_names[i] if i < len(available_names) else f"Runner {i + 1}"
            ),
            "rider": random.choice(riders) if riders[0] else "",
            "trainer": random.choice(trainers_list),
            "weight": round(random.uniform(54, 60), 1) if has_weight else None,
            "barrier": i + 1,
            "form": generate_form(),
            "win_probability": 0,
            "place_probability": 0,
            "badges": [],
            "odds": round(random.uniform(1.5, 51.0), 1),  # Betting odds (1.5 to 51)
            "elo_rating": round(random.uniform(1200, 1800), 0),  # Elo-style rating
        }
        runners.append(runner)

    return calculate_probabilities(runners)


def generate_mock_races() -> List[dict]:
    """Generate upcoming mock races for the next 2 days for all racing types"""
    races = []
    now = datetime.utcnow()

    for racing_type in RACING_TYPES:
        if racing_type == "thoroughbred":
            tracks = THOROUGHBRED_TRACKS
            classes = THOROUGHBRED_CLASSES
            distances = [1000, 1200, 1400, 1600, 1800, 2000, 2400]
        elif racing_type == "harness":
            tracks = HARNESS_TRACKS
            classes = HARNESS_CLASSES
            distances = [
                1609,
                1730,
                2138,
                2240,
                2760,
            ]  # Common harness distances (miles/meters)
        else:  # greyhound
            tracks = GREYHOUND_TRACKS
            classes = GREYHOUND_CLASSES
            distances = [300, 400, 500, 600, 700, 732]  # Greyhound distances

        for day_offset in range(2):
            for track in random.sample(tracks, min(3, len(tracks))):
                # Generate 6-10 races per track
                num_races = random.randint(6, 10)
                base_time = now.replace(
                    hour=12, minute=0, second=0, microsecond=0
                ) + timedelta(days=day_offset)

                for race_num in range(1, num_races + 1):
                    start_time = base_time + timedelta(
                        minutes=race_num * 30 + random.randint(-5, 5)
                    )

                    # Only include future races
                    if start_time > now:
                        if racing_type == "greyhound":
                            runner_count = random.randint(6, 8)
                        else:
                            runner_count = random.randint(8, 14)

                        race = {
                            "id": str(uuid.uuid4()),
                            "racing_type": racing_type,
                            "track": track,
                            "race_number": race_num,
                            "start_time": start_time.isoformat(),
                            "distance": random.choice(distances),
                            "race_class": random.choice(classes),
                            "conditions": random.choice(CONDITIONS),
                            "prize_money": random.choice(
                                [25000, 50000, 75000, 100000, 150000, 250000, 500000]
                            ),
                            "runners": generate_mock_runners(runner_count, racing_type),
                        }
                        races.append(race)

    # Sort by start time
    races.sort(key=lambda x: x["start_time"])
    return races


# In-memory cache for mock data
_cached_races = None


def get_cached_races() -> List[dict]:
    """Return cached races, generating once at startup"""
    global _cached_races
    if _cached_races is None:
        _cached_races = generate_mock_races()
    return _cached_races


# ==================== ELO API CLIENT ====================


class EloApiClient:
    """HTTP client for the tipsharks-elo-api service."""

    def __init__(self, base_url: str | None = None):
        self.base_url = base_url or os.environ.get(
            "ELO_API_URL", "http://localhost:8000"
        )
        self._client = httpx.AsyncClient(timeout=10.0)

    async def get_races(self, limit: int = 50) -> list[dict]:
        response = await self._client.get(
            f"{self.base_url}/v1/races", params={"limit": limit}
        )
        response.raise_for_status()
        return response.json()

    async def get_race(self, race_id: str) -> dict:
        response = await self._client.get(f"{self.base_url}/v1/races/{race_id}")
        response.raise_for_status()
        return response.json()

    async def get_predictions(self, race_id: str | int) -> dict:
        response = await self._client.get(
            f"{self.base_url}/v1/races/{race_id}/predictions"
        )
        response.raise_for_status()
        return response.json()

    async def get_ratings(
        self, entity_type: str | None = None, limit: int = 100, offset: int = 0
    ) -> dict:
        """Get top ratings for horses, drivers, or trainers.

        Args:
            entity_type: One of "horses", "drivers", "trainers" or None for all.
            limit: Maximum results per type.
            offset: Pagination offset.
        """
        if entity_type is None:
            # Fetch all three types
            result = {"data": [], "meta": {}}
            for et in ("horses", "drivers", "trainers"):
                try:
                    resp = await self._client.get(
                        f"{self.base_url}/v1/ratings/{et}",
                        params={"limit": limit, "offset": offset},
                    )
                    if resp.status_code == 200:
                        data = resp.json()
                        result["data"].extend(data.get("data", []))
                except Exception:
                    continue
            return result

        response = await self._client.get(
            f"{self.base_url}/v1/ratings/{entity_type}",
            params={"limit": limit, "offset": offset},
        )
        response.raise_for_status()
        return response.json()

    async def get_entity_rating(self, entity_type: str, entity_id: int) -> dict:
        """Get rating for a specific entity (horse/driver/trainer)."""
        response = await self._client.get(
            f"{self.base_url}/v1/ratings/{entity_type}/{entity_id}"
        )
        response.raise_for_status()
        return response.json()

    async def get_upcoming_races(self, race_date: str | None = None) -> dict:
        """Get upcoming races from the Elo API."""
        params = {}
        if race_date:
            params["race_date"] = race_date
        response = await self._client.get(
            f"{self.base_url}/v1/races/upcoming", params=params
        )
        response.raise_for_status()
        return response.json()

    async def close(self):
        await self._client.aclose()


# ==================== NOTIFICATION PROVIDERS ====================


class TwilioClient:
    """SMS client using the Twilio API via raw HTTP."""

    def __init__(self):
        self.account_sid = os.environ.get("TWILIO_ACCOUNT_SID")
        self.auth_token = os.environ.get("TWILIO_AUTH_TOKEN")
        self.phone_number = os.environ.get("TWILIO_PHONE_NUMBER")

    @property
    def enabled(self) -> bool:
        """True when all Twilio credentials are present."""
        return all([self.account_sid, self.auth_token, self.phone_number])

    async def send_sms(self, to: str, body: str) -> bool:
        """Send an SMS via Twilio. Falls back to logging if not configured."""
        if not self.enabled:
            logger.info("[Twilio Fallback] SMS to %s (not configured): %s", to, body)
            return True

        try:
            async with httpx.AsyncClient() as client:
                response = await client.post(
                    f"https://api.twilio.com/2010-04-01/Accounts"
                    f"/{self.account_sid}/Messages.json",
                    data={"From": self.phone_number, "To": to, "Body": body},
                    auth=(self.account_sid, self.auth_token),
                    timeout=15.0,
                )
                response.raise_for_status()
                sid = response.json().get("sid", "unknown")
                logger.info("Twilio SMS sent to %s: sid=%s", to, sid)
                return True
        except Exception as e:
            logger.error("Twilio SMS failed to %s: %s", to, e)
            return False


class EmailClient:
    """Email client supporting SendGrid and Resend via raw HTTP."""

    def __init__(self):
        self.sendgrid_key = os.environ.get("SENDGRID_API_KEY")
        self.resend_key = os.environ.get("RESEND_API_KEY")
        self.from_email = os.environ.get("FROM_EMAIL", "notifications@tipsharks.com")

    async def send_email(
        self, to: str, subject: str, body: str, html: str | None = None
    ) -> bool:
        """Send email via SendGrid or Resend. Falls back to logging if neither configured."""
        if self.sendgrid_key:
            return await self._send_sendgrid(to, subject, body, html)
        if self.resend_key:
            return await self._send_resend(to, subject, body, html)

        logger.info("[Email Fallback] To: %s, Subject: %s, Body: %s", to, subject, body)
        return True

    async def _send_sendgrid(
        self, to: str, subject: str, body: str, html: str | None = None
    ) -> bool:
        try:
            payload = {
                "personalizations": [{"to": [{"email": to}]}],
                "from": {"email": self.from_email},
                "subject": subject,
                "content": [{"type": "text/plain", "value": body}],
            }
            if html:
                payload["content"].append({"type": "text/html", "value": html})

            async with httpx.AsyncClient() as client:
                response = await client.post(
                    "https://api.sendgrid.com/v3/mail/send",
                    json=payload,
                    headers={"Authorization": f"Bearer {self.sendgrid_key}"},
                    timeout=15.0,
                )
                response.raise_for_status()
                logger.info("SendGrid email sent to %s", to)
                return True
        except Exception as e:
            logger.error("SendGrid email failed to %s: %s", to, e)
            return False

    async def _send_resend(
        self, to: str, subject: str, body: str, html: str | None = None
    ) -> bool:
        try:
            payload = {
                "from": self.from_email,
                "to": [to],
                "subject": subject,
                "text": body,
            }
            if html:
                payload["html"] = html

            async with httpx.AsyncClient() as client:
                response = await client.post(
                    "https://api.resend.com/emails",
                    json=payload,
                    headers={"Authorization": f"Bearer {self.resend_key}"},
                    timeout=15.0,
                )
                response.raise_for_status()
                logger.info("Resend email sent to %s", to)
                return True
        except Exception as e:
            logger.error("Resend email failed to %s: %s", to, e)
            return False


# ==================== RATE LIMITING ====================

RATE_LIMITS: dict[str, int] = {
    "sms": 10,
    "email": 50,
    "push": 100,
}


async def check_rate_limit(user_id: str, channel: str) -> tuple[bool, int]:
    """Check if user has exceeded the rate limit for *channel*.

    Returns (allowed, remaining_count).
    """
    limit = RATE_LIMITS.get(channel)
    if limit is None:
        return True, 0

    hour_bucket = datetime.utcnow().strftime("%Y-%m-%d-%H")

    doc = await db.rate_limits.find_one(
        {"user_id": user_id, "channel": channel, "hour_bucket": hour_bucket}
    )
    count = doc["count"] if doc else 0
    remaining = max(0, limit - count)
    return count < limit, remaining


async def increment_rate_limit(user_id: str, channel: str) -> None:
    """Increment the rate-limit counter for *user_id* / *channel* in the current hour."""
    hour_bucket = datetime.utcnow().strftime("%Y-%m-%d-%H")
    await db.rate_limits.update_one(
        {"user_id": user_id, "channel": channel, "hour_bucket": hour_bucket},
        {"$inc": {"count": 1}},
        upsert=True,
    )


# ==================== RACE CACHING IN MONGODB ====================

CACHE_TTL_SECONDS = 300  # 5 minutes


def _transform_elo_race(elo_race: dict) -> dict:
    """Transform an elo-api race dict into the client-backend Race dict format."""
    meeting = elo_race.get("meeting", {}) or {}

    # Derive racing_type from available fields
    racing_type = (
        elo_race.get("racing_type")
        or meeting.get("racing_type")
        or meeting.get("category")
        or "thoroughbred"
    )

    # Derive track / venue
    track = (
        elo_race.get("track")
        or meeting.get("venue")
        or meeting.get("track")
        or meeting.get("name")
        or "Unknown"
    )

    # Transform runners / starters
    starters = elo_race.get("starters") or elo_race.get("runners") or []
    runners = []
    for starter in starters:
        form = starter.get("form", "")
        if not form:
            form = "-".join(str(random.randint(1, 12)) for _ in range(4))

        runner = {
            "id": starter.get("id", str(uuid.uuid4())),
            "number": starter.get("number", 0),
            "name": starter.get(
                "horse_name",
                starter.get("name", f"Runner {starter.get('number', 0)}"),
            ),
            "rider": starter.get("jockey") or starter.get("driver") or "",
            "trainer": starter.get("trainer", ""),
            "weight": starter.get("weight"),
            "barrier": starter.get("barrier") or starter.get("number", 0),
            "form": form,
            "win_probability": 0.0,
            "place_probability": 0.0,
            "badges": [],
        }
        runners.append(runner)

    if runners:
        runners = calculate_probabilities(runners)

    # Parse / format start_time consistently as ISO string
    raw_start = elo_race.get(
        "race_datetime",
        elo_race.get("start_time", datetime.utcnow().isoformat()),
    )
    if isinstance(raw_start, datetime):
        start_time = raw_start.isoformat()
    else:
        start_time = str(raw_start)

    return {
        "id": elo_race.get("id", str(uuid.uuid4())),
        "racing_type": racing_type,
        "track": track,
        "race_number": elo_race.get("race_number", 1),
        "start_time": start_time,
        "distance": elo_race.get("distance_m") or elo_race.get("distance", 1200),
        "race_class": elo_race.get("race_class") or elo_race.get("class", "Open"),
        "conditions": elo_race.get("track_condition")
        or elo_race.get("conditions", "Good"),
        "prize_money": elo_race.get("prize_money") or elo_race.get("stakes", 25000),
        "runners": runners,
    }


async def fetch_and_cache_races() -> List[dict]:
    """Fetch races from elo-api, transform, cache in MongoDB, and return.

    Falls back to mock data (cached in MongoDB) if elo-api is unavailable.
    """
    try:
        elo_client = EloApiClient()
        try:
            elo_races = await elo_client.get_races(limit=50)
        finally:
            await elo_client.close()

        # Handle both list and {races: [...]} / {data: [...]} responses
        if isinstance(elo_races, dict):
            elo_races = elo_races.get("races") or elo_races.get("data") or []

        if not elo_races:
            logger.warning("elo-api returned empty list; falling back to mock")
            return await _cache_mock_races()

        transformed = []
        for elo_race in elo_races:
            try:
                transformed.append(_transform_elo_race(elo_race))
            except Exception as exc:
                logger.warning(
                    "Failed to transform elo race %s: %s",
                    elo_race.get("id", "unknown"),
                    exc,
                )
                continue

        if not transformed:
            logger.warning("No elo races transformed; falling back to mock")
            return await _cache_mock_races()

        # Replace the cached race collection
        now_iso = datetime.utcnow().isoformat()
        for race in transformed:
            race["_cached_at"] = now_iso

        await db.races.delete_many({})
        await db.races.insert_many(transformed)

        logger.info("Cached %d races from elo-api", len(transformed))
        return transformed

    except httpx.RequestError as exc:
        logger.warning("elo-api connection failed (%s); falling back to mock", exc)
        return await _cache_mock_races()
    except Exception as exc:
        logger.error(
            "Unexpected error fetching from elo-api (%s); falling back to mock",
            exc,
        )
        return await _cache_mock_races()


async def _cache_mock_races() -> List[dict]:
    """Generate mock races, persist in MongoDB, and return them."""
    races = generate_mock_races()
    now_iso = datetime.utcnow().isoformat()
    for race in races:
        race["_cached_at"] = now_iso
    await db.races.delete_many({})
    await db.races.insert_many(races)
    logger.info("Cached %d mock races in MongoDB (elo-api unavailable)", len(races))
    return races


async def get_races_from_db(
    filters: dict[str, Any] | None = None,
) -> List[dict]:
    """Get races from MongoDB with optional filters."""
    query = filters or {}
    races_cursor = db.races.find(query).limit(200)
    races = await races_cursor.to_list(200)
    for race in races:
        race.pop("_id", None)
    return races


async def get_race_by_id(race_id: str) -> dict | None:
    """Look up a single race: DB → elo-api → mock fallback."""
    # 1. Try MongoDB
    race = await db.races.find_one({"id": race_id})
    if race:
        race.pop("_id", None)
        return race

    # 2. Try elo-api
    try:
        elo_client = EloApiClient()
        try:
            elo_race = await elo_client.get_race(race_id)
        finally:
            await elo_client.close()

        if elo_race:
            transformed = _transform_elo_race(elo_race)
            transformed["_cached_at"] = datetime.utcnow().isoformat()
            await db.races.replace_one({"id": race_id}, transformed, upsert=True)
            return transformed
    except Exception as exc:
        logger.warning("Failed to fetch race %s from elo-api: %s", race_id, exc)

    # 3. Fallback to in-memory mock
    races = get_cached_races()
    for r in races:
        if r["id"] == race_id:
            return r

    return None


# ==================== BACKGROUND SCHEDULER ====================

_scheduler_task: asyncio.Task | None = None
_scheduler_running = False


async def _notification_scheduler_loop():
    """Background loop that checks and sends scheduled notifications every 60s."""
    global _scheduler_running
    _scheduler_running = True
    logger.info("Notification scheduler started")

    while _scheduler_running:
        try:
            now = datetime.utcnow()
            due = await db.scheduled_notifications.find(
                {
                    "status": "scheduled",
                    "scheduled_time": {"$lte": now},
                }
            ).to_list(100)

            for notif in due:
                try:
                    channel = notif.get("channel", "push")
                    to = notif.get("to", "")
                    body = notif.get("body", "")
                    subject = notif.get("subject", "")

                    success = False
                    if channel == "sms":
                        twilio = TwilioClient()
                        success = await twilio.send_sms(to, body)
                    elif channel == "email":
                        email_client = EmailClient()
                        success = await email_client.send_email(to, subject, body)
                    else:  # push
                        logger.info("Scheduled push notification: %s", body)
                        success = True

                    new_status = "sent" if success else "failed"
                    error_field = None if success else "Provider returned an error"

                    await db.scheduled_notifications.update_one(
                        {"id": notif["id"]},
                        {"$set": {"status": new_status, "error": error_field}},
                    )

                    # Also persist to the notifications history
                    if success:
                        await db.notifications.insert_one(
                            {
                                "id": str(uuid.uuid4()),
                                "user_id": notif.get("user_id", "default_user"),
                                "type": "scheduled_tip",
                                "title": subject,
                                "body": body,
                                "to": to,
                                "channel": channel,
                                "status": "sent",
                                "created_at": datetime.utcnow(),
                            }
                        )
                except Exception as exc:
                    logger.error(
                        "Failed to process scheduled notification %s: %s",
                        notif.get("id", "unknown"),
                        exc,
                    )
                    await db.scheduled_notifications.update_one(
                        {"id": notif["id"]},
                        {"$set": {"status": "failed", "error": str(exc)}},
                    )
        except Exception as exc:
            logger.error("Notification scheduler loop error: %s", exc)

        await asyncio.sleep(60)


@app.on_event("startup")
async def initialize_data():
    """Fetch races from Elo API (with mock fallback) and start background scheduler.

    On startup, attempts to fetch race data from the tipsharks-elo-api service.
    Falls back to generated mock data if the Elo API is unreachable.
    The Elo API URL is configurable via the ``ELO_API_URL`` environment variable
    (default: ``http://localhost:8000``).
    """
    global _cached_races, _scheduler_task
    _cached_races = await fetch_and_cache_races()
    logger.info(f"Initialized {len(_cached_races)} races at startup")

    # Start the notification scheduler background task
    _scheduler_task = asyncio.create_task(_notification_scheduler_loop())


# ==================== TIP GENERATION ====================


def generate_tip_reasons(
    runners: List[dict], bet_type: str, selected_runner: dict, racing_type: str
) -> List[dict]:
    """Generate reasoning bullets for a tip"""
    reasons = []

    # Racing type specific terminology
    if racing_type == "thoroughbred":
        rider_term = "jockey"
    elif racing_type == "harness":
        rider_term = "driver"
    else:
        rider_term = None  # Greyhounds have no rider

    # Form analysis
    form_positions = [int(x) for x in selected_runner["form"].split("-")]
    if form_positions[0] <= 2:
        reasons.append(
            {
                "text": f"Strong recent form - placed {form_positions[0]}{'st' if form_positions[0] == 1 else 'nd'} last start",
                "type": "positive",
            }
        )

    # Probability ranking
    sorted_runners = sorted(runners, key=lambda x: x["win_probability"], reverse=True)
    rank = next(
        (
            i + 1
            for i, r in enumerate(sorted_runners)
            if r["id"] == selected_runner["id"]
        ),
        0,
    )
    if rank <= 3:
        reasons.append(
            {"text": f"Ranked #{rank} in our probability model", "type": "positive"}
        )

    # Badges
    if "Consistent" in selected_runner.get("badges", []):
        reasons.append({"text": "Consistently finishes in top 3", "type": "positive"})

    if "Value" in selected_runner.get("badges", []):
        reasons.append(
            {"text": "Good value based on current odds assessment", "type": "positive"}
        )

    # Rider analysis (only for horses)
    rider = selected_runner.get("rider", "")
    if rider and rider_term:
        if rider in [
            "J. McDonald",
            "D. Oliver",
            "H. Bowman",
            "L. McCarthy",
            "D. Moran",
        ]:
            reasons.append(
                {
                    "text": f"Top {rider_term} {rider} in the {'saddle' if racing_type == 'thoroughbred' else 'sulky'}",
                    "type": "positive",
                }
            )

    # Trainer analysis
    trainer = selected_runner.get("trainer", "")
    if trainer and trainer in ["C. Waller", "J. Britton", "E. Buter"]:
        reasons.append(
            {"text": f"Trained by {trainer} - proven track record", "type": "positive"}
        )

    # Greyhound specific - box draw
    if racing_type == "greyhound":
        barrier = selected_runner.get("barrier", 0)
        if barrier <= 2:
            reasons.append({"text": "Favorable inside box draw", "type": "positive"})

    # Caution for lower probability
    if selected_runner["win_probability"] < 15:
        reasons.append(
            {"text": "Higher risk pick - consider smaller stake", "type": "caution"}
        )

    return reasons[:5]  # Max 5 reasons


def generate_tip(race: dict, bet_type: str) -> dict:
    """Generate a tip for a race"""
    runners = race.get("runners", race.get("horses", []))
    racing_type = race.get("racing_type", "thoroughbred")

    # Sort runners by win probability
    sorted_runners = sorted(runners, key=lambda x: x["win_probability"], reverse=True)

    if bet_type in ["win", "best_bet"]:
        # Pick the highest probability runner
        selected = sorted_runners[0]
        confidence = (
            "high"
            if selected["win_probability"] > 25
            else "medium" if selected["win_probability"] > 15 else "low"
        )
        recommended_bet = f"WIN on #{selected['number']} {selected['name']}"

    elif bet_type == "place":
        # For place, we can be a bit more conservative
        selected = sorted_runners[0]
        confidence = (
            "high"
            if selected["place_probability"] > 50
            else "medium" if selected["place_probability"] > 35 else "low"
        )
        recommended_bet = f"PLACE on #{selected['number']} {selected['name']}"

    elif bet_type == "quinella":
        r1, r2 = sorted_runners[0], sorted_runners[1]
        selected = r1
        confidence = "medium"
        recommended_bet = (
            f"QUINELLA #{r1['number']} {r1['name']} & #{r2['number']} {r2['name']}"
        )

    elif bet_type == "exacta":
        r1, r2 = sorted_runners[0], sorted_runners[1]
        selected = r1
        confidence = "low"
        recommended_bet = (
            f"EXACTA #{r1['number']} {r1['name']} \u2192 #{r2['number']} {r2['name']}"
        )

    elif bet_type == "trifecta":
        r1, r2, r3 = sorted_runners[0], sorted_runners[1], sorted_runners[2]
        selected = r1
        confidence = "low"
        recommended_bet = (
            f"TRIFECTA #{r1['number']} / #{r2['number']} / #{r3['number']}"
        )
    else:
        # Default to best bet
        selected = sorted_runners[0]
        confidence = "high" if selected["win_probability"] > 25 else "medium"
        recommended_bet = f"BEST BET: WIN on #{selected['number']} {selected['name']}"

    reasons = generate_tip_reasons(runners, bet_type, selected, racing_type)

    return {
        "id": str(uuid.uuid4()),
        "race_id": race["id"],
        "bet_type": bet_type,
        "recommended_bet": recommended_bet,
        "confidence": confidence,
        "runners": runners,
        "racing_type": racing_type,
        "reasons": reasons,
        "created_at": datetime.utcnow().isoformat(),
        "race_info": {
            "track": race["track"],
            "race_number": race["race_number"],
            "start_time": race["start_time"],
            "distance": race["distance"],
            "race_class": race["race_class"],
            "conditions": race["conditions"],
            "racing_type": racing_type,
        },
    }


# ==================== DEPENDENCIES ====================


async def get_user_id(
    response: Response,
    authorization: Optional[str] = Header(None, alias="Authorization"),
    x_user_id: Optional[str] = Header(None, alias="X-User-ID"),
) -> str:
    """Extract a user ID from JWT Bearer token, X-User-ID header, or generate anonymous.

    Priority:
    1. Authorization: Bearer <token> — validates JWT, returns user_id from 'sub' claim
    2. X-User-ID header — returns as-is (anonymous/legacy)
    3. Generates a new anonymous ID and sets X-User-ID response header
    """
    # 1. Try JWT Bearer token
    if authorization and authorization.startswith("Bearer "):
        token = authorization[len("Bearer ") :]  # noqa: E203
        try:
            payload = pyjwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
            user_id: str = payload.get("sub")
            if user_id:
                return user_id
        except pyjwt.PyJWTError:
            logger.debug("Invalid JWT token, falling back to anonymous")

    # 2. Try X-User-ID header
    if x_user_id:
        return x_user_id

    # 3. Generate a new anonymous user ID for this session
    new_id = f"anon_{uuid.uuid4().hex[:12]}"
    response.headers["X-User-ID"] = new_id
    return new_id


# ==================== USER HELPERS ====================


async def get_user_by_email(email: str) -> dict | None:
    """Look up a user by email in the users collection."""
    user = await db.users.find_one({"email": email})
    if user:
        user["_id"] = str(user["_id"])
    return user


async def get_current_user_from_token(
    authorization: Optional[str] = Header(None, alias="Authorization"),
) -> dict | None:
    """Extract and return the current user from a JWT Bearer token, or None."""
    if not authorization or not authorization.startswith("Bearer "):
        return None
    token = authorization[len("Bearer ") :]  # noqa: E203
    try:
        payload = pyjwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        user_id: str = payload.get("sub")
        if user_id:
            user = await db.users.find_one({"id": user_id})
            if user:
                user.pop("_id", None)
                user.pop("password_hash", None)
                return user
    except pyjwt.PyJWTError:
        pass
    return None


# ==================== AUTH ENDPOINTS ====================


@api_router.post("/auth/register", response_model=TokenResponse)
async def register_user(user_data: UserRegister):
    """Register a new user and return a JWT token."""
    # Check if email already exists
    existing = await get_user_by_email(user_data.email)
    if existing:
        raise HTTPException(status_code=400, detail="Email already registered")

    # Create user
    user_id = str(uuid.uuid4())
    user_doc = {
        "id": user_id,
        "email": user_data.email,
        "password_hash": get_password_hash(user_data.password),
        "name": user_data.name,
        "created_at": datetime.utcnow(),
    }
    await db.users.insert_one(user_doc)

    # Return token
    token = create_access_token(data={"sub": user_id})
    return TokenResponse(access_token=token)


@api_router.post("/auth/login", response_model=TokenResponse)
async def login_user(login_data: UserLogin):
    """Authenticate a user and return a JWT token."""
    user = await get_user_by_email(login_data.email)
    if not user:
        raise HTTPException(status_code=401, detail="Invalid email or password")

    if not verify_password(login_data.password, user["password_hash"]):
        raise HTTPException(status_code=401, detail="Invalid email or password")

    token = create_access_token(data={"sub": user["id"]})
    return TokenResponse(access_token=token)


@api_router.get("/auth/me", response_model=UserResponse)
async def get_current_user_endpoint(
    current_user: dict | None = Depends(get_current_user_from_token),
):
    """Return the current authenticated user's profile."""
    if not current_user:
        raise HTTPException(status_code=401, detail="Not authenticated")
    return UserResponse(
        id=current_user["id"],
        email=current_user["email"],
        name=current_user.get("name"),
        created_at=current_user["created_at"],
    )


# ==================== API ENDPOINTS ====================


@api_router.get("/")
async def root():
    return {
        "message": "Racing Tips API",
        "version": "1.0.0",
        "racing_types": ["thoroughbred", "harness", "greyhound"],
    }


@api_router.get("/health")
async def health_check():
    return {"status": "healthy"}


# Race endpoints
@api_router.get("/races", response_model=List[RaceListItem])
async def get_races(
    date: Optional[str] = Query(None, description="Filter by date (today/tomorrow)"),
    track: Optional[str] = Query(None, description="Filter by track name"),
    racing_type: Optional[str] = Query(
        None, description="Filter by racing type (thoroughbred/harness/greyhound)"
    ),
    limit: int = Query(50, description="Limit results"),
):
    """Get list of upcoming races"""
    # Try to get races from MongoDB first
    races = await get_races_from_db()

    # If empty or stale, fetch (and cache) from elo-api or mock
    should_refetch = False
    if not races:
        should_refetch = True
    else:
        # Check staleness by looking at _cached_at on first result
        cached_at = races[0].get("_cached_at")
        if cached_at:
            try:
                cached_time = datetime.fromisoformat(cached_at)
                if (
                    datetime.utcnow() - cached_time
                ).total_seconds() > CACHE_TTL_SECONDS:
                    should_refetch = True
            except (ValueError, TypeError):
                should_refetch = True

    if should_refetch:
        races = await fetch_and_cache_races()

    # Apply filters
    now = datetime.utcnow()
    today = now.date()
    tomorrow = today + timedelta(days=1)

    if date == "today":
        races = [
            r for r in races if datetime.fromisoformat(r["start_time"]).date() == today
        ]
    elif date == "tomorrow":
        races = [
            r
            for r in races
            if datetime.fromisoformat(r["start_time"]).date() == tomorrow
        ]

    if track:
        races = [r for r in races if track.lower() in r["track"].lower()]

    if racing_type:
        races = [r for r in races if r["racing_type"] == racing_type.lower()]

    # Convert to list items
    result = []
    for r in races[:limit]:
        result.append(
            RaceListItem(
                id=r["id"],
                racing_type=r["racing_type"],
                track=r["track"],
                race_number=r["race_number"],
                start_time=datetime.fromisoformat(r["start_time"]),
                distance=r["distance"],
                race_class=r["race_class"],
                conditions=r["conditions"],
                runner_count=len(r["runners"]),
            )
        )

    return result


@api_router.get("/races/{race_id}")
async def get_race(race_id: str):
    """Get detailed race info including horses"""
    race = await get_race_by_id(race_id)
    if race:
        return race
    raise HTTPException(status_code=404, detail="Race not found")


@api_router.get("/races/next/upcoming")
async def get_next_race():
    """Get the next upcoming race"""
    # Try DB first
    races = await get_races_from_db()
    if not races:
        races = await fetch_and_cache_races()
    if races:
        return races[0]
    raise HTTPException(status_code=404, detail="No upcoming races")


# ==================== RATINGS ENDPOINTS (Elo API proxy) ====================


@api_router.get("/ratings")
async def get_ratings(
    entity_type: Optional[str] = Query(
        None, description="Entity type: horses, drivers, trainers (default: all)"
    ),
    limit: int = Query(100, description="Max results per type"),
):
    """Get top ratings from the Elo API.

    Proxies to the tipsharks-elo-api service. Falls back to mock/sample
    rating data if the Elo API is unreachable.

    Args:
        entity_type: One of ``horses``, ``drivers``, ``trainers``, or None for all.
        limit: Maximum results per entity type.
    """
    try:
        elo_client = EloApiClient()
        try:
            if entity_type:
                entity_type_path = {
                    "horses": "horses",
                    "drivers": "drivers",
                    "trainers": "trainers",
                }.get(entity_type.lower(), entity_type.lower())
                result = await elo_client.get_ratings(
                    entity_type=entity_type_path, limit=limit
                )
            else:
                result = await elo_client.get_ratings(limit=limit)
        finally:
            await elo_client.close()
        return result
    except httpx.RequestError as exc:
        logger.warning("Elo API unreachable for ratings (%s); returning mock", exc)
        return _generate_mock_ratings(entity_type, limit)
    except Exception as exc:
        logger.error("Unexpected error fetching ratings: %s", exc)
        return _generate_mock_ratings(entity_type, limit)


@api_router.get("/ratings/{entity_type}/{entity_id}")
async def get_entity_rating(
    entity_type: str,
    entity_id: int,
):
    """Get rating for a specific entity (horse/driver/trainer) from the Elo API.

    Falls back to a mock response if the Elo API is unreachable.

    Args:
        entity_type: ``horses``, ``drivers``, or ``trainers``.
        entity_id: The entity's numeric ID in the Elo system.
    """
    try:
        elo_client = EloApiClient()
        try:
            result = await elo_client.get_entity_rating(entity_type, entity_id)
        finally:
            await elo_client.close()
        return result
    except httpx.RequestError as exc:
        logger.warning(
            "Elo API unreachable for rating %s/%s (%s); returning mock",
            entity_type,
            entity_id,
            exc,
        )
        raise HTTPException(
            status_code=503,
            detail="Elo API unavailable and no mock data available for specific entity",
        )
    except HTTPException:
        raise
    except Exception as exc:
        logger.error(
            "Unexpected error fetching rating %s/%s: %s",
            entity_type,
            entity_id,
            exc,
        )
        raise HTTPException(
            status_code=503,
            detail="Elo API unavailable and no mock data available for specific entity",
        )


@api_router.get("/tips/{race_id}")
async def get_tips_for_race(race_id: str):
    """Get Elo-powered predictions/tips for a race from the Elo API.

    Proxies to ``GET /v1/races/{race_id}/predictions`` on the Elo API.
    Falls back to local tip generation using mock data if the Elo API is
    unreachable.

    Args:
        race_id: Race ID (string — will be parsed to int for the Elo API).
    """
    try:
        elo_client = EloApiClient()
        try:
            # Try as integer ID first (Elo API uses numeric race IDs)
            numeric_id = int(race_id)
            result = await elo_client.get_predictions(numeric_id)
        except (ValueError, TypeError):
            # Race ID is not numeric; fall back to local generation
            logger.info(
                "Race ID %s is not numeric; falling back to local tip generation",
                race_id,
            )
            raise ValueError("Non-numeric race ID")
        finally:
            await elo_client.close()

        # Transform Elo predictions response to the client tip format
        predictions = result.get("predictions", [])
        if not predictions:
            raise HTTPException(
                status_code=404, detail="No predictions available for this race"
            )

        return _transform_predictions_to_tip(result, race_id)
    except (httpx.RequestError, ValueError, TypeError) as exc:
        logger.warning(
            "Elo API unreachable for tips/race %s (%s); falling back to local tip generation",
            race_id,
            exc,
        )
        # Fall back to local tip generation
        race = await get_race_by_id(race_id)
        if not race:
            raise HTTPException(status_code=404, detail="Race not found")
        return generate_tip(race, "best_bet")


def _generate_mock_ratings(entity_type: Optional[str] = None, limit: int = 100) -> dict:
    """Generate mock rating data when the Elo API is unavailable."""
    mock_ratings = {
        "horses": [
            {
                "entity_type": "horse",
                "entity_id": 1,
                "entity_name": "Silver Comet",
                "rating": 1850.0,
                "rd": 85.0,
                "race_count": 24,
                "as_of_race_id": 100,
            },
            {
                "entity_type": "horse",
                "entity_id": 2,
                "entity_name": "Thunder Strike",
                "rating": 1820.0,
                "rd": 90.0,
                "race_count": 18,
                "as_of_race_id": 100,
            },
            {
                "entity_type": "horse",
                "entity_id": 3,
                "entity_name": "Golden Arrow",
                "rating": 1795.0,
                "rd": 78.0,
                "race_count": 32,
                "as_of_race_id": 100,
            },
        ],
        "drivers": [
            {
                "entity_type": "driver",
                "entity_id": 1,
                "entity_name": "L. McCarthy",
                "rating": 1920.0,
                "rd": 65.0,
                "race_count": 150,
                "as_of_race_id": 100,
            },
            {
                "entity_type": "driver",
                "entity_id": 2,
                "entity_name": "D. Moran",
                "rating": 1880.0,
                "rd": 72.0,
                "race_count": 120,
                "as_of_race_id": 100,
            },
            {
                "entity_type": "driver",
                "entity_id": 3,
                "entity_name": "G. Dixon",
                "rating": 1850.0,
                "rd": 80.0,
                "race_count": 95,
                "as_of_race_id": 100,
            },
        ],
        "trainers": [
            {
                "entity_type": "trainer",
                "entity_id": 1,
                "entity_name": "C. Waller",
                "rating": 1780.0,
                "rd": 95.0,
                "race_count": 200,
                "as_of_race_id": 100,
            },
            {
                "entity_type": "trainer",
                "entity_id": 2,
                "entity_name": "J. Britton",
                "rating": 1750.0,
                "rd": 88.0,
                "race_count": 180,
                "as_of_race_id": 100,
            },
            {
                "entity_type": "trainer",
                "entity_id": 3,
                "entity_name": "E. Buter",
                "rating": 1720.0,
                "rd": 92.0,
                "race_count": 160,
                "as_of_race_id": 100,
            },
        ],
    }

    if entity_type:
        et = entity_type.lower().rstrip("s") + "s"
        data = mock_ratings.get(et, [])
        return {
            "data": data[:limit],
            "meta": {"total": len(data), "limit": limit, "offset": 0},
        }
    else:
        all_data = []
        for et_data in mock_ratings.values():
            all_data.extend(et_data[:limit])
        return {
            "data": all_data,
            "meta": {"total": len(all_data), "limit": limit, "offset": 0},
        }


def _transform_predictions_to_tip(prediction_response: dict, race_id: str) -> dict:
    """Transform an Elo API predictions response into the client tip format."""
    predictions = prediction_response.get("predictions", [])
    if not predictions:
        raise HTTPException(status_code=404, detail="No predictions available")

    # Find the top predicted runner
    sorted_preds = sorted(
        predictions, key=lambda p: p.get("win_probability", 0), reverse=True
    )
    top_pred = sorted_preds[0]

    win_prob = top_pred.get("win_probability", 0) * 100  # Convert to percentage
    place_prob = top_pred.get("place_probability", 0) * 100

    confidence = "high" if win_prob > 25 else "medium" if win_prob > 15 else "low"

    runner = {
        "id": str(top_pred.get("horse_id", uuid.uuid4())),
        "number": top_pred.get("predicted_placing", 1),
        "name": top_pred.get("horse_name", f"Horse #{top_pred.get('horse_id', '?')}"),
        "rider": top_pred.get("driver_name", ""),
        "trainer": top_pred.get("trainer_name", ""),
        "weight": None,
        "barrier": top_pred.get("barrier", 0),
        "form": "",
        "win_probability": round(win_prob, 1),
        "place_probability": round(place_prob, 1),
        "badges": [],
    }

    reasons = [
        {"text": f"Elo win probability: {win_prob:.1f}%", "type": "positive"},
        {
            "text": f"Effective rating: {top_pred.get('effective_rating', 0):.0f}",
            "type": "positive",
        },
    ]
    if confidence == "high":
        reasons.append(
            {"text": "Strong confidence — top rated in the field", "type": "positive"}
        )

    return {
        "id": str(uuid.uuid4()),
        "race_id": race_id,
        "bet_type": "best_bet",
        "recommended_bet": f"WIN on #{runner['number']} {runner['name']}",
        "confidence": confidence,
        "runners": [
            {
                "id": str(p.get("horse_id", uuid.uuid4())),
                "number": p.get("predicted_placing", i + 1),
                "name": p.get("horse_name", f"Horse #{p.get('horse_id', '?')}"),
                "rider": p.get("driver_name", ""),
                "trainer": p.get("trainer_name", ""),
                "weight": None,
                "barrier": p.get("barrier", 0),
                "form": "",
                "win_probability": round(p.get("win_probability", 0) * 100, 1),
                "place_probability": round(p.get("place_probability", 0) * 100, 1),
                "badges": [],
            }
            for i, p in enumerate(sorted_preds)
        ],
        "reasons": reasons,
        "created_at": datetime.utcnow().isoformat(),
        "race_info": {
            "track": prediction_response.get("venue", "Unknown"),
            "race_number": prediction_response.get("race_number", 0),
            "start_time": datetime.utcnow().isoformat(),
            "distance": prediction_response.get("distance_m", 0),
            "race_class": "Open",
            "conditions": "Good",
            "racing_type": "harness",
        },
    }


# Tip endpoints
@api_router.post("/tips/generate")
async def generate_tip_endpoint(tip_request: TipCreate):
    """Generate a tip for a race"""
    race = await get_race_by_id(tip_request.race_id)
    if not race:
        raise HTTPException(status_code=404, detail="Race not found")

    tip = generate_tip(race, tip_request.bet_type)
    return tip


@api_router.post("/tips/save")
async def save_tip(tip_data: dict, user_id: str = Depends(get_user_id)):
    """Save a tip for later reference"""
    saved_tip = {
        "id": str(uuid.uuid4()),
        "user_id": user_id,
        "tip": tip_data,
        "saved_at": datetime.utcnow().isoformat(),
    }
    await db.saved_tips.insert_one(saved_tip.copy())
    return saved_tip


@api_router.get("/tips/saved")
async def get_saved_tips(user_id: str = Depends(get_user_id)):
    """Get user's saved tips"""
    tips = (
        await db.saved_tips.find({"user_id": user_id}).sort("saved_at", -1).to_list(100)
    )
    for tip in tips:
        tip["_id"] = str(tip["_id"])
    return tips


@api_router.delete("/tips/saved/{tip_id}")
async def delete_saved_tip(tip_id: str, user_id: str = Depends(get_user_id)):
    """Delete a saved tip"""
    result = await db.saved_tips.delete_one({"id": tip_id, "user_id": user_id})
    if result.deleted_count == 0:
        raise HTTPException(status_code=404, detail="Tip not found")
    return {"message": "Tip deleted"}


# Schedule endpoints
@api_router.post("/schedules")
async def create_schedule(
    schedule_data: ScheduleCreate, user_id: str = Depends(get_user_id)
):
    """Create a scheduled tip notification"""
    race = await get_race_by_id(schedule_data.race_id)

    if not race:
        raise HTTPException(status_code=404, detail="Race not found")

    race_start = datetime.fromisoformat(race["start_time"])
    scheduled_time = race_start - timedelta(minutes=schedule_data.minutes_before)

    schedule = {
        "id": str(uuid.uuid4()),
        "user_id": user_id,
        "race_id": schedule_data.race_id,
        "bet_type": schedule_data.bet_type,
        "minutes_before": schedule_data.minutes_before,
        "channels": schedule_data.channels,
        "status": "active",
        "created_at": datetime.utcnow().isoformat(),
        "scheduled_time": scheduled_time.isoformat(),
        "race_info": {
            "track": race["track"],
            "race_number": race["race_number"],
            "start_time": race["start_time"],
            "distance": race["distance"],
            "race_class": race["race_class"],
        },
    }

    await db.schedules.insert_one(schedule.copy())
    return schedule


@api_router.get("/schedules")
async def get_schedules(user_id: str = Depends(get_user_id)):
    """Get user's scheduled tips"""
    schedules = (
        await db.schedules.find({"user_id": user_id, "status": "active"})
        .sort("scheduled_time", 1)
        .to_list(100)
    )
    for s in schedules:
        s["_id"] = str(s["_id"])
    return schedules


@api_router.delete("/schedules/{schedule_id}")
async def cancel_schedule(schedule_id: str, user_id: str = Depends(get_user_id)):
    """Cancel a scheduled tip"""
    result = await db.schedules.update_one(
        {"id": schedule_id, "user_id": user_id}, {"$set": {"status": "cancelled"}}
    )
    if result.modified_count == 0:
        raise HTTPException(status_code=404, detail="Schedule not found")
    return {"message": "Schedule cancelled"}


# Notification endpoints
@api_router.get("/notifications")
async def get_notifications(user_id: str = Depends(get_user_id)):
    """Get user's notification history"""
    notifications = (
        await db.notifications.find({"user_id": user_id})
        .sort("created_at", -1)
        .to_list(100)
    )
    for n in notifications:
        n["_id"] = str(n["_id"])
    return notifications


@api_router.post("/notifications/mark-read/{notification_id}")
async def mark_notification_read(
    notification_id: str, user_id: str = Depends(get_user_id)
):
    """Mark a notification as read"""
    await db.notifications.update_one(
        {"id": notification_id, "user_id": user_id}, {"$set": {"status": "read"}}
    )
    return {"message": "Notification marked as read"}


@api_router.post("/notifications/send", response_model=NotificationResponse)
async def send_notification(
    request: SendNotificationRequest,
    user_id: str = Depends(get_user_id),
):
    """Send an immediate notification via the specified channel."""
    effective_user_id = request.user_id or user_id

    # Validate channel
    if request.channel not in ("sms", "email", "push"):
        raise HTTPException(
            status_code=400,
            detail=f"Unsupported channel '{request.channel}'. "
            f"Use one of: sms, email, push",
        )

    # Rate limiting check
    allowed, remaining = await check_rate_limit(effective_user_id, request.channel)
    if not allowed:
        raise HTTPException(
            status_code=429,
            detail=(
                f"Rate limit exceeded for {request.channel}. "
                f"Limit: {RATE_LIMITS.get(request.channel, '?')}/hour. "
                f"Try again later."
            ),
        )

    notification_id = str(uuid.uuid4())
    status = "sent"
    error_msg: str | None = None

    try:
        if request.channel == "sms":
            twilio = TwilioClient()
            success = await twilio.send_sms(request.to, request.body)
            if not success:
                status = "failed"
                error_msg = "SMS provider returned an error"
        elif request.channel == "email":
            email_client = EmailClient()
            success = await email_client.send_email(
                request.to, request.subject, request.body
            )
            if not success:
                status = "failed"
                error_msg = "Email provider returned an error"
        else:  # push
            logger.info(
                "Push notification for user %s: %s",
                effective_user_id,
                request.body,
            )
    except HTTPException:
        raise
    except Exception as exc:
        status = "failed"
        error_msg = str(exc)
        logger.error("Notification send failed: %s", exc)

    # Persist to notifications collection
    notification_doc = {
        "id": notification_id,
        "user_id": effective_user_id,
        "type": "sent",
        "title": request.subject,
        "body": request.body,
        "to": request.to,
        "channel": request.channel,
        "status": status,
        "error": error_msg,
        "created_at": datetime.utcnow(),
    }
    await db.notifications.insert_one(notification_doc)

    # Increment rate limit only on successful sends
    if status == "sent":
        await increment_rate_limit(effective_user_id, request.channel)

    return NotificationResponse(
        id=notification_id,
        status=status,
        channel=request.channel,
        created_at=notification_doc["created_at"],
        error=error_msg,
    )


@api_router.post("/notifications/schedule", response_model=NotificationResponse)
async def schedule_notification(
    request: ScheduleNotificationRequest,
    user_id: str = Depends(get_user_id),
):
    """Schedule a notification for later delivery."""
    if request.scheduled_time <= datetime.utcnow():
        raise HTTPException(
            status_code=400,
            detail="scheduled_time must be in the future",
        )

    notification_id = str(uuid.uuid4())

    scheduled_doc = {
        "id": notification_id,
        "user_id": user_id,
        "to": request.to,
        "subject": request.subject,
        "body": request.body,
        "channel": request.channel,
        "scheduled_time": request.scheduled_time,
        "race_id": request.race_id,
        "status": "scheduled",
        "created_at": datetime.utcnow(),
    }
    await db.scheduled_notifications.insert_one(scheduled_doc)

    return NotificationResponse(
        id=notification_id,
        status="scheduled",
        channel=request.channel,
        created_at=scheduled_doc["created_at"],
    )


@api_router.get("/notifications/providers")
async def get_notification_providers():
    """Return available notification providers based on environment configuration."""
    twilio_configured = all(
        [
            os.environ.get("TWILIO_ACCOUNT_SID"),
            os.environ.get("TWILIO_AUTH_TOKEN"),
            os.environ.get("TWILIO_PHONE_NUMBER"),
        ]
    )
    sendgrid_configured = bool(os.environ.get("SENDGRID_API_KEY"))
    resend_configured = bool(os.environ.get("RESEND_API_KEY"))

    return {
        "providers": {
            "sms": {
                "available": twilio_configured,
                "provider": "twilio" if twilio_configured else None,
            },
            "email": {
                "available": sendgrid_configured or resend_configured,
                "providers": {
                    "sendgrid": sendgrid_configured,
                    "resend": resend_configured,
                },
            },
            "push": {
                "available": True,
                "provider": "built-in",
            },
        }
    }


# User preferences endpoints
@api_router.get("/user/preferences")
async def get_preferences(user_id: str = Depends(get_user_id)):
    """Get user preferences"""
    prefs = await db.preferences.find_one({"id": user_id})
    if not prefs:
        # Return default preferences
        default_prefs = UserPreferences(id=user_id).model_dump()
        await db.preferences.insert_one(default_prefs)
        return UserPreferences(id=user_id)
    # Remove MongoDB _id and return as proper object
    prefs.pop("_id", None)
    return prefs


@api_router.put("/user/preferences")
async def update_preferences(
    updates: UserPreferencesUpdate, user_id: str = Depends(get_user_id)
):
    """Update user preferences"""
    update_dict = {k: v for k, v in updates.dict().items() if v is not None}

    if not update_dict:
        raise HTTPException(status_code=400, detail="No updates provided")

    await db.preferences.update_one({"id": user_id}, {"$set": update_dict}, upsert=True)

    prefs = await db.preferences.find_one({"id": user_id})
    if prefs:
        prefs.pop("_id", None)
        return prefs
    return UserPreferences(id=user_id)


@api_router.get("/tracks")
async def get_tracks(
    racing_type: Optional[str] = Query(None, description="Filter by racing type")
):
    """Get list of available tracks by racing type"""
    if racing_type == "thoroughbred":
        return {"tracks": THOROUGHBRED_TRACKS, "racing_type": "thoroughbred"}
    elif racing_type == "harness":
        return {"tracks": HARNESS_TRACKS, "racing_type": "harness"}
    elif racing_type == "greyhound":
        return {"tracks": GREYHOUND_TRACKS, "racing_type": "greyhound"}
    else:
        # Return all tracks grouped by type
        return {
            "all_tracks": THOROUGHBRED_TRACKS + HARNESS_TRACKS + GREYHOUND_TRACKS,
            "by_type": {
                "thoroughbred": THOROUGHBRED_TRACKS,
                "harness": HARNESS_TRACKS,
                "greyhound": GREYHOUND_TRACKS,
            },
        }


@api_router.get("/bet-types")
async def get_bet_types():
    """Get available bet types with descriptions"""
    return {
        "simple": [
            {
                "id": "best_bet",
                "name": "Best Bet",
                "description": "Our recommended pick based on all factors",
            },
            {"id": "win", "name": "Win", "description": "Runner must finish first"},
            {
                "id": "place",
                "name": "Place",
                "description": "Runner must finish in top 3",
            },
        ],
        "advanced": [
            {
                "id": "quinella",
                "name": "Quinella",
                "description": "Pick 2 runners to finish 1st and 2nd in any order",
            },
            {
                "id": "exacta",
                "name": "Exacta",
                "description": "Pick 2 runners in exact 1st and 2nd order",
            },
            {
                "id": "trifecta",
                "name": "Trifecta",
                "description": "Pick 3 runners in exact 1st, 2nd, 3rd order",
            },
        ],
    }


@api_router.get("/race-options")
async def get_race_options():
    """Get available race filter options including racing types"""
    return {
        "racing_types": [
            {
                "id": "thoroughbred",
                "name": "Thoroughbred",
                "description": "Traditional horse racing with jockeys",
                "icon": "horse",
            },
            {
                "id": "harness",
                "name": "Harness",
                "description": "Trotters and pacers with drivers",
                "icon": "car-sport",
            },
            {
                "id": "greyhound",
                "name": "Greyhound",
                "description": "Dog racing",
                "icon": "paw",
            },
        ],
        "race_classes": [
            {"id": "Group 1", "name": "Group 1", "description": "Highest level races"},
            {
                "id": "Group 2",
                "name": "Group 2",
                "description": "Second tier feature races",
            },
            {
                "id": "Group 3",
                "name": "Group 3",
                "description": "Third tier feature races",
            },
            {"id": "Listed", "name": "Listed", "description": "Listed feature races"},
            {"id": "Open", "name": "Open", "description": "Open handicap races"},
            {
                "id": "Free For All",
                "name": "Free For All",
                "description": "Open class - harness/greyhound",
            },
            {"id": "Maiden", "name": "Maiden", "description": "Yet to win a race"},
        ],
        "distances": [
            {
                "id": "sprint",
                "name": "Sprint",
                "description": "Short distance races",
                "range": [0, 1200],
            },
            {
                "id": "middle",
                "name": "Middle Distance",
                "description": "Mid-range races",
                "range": [1200, 1800],
            },
            {
                "id": "staying",
                "name": "Staying",
                "description": "Long distance races",
                "range": [1800, 9999],
            },
        ],
        "conditions": [
            {"id": "Good", "name": "Good", "description": "Firm, fast track"},
            {"id": "Soft", "name": "Soft", "description": "Rain-affected, yielding"},
            {"id": "Heavy", "name": "Heavy", "description": "Very wet track"},
            {"id": "Firm", "name": "Firm", "description": "Hard, dry track"},
            {
                "id": "Synthetic",
                "name": "Synthetic",
                "description": "All-weather track",
            },
        ],
    }


# Include the router in the main app
app.include_router(api_router)

app.add_middleware(
    CORSMiddleware,
    allow_credentials=True,
    allow_origins=["*"],
    allow_methods=["*"],
    allow_headers=["*"],
)


@app.on_event("shutdown")
async def shutdown_db_client():
    """Clean up MongoDB client and background scheduler on shutdown."""
    global _scheduler_running, _scheduler_task
    _scheduler_running = False
    if _scheduler_task is not None:
        _scheduler_task.cancel()
        try:
            await _scheduler_task
        except asyncio.CancelledError:
            pass
        _scheduler_task = None
        logger.info("Notification scheduler stopped")
    client.close()
