"""Pure functions for the Shopify OAuth authorization code grant.

No database access and no global state, so every security control here is
unit-testable without infrastructure.
"""
import contextlib
import hashlib
import hmac
import logging
import re
from urllib.parse import urlencode

import httpx

from app.core.config import settings
from app.services.integrations.platform import get_platform_config

logger = logging.getLogger(__name__)

# Pinned in code rather than settings so config drift cannot silently change
# API behaviour. Must match the app version in the Shopify Dev Dashboard.
SHOPIFY_API_VERSION = "2026-07"
SHOPIFY_SCOPES = "read_products,read_inventory"

PLATFORM = "shopify"


def shopify_credentials() -> tuple[str, str]:
    """Client credentials from platform_configs, falling back to the environment.

    platform_configs is where the other thirteen providers keep theirs, and
    rotating a secret in one place every service reads beats rotating a copy per
    service. The fallback exists so a missing row -- or an unreachable master
    database -- cannot break a working connector; do not delete it as dead code.
    """
    try:
        cfg = get_platform_config(PLATFORM)
    except Exception:
        logger.warning("platform_configs lookup failed; using environment "
                       "credentials", exc_info=True)
        cfg = None

    if cfg and cfg.get("client_id") and cfg.get("client_secret"):
        return cfg["client_id"], cfg["client_secret"]
    return settings.SHOPIFY_CLIENT_ID, settings.SHOPIFY_CLIENT_SECRET

# Both ends are anchored. \Z is used instead of $ because $ also matches
# just before a trailing newline, e.g. "shop.myshopify.com\n" would pass.
SHOP_RE = re.compile(r"\A[a-zA-Z0-9][a-zA-Z0-9-]*\.myshopify\.com\Z")


def is_valid_shop_domain(shop: str | None) -> bool:
    """True only for a well-formed <handle>.myshopify.com domain."""
    if not shop or not isinstance(shop, str):
        return False
    return SHOP_RE.match(shop) is not None


def normalise_shop_domain(raw: str | None) -> str | None:
    """Turn what a merchant typed into a valid shop domain, or None.

    Merchants routinely enter their custom domain or a full admin URL. A bare
    handle gets the suffix appended; anything that still fails validation
    returns None rather than a guess.
    """
    if not raw or not isinstance(raw, str):
        return None
    domain = raw.strip().lower()
    domain = re.sub(r"^https?://", "", domain)
    domain = domain.split("/")[0]
    if not domain:
        return None
    if not domain.endswith(".myshopify.com"):
        domain = f"{domain}.myshopify.com"
    return domain if is_valid_shop_domain(domain) else None


def redirect_uri() -> str:
    """The callback URL registered with Shopify. Must match exactly."""
    return f"{settings.PUBLIC_BASE_URL.rstrip('/')}/api/shopify/callback"


def build_authorize_url(shop: str, state: str) -> str:
    """Authorize URL for the offline-token authorization code grant.

    grant_options[] is deliberately omitted: including it as "per-user" yields
    an online token that dies with the merchant's admin session.
    """
    client_id, _ = shopify_credentials()
    params = {
        "client_id": client_id,
        "scope": SHOPIFY_SCOPES,
        "redirect_uri": redirect_uri(),
        "state": state,
    }
    return f"https://{shop}/admin/oauth/authorize?{urlencode(params)}"


_HMAC_EXCLUDED = ("hmac", "signature")


def _escape(value: str) -> str:
    # % must go first: escaping & or = before % would double-escape the
    # "%25"/"%26"/"%3D" they introduce, and the digest would never match.
    return (
        str(value)
        .replace("%", "%25")
        .replace("&", "%26")
        .replace("=", "%3D")
    )


def build_hmac_message(params: dict) -> str:
    """Canonical message Shopify signs: sorted key=value pairs joined by &."""
    pairs = [
        f"{_escape(k)}={_escape(v)}"
        for k, v in sorted(params.items())
        if k not in _HMAC_EXCLUDED
    ]
    return "&".join(pairs)


def verify_hmac(params: dict) -> bool:
    """Timing-safe verification of Shopify's signature over the query params."""
    received = params.get("hmac")
    if not received:
        return False

    _, client_secret = shopify_credentials()
    digest = hmac.new(
        client_secret.encode("utf-8"),
        build_hmac_message(params).encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()

    try:
        return hmac.compare_digest(digest, received)
    except (TypeError, ValueError):
        # A malformed hmac param (wrong length/type) from an attacker must
        # not 500 the callback route.
        return False


_SHOP_QUERY = "{ shop { name currencyCode primaryDomain { url } } }"


class TokenExchangeError(Exception):
    """Shopify refused to exchange the authorization code for a token."""


class ShopQueryError(Exception):
    """The new token could not perform a real Admin API call."""


@contextlib.asynccontextmanager
async def _http(client):
    """Use the caller's client when injected (tests), else own one."""
    if client is not None:
        yield client
    else:
        async with httpx.AsyncClient(timeout=20.0) as owned:
            yield owned


async def exchange_code(shop: str, code: str, *, client: httpx.AsyncClient | None = None) -> dict:
    """Trade the authorization code for a permanent offline access token."""
    client_id, client_secret = shopify_credentials()
    async with _http(client) as c:
        resp = await c.post(
            f"https://{shop}/admin/oauth/access_token",
            headers={"Content-Type": "application/json"},
            json={
                "client_id": client_id,
                "client_secret": client_secret,
                "code": code,
            },
        )

    if resp.status_code != 200:
        # Never log the body: it can echo the code, and the code is a credential.
        logger.error("Shopify token exchange failed for %s: HTTP %s",
                     shop, resp.status_code)
        raise TokenExchangeError(f"HTTP {resp.status_code}")

    data = resp.json()
    token = data.get("access_token")
    if not token:
        logger.error("Shopify token exchange for %s returned no access_token", shop)
        raise TokenExchangeError("no access_token in response")

    return {"access_token": token, "scope": data.get("scope", "")}


async def fetch_shop_info(shop: str, token: str, *, client: httpx.AsyncClient | None = None) -> dict:
    """Prove the token works, and collect what the catalog sync will need.

    Currency and primary domain are required later for price normalisation and
    product URL construction, and are free to fetch at install time.
    """
    async with _http(client) as c:
        resp = await c.post(
            f"https://{shop}/admin/api/{SHOPIFY_API_VERSION}/graphql.json",
            headers={
                "X-Shopify-Access-Token": token,
                "Content-Type": "application/json",
            },
            json={"query": _SHOP_QUERY},
        )

    if resp.status_code != 200:
        logger.error("Shop query failed for %s: HTTP %s", shop, resp.status_code)
        raise ShopQueryError(f"HTTP {resp.status_code}")

    body = resp.json()
    if body.get("errors"):
        logger.error("Shop query returned GraphQL errors for %s: %s",
                     shop, body["errors"])
        raise ShopQueryError(str(body["errors"]))

    shop_node = (body.get("data") or {}).get("shop") or {}
    if not shop_node:
        raise ShopQueryError("empty shop node")

    return {
        "name": shop_node.get("name"),
        "currency_code": shop_node.get("currencyCode"),
        "primary_domain": (shop_node.get("primaryDomain") or {}).get("url"),
    }
