"""
FrankenClaw — Shopify tools

Product catalog, inventory, and collection access for one OR MORE Shopify
stores. Auth uses the post-Jan 2026 client_credentials flow — tokens
expire every 24 hours and are auto-refreshed before each call.

Credentials come from the keys file (default `~/.frankenclaw/keys.json`).
Each store you want to talk to gets its own object in keys.json with
`store`, `client_id`, `client_secret`. Example fragment with two stores:

    {
      "shopify": {
        "store": "primary-store.myshopify.com",
        "client_id": "...",
        "client_secret": "..."
      },
      "shopify_secondary": {
        "store": "other-store.myshopify.com",
        "client_id": "...",
        "client_secret": "..."
      }
    }

How tools resolve the store, in priority order:

  1. The per-call `store` argument — pass the keys-file entry name
     (e.g. `store="shopify_secondary"`) to override per call.
  2. The `FRANKENCLAW_SHOPIFY_KEY` environment variable (single-store
     default for the whole process).
  3. The literal entry name `"shopify"`.

Tokens are cached per store at /tmp/frankenclaw-shopify-token-<entry>.json
so multiple stores can be in flight without invalidating each other.
"""

import json
import os
import time

import httpx

from config import load_keys

API_VERSION = "2026-04"
TOKEN_CACHE_TEMPLATE = "/tmp/frankenclaw-shopify-token-{entry}.json"
REFRESH_MARGIN = 300  # refresh if <5 min remaining
DEFAULT_KEYS_ENTRY = os.environ.get("FRANKENCLAW_SHOPIFY_KEY", "shopify")


def _resolve_entry(store: str) -> str:
    """Pick the keys-file entry name from per-call arg or env default."""
    return store.strip() if store and store.strip() else DEFAULT_KEYS_ENTRY


def _token_cache_path(entry: str) -> str:
    # Sanitize entry name for use in filename — strip path separators and
    # restrict to a safe charset. Keys.json entries are author-controlled so
    # this is mostly defensive.
    safe = "".join(c if c.isalnum() or c in ("-", "_") else "_" for c in entry)
    return TOKEN_CACHE_TEMPLATE.format(entry=safe)


def _get_creds(entry: str) -> dict:
    """Load Shopify credentials from the configured keys file."""
    keys = load_keys()
    creds = keys.get(entry)
    if not creds or not isinstance(creds, dict):
        raise KeyError(
            f"No '{entry}' object found in keys.json. "
            f"Pass a different `store` argument, set FRANKENCLAW_SHOPIFY_KEY "
            f"to point at a different default entry, or add a '{entry}' "
            f"object with store, client_id, client_secret."
        )
    return creds


def _load_cached_token(entry: str) -> dict | None:
    """Load cached token if it exists and hasn't expired."""
    try:
        with open(_token_cache_path(entry)) as f:
            cached = json.load(f)
        if cached.get("expires_at", 0) - time.time() > REFRESH_MARGIN:
            return cached
    except (FileNotFoundError, json.JSONDecodeError, KeyError):
        pass
    return None


def _save_token(entry: str, token: str, expires_in: int):
    """Cache token with expiry timestamp.

    Written 0o600 via temp-file + atomic replace — /tmp is world-readable and
    this is a Shopify Admin token, so it must not land with the default umask
    (matches the gdrive token-persist pattern).
    """
    data = {
        "access_token": token,
        "expires_at": time.time() + expires_in,
    }
    path = _token_cache_path(entry)
    tmp = path + ".tmp"
    with open(tmp, "w") as f:
        json.dump(data, f)
    os.chmod(tmp, 0o600)
    os.replace(tmp, path)


async def _get_token(client: httpx.AsyncClient, entry: str, creds: dict) -> str:
    """Get a valid access token, refreshing if needed."""
    cached = _load_cached_token(entry)
    if cached:
        return cached["access_token"]

    store = creds["store"]
    resp = await client.post(
        f"https://{store}/admin/oauth/access_token",
        data={
            "grant_type": "client_credentials",
            "client_id": creds["client_id"],
            "client_secret": creds["client_secret"],
        },
    )
    resp.raise_for_status()
    data = resp.json()
    token = data["access_token"]
    expires_in = data.get("expires_in", 86400)
    _save_token(entry, token, expires_in)
    return token


async def _api(method: str, path: str, body: dict | None = None, *, store: str = "") -> dict:
    """Make an authenticated Shopify Admin API call with auto-refresh."""
    entry = _resolve_entry(store)
    creds = _get_creds(entry)
    domain = creds["store"]
    url = f"https://{domain}/admin/api/{API_VERSION}/{path}"

    async with httpx.AsyncClient(timeout=30) as client:
        token = await _get_token(client, entry, creds)
        headers = {"X-Shopify-Access-Token": token, "Content-Type": "application/json"}

        resp = await client.request(method, url, headers=headers, json=body)

        # Auto-refresh on 401
        if resp.status_code == 401:
            try:
                os.remove(_token_cache_path(entry))
            except FileNotFoundError:
                pass
            token = await _get_token(client, entry, creds)
            headers["X-Shopify-Access-Token"] = token
            resp = await client.request(method, url, headers=headers, json=body)

        resp.raise_for_status()

        if resp.status_code == 204:
            return {"success": True}
        return resp.json()


def _ok(data):
    return json.dumps({"success": True, "result": data}, indent=2, default=str)


def _err(msg):
    return json.dumps({"success": False, "error": str(msg)}, indent=2)


_STORE_ARG_DOC = (
    "store: Optional keys.json entry name to target a specific Shopify "
    "store (e.g. 'shopify_gallery', 'shopify_secondary'). Empty string "
    "(default) uses FRANKENCLAW_SHOPIFY_KEY env or 'shopify'."
)


