from __future__ import annotations

from datetime import datetime
import json
import time
from typing import Any, Dict, List, Optional

from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError

from .. import settings
from ..services.google_sheets_oauth import GOOGLE_SHEETS_SCOPE
from ..services.google_rate_limiter import DEFAULT_RPM_LIMIT, acquire as acquire_rate_limit


class DirectSheetsWriterError(Exception):
    pass


class RateLimitedError(DirectSheetsWriterError):
    pass


class SheetNotFoundError(DirectSheetsWriterError):
    pass


class TransientGoogleSheetsError(DirectSheetsWriterError):
    pass


def _column_letter(idx: int) -> str:
    letters = ""
    while idx > 0:
        idx, remainder = divmod(idx - 1, 26)
        letters = chr(65 + remainder) + letters
    return letters or "A"


def _quote_sheet_name(name: str) -> str:
    safe = str(name or "").replace("'", "''")
    return f"'{safe}'"


def _http_status(err: HttpError) -> Optional[int]:
    if hasattr(err, "status_code") and err.status_code:
        return int(err.status_code)
    resp = getattr(err, "resp", None)
    if resp is not None and getattr(resp, "status", None) is not None:
        return int(resp.status)
    return None


def _http_message(err: HttpError) -> str:
    content = ""
    raw = getattr(err, "content", None)
    if raw:
        try:
            content = raw.decode("utf-8")
        except Exception:
            content = str(raw)
    if content:
        try:
            parsed = json.loads(content)
            if isinstance(parsed, dict):
                payload = parsed.get("error") if isinstance(parsed.get("error"), dict) else parsed
                message = payload.get("message") if isinstance(payload, dict) else None
                if message:
                    return str(message)
        except Exception:
            pass
    return str(err)


def _is_sheet_not_found(err: HttpError) -> bool:
    status = _http_status(err)
    if status not in (400, 404):
        return False
    message = _http_message(err).lower()
    return (
        "sheet not found" in message
        or "unable to parse range" in message
        or "not found: sheet" in message
    )


def _is_retryable_http_status(status: Optional[int]) -> bool:
    return status in {408, 409, 425, 429, 500, 502, 503, 504}


def _is_retryable_http_error(err: HttpError) -> bool:
    status = _http_status(err)
    if _is_retryable_http_status(status):
        return True
    message = _http_message(err).lower()
    return any(
        marker in message
        for marker in (
            "backend error",
            "connection reset",
            "deadline exceeded",
            "internal error",
            "service unavailable",
            "temporarily unavailable",
            "timed out",
            "timeout",
            "try again later",
        )
    )


def _is_retryable_client_error(exc: BaseException) -> bool:
    if isinstance(exc, (TimeoutError, OSError)):
        return True
    text = str(exc).lower()
    return any(
        marker in text
        for marker in (
            "connection aborted",
            "connection reset",
            "deadline exceeded",
            "remote end closed",
            "service unavailable",
            "temporarily unavailable",
            "timed out",
            "timeout",
            "try again later",
        )
    )


