"""Thin async GraphQL client for Shopify Admin API.

Built on httpx — lighter than the official ShopifyAPI package,
async-native, and gives us control over rate limit handling.

Handles:
    - Authentication (X-Shopify-Access-Token header)
    - Cost-aware rate limiting (parses throttleStatus, backs off automatically)
    - Cursor-based pagination (auto-follows hasNextPage)
    - Bulk operations (submit, poll, download JSONL)
    - Retries with exponential backoff

Rate limit model (standard plan):
    - 50 points/sec restore rate, 1000 point bucket
    - Each response includes extensions.cost.throttleStatus
    - We back off when currentlyAvailable < 100 points
"""

from __future__ import annotations

import asyncio
import json
import logging
from typing import Any, Dict, List, Optional

import httpx

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# GraphQL mutations for bulk operations
# ---------------------------------------------------------------------------

BULK_SUBMIT_MUTATION = """
mutation BulkRun($query: String!) {
  bulkOperationRunQuery(query: $query) {
    bulkOperation { id status }
    userErrors { field message }
  }
}
"""

BULK_STATUS_QUERY = """
query BulkStatus($id: ID!) {
  node(id: $id) {
    ... on BulkOperation {
      id
      status
      errorCode
      objectCount
      url
    }
  }
}
"""


class ShopifyGraphQLClient:
    """Async Shopify Admin GraphQL API client.

    Usage:
        client = ShopifyGraphQLClient(shop="store.myshopify.com", token="shpat_xxx")
        result = await client.execute("{ shop { name } }")
        products = await client.paginated_query(PRODUCTS_QUERY, variables={...})
    """

    API_URL_TEMPLATE = "https://{shop}/admin/api/{version}/graphql.json"

    def __init__(
        self,
        shop: str,
        token: str,
        version: str = "2026-01",
        max_retries: int = 3,
    ):
        """Initialize client.

        Args:
            shop: Store domain (e.g. "mystore.myshopify.com")
            token: Admin API access token
            version: API version string
            max_retries: Retries on transient failures
        """
        self.shop = shop
        self.url = self.API_URL_TEMPLATE.format(shop=shop, version=version)
        self.token = token
        self.headers = {
            "X-Shopify-Access-Token": token,
            "Content-Type": "application/json",
        }
        self.max_retries = max_retries
        self._available_points = 1000.0
        self._client: Optional[httpx.AsyncClient] = None

    async def _get_client(self) -> httpx.AsyncClient:
        """Lazy-create a shared httpx client."""
        if self._client is None or self._client.is_closed:
            self._client = httpx.AsyncClient(
                headers=self.headers,
                timeout=httpx.Timeout(30.0),
            )
        return self._client

    async def close(self) -> None:
        """Close the underlying HTTP client."""
        if self._client and not self._client.is_closed:
            await self._client.aclose()

    async def execute(self, query: str, variables: Optional[Dict] = None) -> Dict:
        """Execute a single GraphQL query/mutation.

        Parses rate limit info from response and backs off if needed.
        Retries on transient HTTP errors with exponential backoff.

        Args:
            query: GraphQL query string
            variables: Optional query variables

        Returns:
            Full JSON response dict (includes data, errors, extensions)
        """
        await self._wait_for_capacity()
        client = await self._get_client()

        payload: Dict[str, Any] = {"query": query}
        if variables:
            payload["variables"] = variables

        last_error = None
        for attempt in range(self.max_retries):
            try:
                resp = await client.post(self.url, json=payload)
                resp.raise_for_status()
                data = resp.json()
                self._update_rate_limit(data.get("extensions", {}))

                # Check for throttled errors
                errors = data.get("errors", [])
                if errors and any("Throttled" in str(e) for e in errors):
                    logger.warning("Throttled by Shopify, backing off")
                    await asyncio.sleep(2 ** attempt)
                    continue

                return data

            except httpx.HTTPStatusError as e:
                last_error = e
                if e.response.status_code == 429:
                    retry_after = float(e.response.headers.get("Retry-After", 2 ** attempt))
                    logger.warning(f"Rate limited (429), retrying in {retry_after}s")
                    await asyncio.sleep(retry_after)
                elif e.response.status_code >= 500:
                    logger.warning(f"Server error {e.response.status_code}, retrying")
                    await asyncio.sleep(2 ** attempt)
                else:
                    raise
            except httpx.TransportError as e:
                last_error = e
                logger.warning(f"Transport error: {e}, retrying")
                await asyncio.sleep(2 ** attempt)

        raise last_error or RuntimeError("Max retries exceeded")

    async def paginated_query(
        self,
        query: str,
        variables: Optional[Dict] = None,
        connection_path: Optional[List[str]] = None,
        max_pages: int = 100,
    ) -> List[Dict]:
        """Execute a paginated GraphQL query, following cursors automatically.

        The query must include a $cursor variable and pageInfo { hasNextPage endCursor }.

        Args:
            query: GraphQL query with $cursor variable and pageInfo
            variables: Initial variables (cursor will be injected)
            connection_path: Path to the connection in the response data
                             e.g. ["products"] for data.products.edges
            max_pages: Safety limit on number of pages

        Returns:
            Flat list of node dicts from all pages
        """
        all_nodes: List[Dict] = []
        cursor = None

        for page in range(max_pages):
            page_vars = {**(variables or {})}
            if cursor:
                page_vars["cursor"] = cursor

            result = await self.execute(query, page_vars)
            data = result.get("data", {})

            # Navigate to the connection
            connection = data
            if connection_path:
                for key in connection_path:
                    connection = connection.get(key, {})

            # Extract nodes from edges
            edges = connection.get("edges", [])
            for edge in edges:
                if "node" in edge:
                    all_nodes.append(edge["node"])

            # Check pagination
            page_info = connection.get("pageInfo", {})
            if not page_info.get("hasNextPage", False):
                break
            cursor = page_info.get("endCursor")
            if not cursor:
                break

            logger.debug(f"Page {page + 1}: fetched {len(edges)} nodes, continuing")

        return all_nodes

    async def bulk_query(self, query: str, poll_interval: float = 2.0, max_wait: float = 600.0) -> List[Dict]:
        """Submit a bulk operation query, poll until complete, download results.

        Shopify runs the query server-side and produces a JSONL download URL.
        No pagination or rate limits on the query execution itself.

        Args:
            query: GraphQL query for bulk operation (no variables, no pagination)
            poll_interval: Seconds between status polls
            max_wait: Maximum seconds to wait for bulk op to complete

        Returns:
            List of dicts parsed from JSONL result file
        """
        # 1. Submit bulk operation
        submit_result = await self.execute(
            BULK_SUBMIT_MUTATION, {"query": query}
        )

        op_data = submit_result.get("data", {}).get("bulkOperationRunQuery", {})
        user_errors = op_data.get("userErrors", [])
        if user_errors:
            raise RuntimeError(f"Bulk operation failed: {user_errors}")

        op = op_data.get("bulkOperation", {})
        op_id = op.get("id")
        logger.info(f"Bulk operation submitted: {op_id}")

        # 2. Poll for completion using node(id:) query
        elapsed = 0.0
        download_url = None
        while elapsed < max_wait:
            await asyncio.sleep(poll_interval)
            elapsed += poll_interval

            status_result = await self.execute(BULK_STATUS_QUERY, {"id": op_id})
            current_op = status_result.get("data", {}).get("node", {})

            status = current_op.get("status", "")
            if status == "COMPLETED":
                download_url = current_op.get("url")
                if not download_url:
                    return []  # No results
                break
            elif status in ("FAILED", "CANCELED"):
                error = current_op.get("errorCode", "unknown")
                raise RuntimeError(f"Bulk operation {status}: {error}")

            logger.debug(f"Bulk op status: {status}, objects: {current_op.get('objectCount', 0)}")
        else:
            raise TimeoutError(f"Bulk operation timed out after {max_wait}s")

        # 3. Download and parse JSONL
        client = await self._get_client()
        resp = await client.get(download_url)
        resp.raise_for_status()

        flat_records = []
        for line in resp.text.strip().split("\n"):
            line = line.strip()
            if line:
                flat_records.append(json.loads(line))

        # 4. Reassemble parent-child relationships via __parentId
        #    Shopify bulk JSONL emits flat lines — children have __parentId
        #    pointing to their parent's GID. We nest children under parents.
        results = _reassemble_bulk_jsonl(flat_records)

        logger.info(f"Bulk operation downloaded: {len(flat_records)} lines → {len(results)} top-level records")
        return results

    def _update_rate_limit(self, extensions: Dict) -> None:
        """Parse throttleStatus from GraphQL response extensions."""
        cost = extensions.get("cost", {})
        throttle = cost.get("throttleStatus", {})
        if "currentlyAvailable" in throttle:
            self._available_points = float(throttle["currentlyAvailable"])

    async def _wait_for_capacity(self, min_points: float = 100.0) -> None:
        """Back off if available points are low."""
        if self._available_points < min_points:
            wait = (min_points - self._available_points) / 50.0  # 50 pts/sec restore
            wait = min(wait, 10.0)  # cap at 10s
            logger.debug(f"Rate limit: {self._available_points:.0f} points, waiting {wait:.1f}s")
            await asyncio.sleep(wait)
            self._available_points = min_points  # Optimistic estimate after wait