async def shopify_products(
    action: str,
    product_id: str = "",
    data: str = "",
    store: str = "",
) -> str:
    """
    Manage Shopify products.

    Args:
        action: One of: list, get, create, update, delete.
        product_id: Product ID (required for get, update, delete).
        data: JSON string with product data (for create, update).
        {store_doc}

    Returns:
        JSON with product info.
    """
    try:
        if action == "list":
            resp = await _api("GET", "products.json", store=store)
            products = resp.get("products", [])
            return _ok([{
                "id": p["id"],
                "title": p["title"],
                "vendor": p.get("vendor", ""),
                "status": p.get("status", ""),
                "tags": p.get("tags", ""),
                "variants": len(p.get("variants", [])),
                "images": len(p.get("images", [])),
            } for p in products])

        elif action == "get":
            if not product_id:
                return _err("'get' requires a product_id.")
            resp = await _api("GET", f"products/{product_id}.json", store=store)
            return _ok(resp.get("product", resp))

        elif action == "create":
            if not data:
                return _err("'create' requires product data as JSON string.")
            body = {"product": json.loads(data)}
            resp = await _api("POST", "products.json", body, store=store)
            return _ok(resp.get("product", resp))

        elif action == "update":
            if not product_id or not data:
                return _err("'update' requires product_id and data.")
            body = {"product": json.loads(data)}
            resp = await _api("PUT", f"products/{product_id}.json", body, store=store)
            return _ok(resp.get("product", resp))

        elif action == "delete":
            if not product_id:
                return _err("'delete' requires a product_id.")
            resp = await _api("DELETE", f"products/{product_id}.json", store=store)
            return _ok({"deleted": product_id})

        else:
            return _err(f"Unknown action '{action}'. Use: list, get, create, update, delete.")
    except httpx.HTTPStatusError as e:
        return _err(f"Shopify API {e.response.status_code}: {e.response.text[:500]}")
    except Exception as e:
        return _err(e)


async def shopify_inventory(
    action: str,
    inventory_item_id: str = "",
    location_id: str = "",
    quantity: int = 0,
    store: str = "",
) -> str:
    """
    Manage Shopify inventory levels.

    Args:
        action: One of: list_locations, get_levels, set_level.
        inventory_item_id: Inventory item ID (for get_levels, set_level).
        location_id: Location ID (for set_level).
        quantity: New quantity (for set_level).
        {store_doc}

    Returns:
        JSON with inventory info.
    """
    try:
        if action == "list_locations":
            resp = await _api("GET", "locations.json", store=store)
            locations = resp.get("locations", [])
            return _ok([{
                "id": loc["id"],
                "name": loc["name"],
                "active": loc.get("active", False),
                "city": loc.get("city", ""),
                "province": loc.get("province", ""),
            } for loc in locations])

        elif action == "get_levels":
            if not inventory_item_id:
                return _err("'get_levels' requires an inventory_item_id.")
            resp = await _api(
                "GET",
                f"inventory_levels.json?inventory_item_ids={inventory_item_id}",
                store=store,
            )
            return _ok(resp.get("inventory_levels", resp))

        elif action == "set_level":
            if not inventory_item_id or not location_id:
                return _err("'set_level' requires inventory_item_id and location_id.")
            body = {
                "location_id": int(location_id),
                "inventory_item_id": int(inventory_item_id),
                "available": quantity,
            }
            resp = await _api("POST", "inventory_levels/set.json", body, store=store)
            return _ok(resp.get("inventory_level", resp))

        else:
            return _err(f"Unknown action '{action}'. Use: list_locations, get_levels, set_level.")
    except httpx.HTTPStatusError as e:
        return _err(f"Shopify API {e.response.status_code}: {e.response.text[:500]}")
    except Exception as e:
        return _err(e)


async def shopify_collections(
    action: str,
    collection_id: str = "",
    store: str = "",
) -> str:
    """
    Browse Shopify collections.

    Args:
        action: One of: list, get.
        collection_id: Collection ID (required for get — returns products in collection).
        {store_doc}

    Returns:
        JSON with collection info.
    """
    try:
        if action == "list":
            custom = await _api("GET", "custom_collections.json", store=store)
            smart = await _api("GET", "smart_collections.json", store=store)
            collections = []
            for c in custom.get("custom_collections", []):
                collections.append({
                    "id": c["id"],
                    "title": c["title"],
                    "type": "custom",
                    "published": c.get("published_at") is not None,
                })
            for s in smart.get("smart_collections", []):
                collections.append({
                    "id": s["id"],
                    "title": s["title"],
                    "type": "smart",
                    "published": s.get("published_at") is not None,
                })
            return _ok(collections)

        elif action == "get":
            if not collection_id:
                return _err("'get' requires a collection_id.")
            resp = await _api(
                "GET",
                f"collections/{collection_id}/products.json",
                store=store,
            )
            products = resp.get("products", [])
            return _ok([{
                "id": p["id"],
                "title": p["title"],
                "vendor": p.get("vendor", ""),
                "status": p.get("status", ""),
            } for p in products])

        else:
            return _err(f"Unknown action '{action}'. Use: list, get.")
    except httpx.HTTPStatusError as e:
        return _err(f"Shopify API {e.response.status_code}: {e.response.text[:500]}")
    except Exception as e:
        return _err(e)


# Splice the shared store-arg description into each tool's docstring so the
# MCP tool listing surfaces the parameter without three copies of the same
# paragraph in source.
for _fn in (shopify_products, shopify_inventory, shopify_collections):
    if _fn.__doc__:
        _fn.__doc__ = _fn.__doc__.replace("{store_doc}", _STORE_ARG_DOC)