class DirectSheetsWriter:
    def __init__(
        self,
        access_token: str,
        refresh_token: Optional[str] = None,
        expires_at: Optional[datetime] = None,
        account_id: Optional[str] = None,
        rate_limit_rpm: int = DEFAULT_RPM_LIMIT,
        max_429_retries: int = 3,
        max_transient_retries: int = 2,
    ) -> None:
        if not access_token:
            raise DirectSheetsWriterError("Missing Google access token")
        self._credentials = Credentials(
            token=access_token,
            refresh_token=refresh_token,
            token_uri="https://oauth2.googleapis.com/token",
            client_id=settings.GOOGLE_CLIENT_ID,
            client_secret=settings.GOOGLE_CLIENT_SECRET,
            scopes=[GOOGLE_SHEETS_SCOPE],
        )
        if expires_at:
            self._credentials.expiry = expires_at
        self._account_id = account_id
        self._rate_limit_rpm = max(1, int(rate_limit_rpm or DEFAULT_RPM_LIMIT))
        self._max_429_retries = max(0, int(max_429_retries))
        self._max_transient_retries = max(0, int(max_transient_retries))

    def _service(self):
        return build("sheets", "v4", credentials=self._credentials, cache_discovery=False)

    def _call_with_backoff(
        self, action, action_name: str, max_429_retries: Optional[int] = None
    ):
        rate_attempt = 0
        transient_attempt = 0
        backoff_seconds = 1
        retry_limit = (
            self._max_429_retries
            if max_429_retries is None
            else max(0, int(max_429_retries))
        )
        transient_limit = self._max_transient_retries
        while True:
            if self._account_id:
                allowed = acquire_rate_limit(self._account_id, self._rate_limit_rpm)
                if not allowed:
                    raise RateLimitedError(
                        "Google Sheets rate limit reached before " + action_name
                    )
            try:
                return action()
            except HttpError as err:
                status = _http_status(err)
                if status == 429:
                    if rate_attempt >= retry_limit:
                        raise RateLimitedError(
                            "Google Sheets rate limit exceeded during " + action_name
                        ) from err
                    time.sleep(backoff_seconds)
                    rate_attempt += 1
                    backoff_seconds *= 2
                    continue
                if _is_sheet_not_found(err):
                    raise SheetNotFoundError(_http_message(err)) from err
                if _is_retryable_http_error(err):
                    if transient_attempt >= transient_limit:
                        raise TransientGoogleSheetsError(_http_message(err)) from err
                    time.sleep(backoff_seconds)
                    transient_attempt += 1
                    backoff_seconds *= 2
                    continue
                raise DirectSheetsWriterError(_http_message(err)) from err
            except Exception as err:
                if not _is_retryable_client_error(err):
                    raise
                if transient_attempt >= transient_limit:
                    raise TransientGoogleSheetsError(str(err)) from err
                time.sleep(backoff_seconds)
                transient_attempt += 1
                backoff_seconds *= 2

    def _get_sheet_metadata(self, spreadsheet_id: str) -> Dict[str, Any]:
        service = self._service()
        return self._call_with_backoff(
            lambda: service.spreadsheets()
            .get(
                spreadsheetId=spreadsheet_id,
                fields="sheets.properties.sheetId,sheets.properties.title",
            )
            .execute(),
            "metadata_lookup",
        )

    @staticmethod
    def _extract_sheet_titles(meta: Dict[str, Any]) -> Dict[str, str]:
        out: Dict[str, str] = {}
        sheets = meta.get("sheets", []) if isinstance(meta, dict) else []
        for sheet in sheets:
            props = sheet.get("properties") or {}
            title = props.get("title")
            sheet_id = props.get("sheetId")
            if title is None:
                continue
            out[str(sheet_id)] = str(title)
        return out

    def _resolve_sheet_title(
        self,
        spreadsheet_id: str,
        sheet_gid: Optional[str],
        fallback_name: Optional[str],
        create_if_missing: bool = False,
    ) -> str:
        meta = self._get_sheet_metadata(spreadsheet_id)
        titles_by_id = self._extract_sheet_titles(meta)
        fallback = str(fallback_name or "").strip()

        if sheet_gid is not None:
            by_gid = titles_by_id.get(str(sheet_gid))
            if by_gid:
                return by_gid

        if fallback:
            for title in titles_by_id.values():
                if title == fallback:
                    return title
            if create_if_missing:
                return self.ensure_sheet_exists(spreadsheet_id, fallback)

        if create_if_missing and fallback:
            return self.ensure_sheet_exists(spreadsheet_id, fallback)

        raise SheetNotFoundError("Sheet tab not found for scheduled write")

    def ensure_sheet_exists(self, spreadsheet_id: str, sheet_name: str) -> str:
        if not spreadsheet_id:
            raise DirectSheetsWriterError("Missing spreadsheet_id")
        title = str(sheet_name or "").strip()
        if not title:
            raise DirectSheetsWriterError("Missing sheet_name")

        meta = self._get_sheet_metadata(spreadsheet_id)
        titles_by_id = self._extract_sheet_titles(meta)
        if title in titles_by_id.values():
            return title

        service = self._service()
        try:
            self._call_with_backoff(
                lambda: service.spreadsheets()
                .batchUpdate(
                    spreadsheetId=spreadsheet_id,
                    body={"requests": [{"addSheet": {"properties": {"title": title}}}]},
                )
                .execute(),
                "create_sheet",
            )
        except DirectSheetsWriterError as err:
            # Concurrent create calls can race; if the tab now exists, proceed.
            if "already exists" not in str(err).lower():
                raise

        return title

    def read_header_row(
        self,
        spreadsheet_id: str,
        sheet_name: Optional[str],
        sheet_gid: Optional[str] = None,
        cached_title: Optional[str] = None,
        create_sheet: bool = False,
    ) -> Dict[str, Any]:
        if not spreadsheet_id:
            raise DirectSheetsWriterError("Missing spreadsheet_id")
        title = self._resolve_sheet_title(
            spreadsheet_id=spreadsheet_id,
            sheet_gid=sheet_gid,
            fallback_name=cached_title or sheet_name,
            create_if_missing=create_sheet,
        )

        quoted = _quote_sheet_name(title)
        service = self._service()
        resp = self._call_with_backoff(
            lambda: service.spreadsheets()
            .values()
            .get(
                spreadsheetId=spreadsheet_id,
                range=f"{quoted}!1:1",
                majorDimension="ROWS",
            )
            .execute(),
            "read_header",
        )

        values = resp.get("values") if isinstance(resp, dict) else None
        headers: List[str] = []
        if values and isinstance(values, list) and values and isinstance(values[0], list):
            headers = [str(v) if v is not None else "" for v in values[0]]
            while headers and headers[-1] == "":
                headers.pop()

        return {"sheet_name": title, "headers": headers}

    def update_header_row(
        self,
        spreadsheet_id: str,
        sheet_name: str,
        headers: List[str],
    ) -> Dict[str, Any]:
        if not spreadsheet_id:
            raise DirectSheetsWriterError("Missing spreadsheet_id")
        if not sheet_name:
            raise DirectSheetsWriterError("Missing sheet_name")

        normalized_headers = [str(h) if h is not None else "" for h in (headers or [])]
        if not normalized_headers:
            return {"ok": True, "updated": 0}

        quoted = _quote_sheet_name(sheet_name)
        last_col = _column_letter(len(normalized_headers))
        service = self._service()
        resp = self._call_with_backoff(
            lambda: service.spreadsheets()
            .values()
            .update(
                spreadsheetId=spreadsheet_id,
                range=f"{quoted}!A1:{last_col}1",
                valueInputOption="RAW",
                body={"values": [normalized_headers]},
            )
            .execute(),
            "update_header",
        )
        return {"ok": True, "updated": 1, "response": resp}

    def append_to_sheet(
        self,
        spreadsheet_id: str,
        sheet_name: Optional[str],
        rows: List[List[Any]],
        sheet_gid: Optional[str] = None,
        cached_title: Optional[str] = None,
        create_sheet: bool = False,
    ) -> Dict[str, Any]:
        if not spreadsheet_id:
            raise DirectSheetsWriterError("Missing spreadsheet_id")
        title = self._resolve_sheet_title(
            spreadsheet_id=spreadsheet_id,
            sheet_gid=sheet_gid,
            fallback_name=cached_title or sheet_name,
            create_if_missing=create_sheet,
        )

        if not rows:
            return {"ok": True, "rows": 0, "sheet_name": title}

        quoted = _quote_sheet_name(title)
        service = self._service()
        resp = self._call_with_backoff(
            lambda: service.spreadsheets()
            .values()
            .append(
                spreadsheetId=spreadsheet_id,
                range=f"{quoted}!A1",
                valueInputOption="RAW",
                insertDataOption="INSERT_ROWS",
                body={"values": rows},
            )
            .execute(),
            "append",
        )

        return {"ok": True, "rows": len(rows), "sheet_name": title, "response": resp}

    def write_to_sheet(
        self,
        spreadsheet_id: str,
        sheet_name: Optional[str],
        headers: List[str],
        rows: List[List[Any]],
        sheet_gid: Optional[str] = None,
        cached_title: Optional[str] = None,
    ) -> Dict[str, Any]:
        if not spreadsheet_id:
            raise DirectSheetsWriterError("Missing spreadsheet_id")

        def _write_with_title(title: str) -> Dict[str, Any]:
            quoted = _quote_sheet_name(title)
            service = self._service()

            self._call_with_backoff(
                lambda: service.spreadsheets()
                .values()
                .clear(
                    spreadsheetId=spreadsheet_id,
                    range=quoted,
                    body={},
                )
                .execute(),
                "clear",
            )

            values = []
            if headers:
                values.append(headers)
            values.extend(rows or [])

            if not values:
                return {"ok": True, "rows": 0, "sheet_name": title}

            self._call_with_backoff(
                lambda: service.spreadsheets()
                .values()
                .update(
                    spreadsheetId=spreadsheet_id,
                    range=f"{quoted}!A1",
                    valueInputOption="RAW",
                    body={"values": values},
                )
                .execute(),
                "update",
            )

            return {
                "ok": True,
                "rows": len(values) - (1 if headers else 0),
                "sheet_name": title,
            }

        if cached_title:
            try:
                return _write_with_title(cached_title)
            except SheetNotFoundError:
                pass

        title = self._resolve_sheet_title(
            spreadsheet_id, sheet_gid, sheet_name, create_if_missing=False
        )
        return _write_with_title(title)