def _reassemble_bulk_jsonl(records: List[Dict]) -> List[Dict]:
    """Reassemble flat Shopify bulk JSONL into nested parent-child structures.

    Shopify bulk operations produce flat JSONL where each line is a separate
    object. Child objects include a `__parentId` field referencing their parent's
    GID. We group children under their parent by detecting the relationship
    from the GID type (e.g. ProductVariant → variants).

    Known child types and their nesting keys:
        ProductVariant → variants
        LineItem → lineItems
        InventoryLevel → inventoryLevels
    """
    CHILD_KEY_MAP = {
        "ProductVariant": "variants",
        "InventoryLevel": "inventoryLevels",
    }

    parents: Dict[str, Dict] = {}  # gid → record
    parent_order: List[str] = []  # preserve insertion order

    for record in records:
        parent_id = record.get("__parentId")
        gid = record.get("id", "")

        if parent_id is None:
            # Top-level record (parent)
            parents[gid] = record
            parent_order.append(gid)
        elif parent_id in parents:
            # Child record — figure out nesting key from GID type
            child_type = _gid_type(gid)
            key = CHILD_KEY_MAP.get(child_type, _pluralize(child_type))

            # Remove __parentId before nesting
            child = {k: v for k, v in record.items() if k != "__parentId"}

            parent = parents[parent_id]
            parent.setdefault(key, [])
            parent[key].append(child)

    return [parents[gid] for gid in parent_order if gid in parents]


def _gid_type(gid: str) -> str:
    """Extract the type from a Shopify GID like 'gid://shopify/ProductVariant/123'."""
    parts = gid.split("/")
    return parts[3] if len(parts) >= 4 else ""


def _pluralize(name: str) -> str:
    """Simple CamelCase → camelCase plural for nesting keys."""
    if not name:
        return "children"
    # CamelCase to camelCase
    result = name[0].lower() + name[1:]
    return result + "s"
