"""
Google Calendar Tool for negoT8.

Allows the scheduling negotiation agent to query a user's real Google Calendar
when they haven't specified available times in their message.

Flow:
  1. User runs /connectcalendar in Telegram.
  2. Bot sends the OAuth URL (get_oauth_url).
  3. User authorises in browser → Google redirects to /api/auth/google/callback.
  4. exchange_code() stores the token in the DB.
  5. get_free_slots() is called automatically by SchedulingFeature.get_context()
     whenever a scheduling negotiation starts without explicit times.
"""

import asyncio
import json
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo

import database as db
from config import (
    GOOGLE_CLIENT_ID,
    GOOGLE_CLIENT_SECRET,
    GOOGLE_REDIRECT_URI,
    GOOGLE_CALENDAR_SCOPES,
)

# ── Module-level PKCE verifier store ──────────────────────────────────────
# Maps telegram_id → code_verifier string generated during get_oauth_url().
# Entries are cleaned up after a successful or failed exchange.
_pending_verifiers: dict[int, str] = {}


def _build_flow():
    from google_auth_oauthlib.flow import Flow
    return Flow.from_client_config(
        {
            "web": {
                "client_id": GOOGLE_CLIENT_ID,
                "client_secret": GOOGLE_CLIENT_SECRET,
                "redirect_uris": [GOOGLE_REDIRECT_URI],
                "auth_uri": "https://accounts.google.com/o/oauth2/auth",
                "token_uri": "https://oauth2.googleapis.com/token",
            }
        },
        scopes=GOOGLE_CALENDAR_SCOPES,
        redirect_uri=GOOGLE_REDIRECT_URI,
    )


class GoogleCalendarTool:
    name = "google_calendar"

    # ── OAuth helpers ────────────────────────────────────────────────────────

    async def is_connected(self, user_id: int) -> bool:
        """Check whether the user has a stored OAuth token."""
        token_json = await db.get_calendar_token(user_id)
        return token_json is not None

    async def get_oauth_url(self, user_id: int) -> str:
        """
        Build the Google OAuth2 authorisation URL manually — no PKCE.
        Building it by hand avoids the library silently injecting a
        code_challenge that we can't recover during token exchange.
        """
        from urllib.parse import urlencode
        params = {
            "client_id": GOOGLE_CLIENT_ID,
            "redirect_uri": GOOGLE_REDIRECT_URI,
            "response_type": "code",
            "scope": " ".join(GOOGLE_CALENDAR_SCOPES),
            "access_type": "offline",
            "prompt": "consent",
            "state": str(user_id),
        }
        return "https://accounts.google.com/o/oauth2/auth?" + urlencode(params)

    async def exchange_code(self, user_id: int, code: str) -> bool:
        """
        Exchange the OAuth `code` (received in the callback) for credentials
        and persist them in the DB.  Returns True on success.

        Uses the stored PKCE code_verifier (if any) so Google doesn't reject
        the exchange with 'Missing code verifier'.
        """
        try:
            import httpx

            # ── Manual token exchange — avoids PKCE state mismatch ──────────
            # We post directly to Google's token endpoint so we're not
            # dependent on a Flow instance having the right code_verifier.
            verifier = _pending_verifiers.pop(user_id, None)

            post_data: dict = {
                "code": code,
                "client_id": GOOGLE_CLIENT_ID,
                "client_secret": GOOGLE_CLIENT_SECRET,
                "redirect_uri": GOOGLE_REDIRECT_URI,
                "grant_type": "authorization_code",
            }
            if verifier:
                post_data["code_verifier"] = verifier

            async with httpx.AsyncClient() as client:
                resp = await client.post(
                    "https://oauth2.googleapis.com/token",
                    data=post_data,
                    timeout=15.0,
                )

            if resp.status_code != 200:
                print(f"[GoogleCalendar] token exchange HTTP {resp.status_code}: {resp.text}")
                return False

            token_data = resp.json()

            # Build a Credentials-compatible JSON that google-auth can reload
            import datetime as _dt
            expires_in = token_data.get("expires_in", 3600)
            expiry = (
                _dt.datetime.utcnow() + _dt.timedelta(seconds=expires_in)
            ).isoformat() + "Z"

            creds_json = json.dumps({
                "token": token_data["access_token"],
                "refresh_token": token_data.get("refresh_token"),
                "token_uri": "https://oauth2.googleapis.com/token",
                "client_id": GOOGLE_CLIENT_ID,
                "client_secret": GOOGLE_CLIENT_SECRET,
                "scopes": GOOGLE_CALENDAR_SCOPES,
                "expiry": expiry,
            })

            await db.save_calendar_token(user_id, creds_json)
            return True

        except Exception as e:
            _pending_verifiers.pop(user_id, None)   # clean up on failure
            print(f"[GoogleCalendar] exchange_code failed for user {user_id}: {e}")
            return False

    # ── Main tool: free slot discovery ──────────────────────────────────────

    async def get_free_slots(
        self,
        user_id: int,
        days_ahead: int = 7,
        duration_minutes: int = 60,
        timezone_str: str = "Asia/Kolkata",
    ) -> list[str]:
        """
        Return up to 6 free time slots for the user over the next `days_ahead`
        days, each at least `duration_minutes` long.

        Slots are within business hours (9 AM – 7 PM local time) and exclude
        any existing calendar events (busy intervals from the freeBusy API).

        Returns [] if the user hasn't connected their calendar, or on any
        API / network error — never raises so negotiations always continue.
        """
        token_json = await db.get_calendar_token(user_id)
        if not token_json:
            return []   # user hasn't connected calendar

        try:
            # Run the synchronous Google API calls in a thread pool so we
            # don't block the async event loop.
            return await asyncio.get_event_loop().run_in_executor(
                None,
                self._sync_get_free_slots,
                token_json,
                user_id,
                days_ahead,
                duration_minutes,
                timezone_str,
            )
        except Exception as e:
            print(f"[GoogleCalendar] get_free_slots failed for user {user_id}: {e}")
            return []

    def _sync_get_free_slots(
        self,
        token_json: str,
        user_id: int,
        days_ahead: int,
        duration_minutes: int,
        timezone_str: str,
    ) -> list[str]:
        """Synchronous implementation (runs in executor)."""
        import asyncio

        from google.auth.transport.requests import Request
        from google.oauth2.credentials import Credentials
        from googleapiclient.discovery import build

        # ── Load & refresh credentials ───────────────────────────────────
        creds = Credentials.from_authorized_user_info(
            json.loads(token_json), GOOGLE_CALENDAR_SCOPES
        )
        if creds.expired and creds.refresh_token:
            creds.refresh(Request())
            # Persist refreshed token (fire-and-forget via a new event loop)
            try:
                loop = asyncio.new_event_loop()
                loop.run_until_complete(
                    db.save_calendar_token(user_id, creds.to_json())
                )
                loop.close()
            except Exception:
                pass  # non-critical

        service = build("calendar", "v3", credentials=creds, cache_discovery=False)

        tz = ZoneInfo(timezone_str)
        now = datetime.now(tz)
        # Start from the next whole hour
        query_start = now.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)
        query_end = query_start + timedelta(days=days_ahead)

        time_min = query_start.astimezone(timezone.utc).isoformat()
        time_max = query_end.astimezone(timezone.utc).isoformat()

        # ── freeBusy query ───────────────────────────────────────────────
        body = {
            "timeMin": time_min,
            "timeMax": time_max,
            "timeZone": timezone_str,
            "items": [{"id": "primary"}],
        }
        result = service.freebusy().query(body=body).execute()
        busy_intervals = result.get("calendars", {}).get("primary", {}).get("busy", [])

        # Convert busy intervals to (start, end) datetime pairs
        busy = []
        for interval in busy_intervals:
            b_start = datetime.fromisoformat(interval["start"]).astimezone(tz)
            b_end = datetime.fromisoformat(interval["end"]).astimezone(tz)
            busy.append((b_start, b_end))

        # ── Find free slots in 9 AM – 7 PM business hours ───────────────
        slot_duration = timedelta(minutes=duration_minutes)
        free_slots: list[str] = []

        cursor = query_start
        while cursor < query_end and len(free_slots) < 6:
            # Jump to business hours start if before 9 AM
            day_start = cursor.replace(hour=9, minute=0, second=0, microsecond=0)
            day_end = cursor.replace(hour=19, minute=0, second=0, microsecond=0)

            if cursor < day_start:
                cursor = day_start
            if cursor >= day_end:
                # Move to next day 9 AM
                cursor = (cursor + timedelta(days=1)).replace(
                    hour=9, minute=0, second=0, microsecond=0
                )
                continue

            slot_end = cursor + slot_duration
            if slot_end > day_end:
                cursor = (cursor + timedelta(days=1)).replace(
                    hour=9, minute=0, second=0, microsecond=0
                )
                continue

            # Check for conflict with any busy interval
            conflict = any(
                not (slot_end <= b[0] or cursor >= b[1]) for b in busy
            )
            if not conflict:
                label = cursor.strftime("%a %b %-d %-I:%M") + "–" + slot_end.strftime("%-I:%M %p")
                free_slots.append(label)
                cursor = slot_end   # advance by one slot (non-overlapping)
            else:
                cursor += timedelta(minutes=30)  # try next 30-min block

        return free_slots
