import hashlib
import hmac
import json
from urllib.parse import parse_qs, urlparse

import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient

from app.api import shopify as shopify_api
from app.core.config import settings
from app.services.integrations.shopify_oauth import (
    ShopQueryError, TokenExchangeError, build_hmac_message,
)


class FakeRedis:
    """Minimal stand-in for the async redis client used by the routes."""

    def __init__(self):
        self.store = {}

    async def setex(self, key, ttl, value):
        self.store[key] = value

    async def get(self, key):
        return self.store.get(key)

    async def delete(self, key):
        self.store.pop(key, None)

    async def __aenter__(self):
        return self

    async def __aexit__(self, *a):
        return False


@pytest.fixture
def fake_redis(monkeypatch):
    r = FakeRedis()
    monkeypatch.setattr(shopify_api, "get_redis_client", lambda: r)
    return r


@pytest.fixture
def client():
    app = FastAPI()
    app.include_router(shopify_api.router)
    return TestClient(app)


def test_install_redirects_to_shopify(client, fake_redis):
    resp = client.get("/api/shopify/install",
                      params={"shop": "galaxiq-braexaal.myshopify.com",
                              "tenant_id": "org_test"},
                      follow_redirects=False)

    assert resp.status_code == 307
    loc = resp.headers["location"]
    assert loc.startswith("https://galaxiq-braexaal.myshopify.com/admin/oauth/authorize")
    assert "grant_options" not in loc


def test_install_stores_state_bound_to_shop_and_tenant(client, fake_redis):
    client.get("/api/shopify/install",
               params={"shop": "galaxiq-braexaal.myshopify.com",
                       "tenant_id": "org_test"},
               follow_redirects=False)

    assert len(fake_redis.store) == 1
    key, raw = next(iter(fake_redis.store.items()))
    assert key.startswith("shopify:oauth:")
    payload = json.loads(raw)
    assert payload == {"shop": "galaxiq-braexaal.myshopify.com",
                       "tenant_id": "org_test"}


def test_install_accepts_bare_handle(client, fake_redis):
    resp = client.get("/api/shopify/install",
                      params={"shop": "galaxiq-braexaal", "tenant_id": "org_test"},
                      follow_redirects=False)
    assert resp.status_code == 307
    assert "galaxiq-braexaal.myshopify.com" in resp.headers["location"]


@pytest.mark.parametrize("shop", ["evil.com", "../../etc", "", "mystore.com"])
def test_install_rejects_bad_shop_domain(client, fake_redis, shop):
    resp = client.get("/api/shopify/install",
                      params={"shop": shop, "tenant_id": "org_test"},
                      follow_redirects=False)
    assert resp.status_code == 400
    assert fake_redis.store == {}


def test_install_requires_tenant_id(client, fake_redis):
    resp = client.get("/api/shopify/install",
                      params={"shop": "galaxiq-braexaal.myshopify.com"},
                      follow_redirects=False)
    assert resp.status_code == 422


def test_state_is_unique_per_call(client, fake_redis):
    for _ in range(2):
        client.get("/api/shopify/install",
                   params={"shop": "galaxiq-braexaal.myshopify.com",
                           "tenant_id": "org_test"},
                   follow_redirects=False)
    assert len(fake_redis.store) == 2


def _seed_state(fake_redis, state="s-nonce", shop="galaxiq-braexaal.myshopify.com",
                tenant="org_test"):
    fake_redis.store[f"shopify:oauth:{state}"] = json.dumps(
        {"shop": shop, "tenant_id": tenant})
    return state


@pytest.fixture
def happy_path(monkeypatch):
    """Stub the network and the database; the route logic is what is under test."""
    saved = {}

    async def fake_exchange(shop, code, **kw):
        return {"access_token": "shpat_realtoken",
                "scope": "read_products,read_inventory"}

    async def fake_shop_info(shop, token, **kw):
        return {"name": "Galaxiq dev", "currency_code": "USD",
                "primary_domain": "https://galaxiq-braexaal.myshopify.com"}

    def fake_upsert(tenant_id, kind, external_ref, config, credentials):
        saved.update(tenant_id=tenant_id, kind=kind, external_ref=external_ref,
                     config=config, credentials=credentials)

    monkeypatch.setattr(shopify_api, "exchange_code", fake_exchange)
    monkeypatch.setattr(shopify_api, "fetch_shop_info", fake_shop_info)
    monkeypatch.setattr(shopify_api, "upsert_source", fake_upsert)
    monkeypatch.setattr(shopify_api, "verify_hmac", lambda params: True)
    # Platform bookkeeping talks to the real master DB; stub it here so every
    # test built on this fixture stays a unit test instead of an integration
    # test against a database that isn't there.
    monkeypatch.setattr(shopify_api, "upsert_integration",
                        lambda **kw: "int-test")
    monkeypatch.setattr(shopify_api, "track_connect", lambda *a, **kw: None)
    return saved


def _callback(client, **params):
    return client.get("/api/shopify/callback", params=params, follow_redirects=False)


def test_callback_success_persists_and_redirects(client, fake_redis, happy_path):
    state = _seed_state(fake_redis)
    resp = _callback(client, shop="galaxiq-braexaal.myshopify.com",
                     code="the-code", state=state, hmac="x")

    assert resp.status_code == 307
    q = parse_qs(urlparse(resp.headers["location"]).query)
    assert q["connected"] == ["shopify"]
    assert q["shop"] == ["galaxiq-braexaal.myshopify.com"]

    assert happy_path["tenant_id"] == "org_test"
    assert happy_path["kind"] == "shopify"
    assert happy_path["external_ref"] == "galaxiq-braexaal.myshopify.com"
    assert happy_path["credentials"] == {"access_token": "shpat_realtoken"}
    assert happy_path["config"]["currency_code"] == "USD"
    assert happy_path["config"]["scopes"] == "read_products,read_inventory"
    assert happy_path["config"]["api_version"] == "2026-07"


def test_callback_consumes_state_once(client, fake_redis, happy_path):
    state = _seed_state(fake_redis)
    first = _callback(client, shop="galaxiq-braexaal.myshopify.com",
                      code="c", state=state, hmac="x")
    assert first.status_code == 307

    replay = _callback(client, shop="galaxiq-braexaal.myshopify.com",
                       code="c", state=state, hmac="x")
    assert replay.status_code == 303
    assert "invalid_state" in replay.headers["location"]


def test_callback_rejects_unknown_state(client, fake_redis, happy_path):
    resp = _callback(client, shop="galaxiq-braexaal.myshopify.com",
                     code="c", state="never-issued", hmac="x")
    assert resp.status_code == 303
    assert "invalid_state" in resp.headers["location"]


def test_callback_rejects_shop_mismatch(client, fake_redis, happy_path):
    state = _seed_state(fake_redis, shop="other-store.myshopify.com")
    resp = _callback(client, shop="galaxiq-braexaal.myshopify.com",
                     code="c", state=state, hmac="x")
    assert resp.status_code == 303
    assert "shop_mismatch" in resp.headers["location"]


def test_callback_rejects_bad_shop_domain(client, fake_redis, happy_path):
    resp = _callback(client, shop="evil.com", code="c", state="s", hmac="x")
    assert resp.status_code == 303
    assert "invalid_shop" in resp.headers["location"]


def test_callback_rejects_bad_hmac(client, fake_redis, happy_path, monkeypatch):
    monkeypatch.setattr(shopify_api, "verify_hmac", lambda params: False)
    state = _seed_state(fake_redis)
    resp = _callback(client, shop="galaxiq-braexaal.myshopify.com",
                     code="c", state=state, hmac="forged")
    assert resp.status_code == 303
    assert "hmac_failed" in resp.headers["location"]


def test_callback_state_consumed_before_hmac_check(client, fake_redis, happy_path,
                                                   monkeypatch):
    # A failed attempt must still burn the nonce, or an attacker can retry
    # signatures against a live state value.
    monkeypatch.setattr(shopify_api, "verify_hmac", lambda params: False)
    state = _seed_state(fake_redis)
    _callback(client, shop="galaxiq-braexaal.myshopify.com",
              code="c", state=state, hmac="forged")
    assert fake_redis.store == {}


def test_callback_token_exchange_failure(client, fake_redis, happy_path, monkeypatch):
    async def boom(shop, code, **kw):
        raise TokenExchangeError("HTTP 400")

    monkeypatch.setattr(shopify_api, "exchange_code", boom)
    state = _seed_state(fake_redis)
    resp = _callback(client, shop="galaxiq-braexaal.myshopify.com",
                     code="bad", state=state, hmac="x")
    assert resp.status_code == 303
    assert "token_exchange_failed" in resp.headers["location"]


def test_callback_persists_nothing_when_verification_fails(client, fake_redis,
                                                           happy_path, monkeypatch):
    async def boom(shop, token, **kw):
        raise ShopQueryError("HTTP 401")

    monkeypatch.setattr(shopify_api, "fetch_shop_info", boom)
    state = _seed_state(fake_redis)
    resp = _callback(client, shop="galaxiq-braexaal.myshopify.com",
                     code="c", state=state, hmac="x")

    assert resp.status_code == 303
    assert "token_verification_failed" in resp.headers["location"]
    assert happy_path == {}   # nothing written


def test_callback_verifies_hmac_over_the_raw_query_params(client, fake_redis,
                                                           monkeypatch):
    # Deliberately does NOT stub verify_hmac, unlike every other callback
    # test. Shopify signs the raw shop/code/state/timestamp it sent, not the
    # normalised domain the route derives from them; if the route ever
    # passed the wrong values into verify_hmac, this is the only test that
    # would notice, since every other test hides the real algorithm behind
    # a stub that returns True unconditionally.
    async def fake_exchange(shop, code, **kw):
        return {"access_token": "shpat_realtoken",
                "scope": "read_products,read_inventory"}

    async def fake_shop_info(shop, token, **kw):
        return {"name": "Galaxiq dev", "currency_code": "USD",
                "primary_domain": "https://galaxiq-braexaal.myshopify.com"}

    monkeypatch.setattr(shopify_api, "exchange_code", fake_exchange)
    monkeypatch.setattr(shopify_api, "fetch_shop_info", fake_shop_info)
    monkeypatch.setattr(shopify_api, "upsert_source", lambda **kw: None)
    monkeypatch.setattr(shopify_api, "upsert_integration", lambda **kw: "int-test")
    monkeypatch.setattr(shopify_api, "track_connect", lambda *a, **kw: None)

    # Mixed-case so the raw value Shopify actually signs differs from the
    # normalised domain the route derives from it.
    shop = "Galaxiq-Braexaal.MyShopify.com"
    state = _seed_state(fake_redis, shop="galaxiq-braexaal.myshopify.com")
    params = {"shop": shop, "code": "the-code", "state": state,
              "timestamp": "1700000000"}
    signature = hmac.new(
        settings.SHOPIFY_CLIENT_SECRET.encode(),
        build_hmac_message(params).encode(),
        hashlib.sha256,
    ).hexdigest()

    resp = _callback(client, hmac=signature, **params)

    assert resp.status_code == 307
    q = parse_qs(urlparse(resp.headers["location"]).query)
    assert q["connected"] == ["shopify"]


def test_status_lists_sources_without_credentials(client, monkeypatch):
    monkeypatch.setattr(shopify_api, "get_sources", lambda tenant_id: [{
        "kind": "shopify",
        "external_ref": "galaxiq-braexaal.myshopify.com",
        "config": {"currency_code": "USD", "scopes": "read_products,read_inventory"},
        "status": "active",
        "connected_at": None,
        "last_synced_at": None,
    }])

    resp = client.get("/api/shopify/status", params={"tenant_id": "org_test"})
    assert resp.status_code == 200

    body = resp.json()
    assert body["sources"][0]["external_ref"] == "galaxiq-braexaal.myshopify.com"
    assert "credentials" not in json.dumps(body)
    assert "access_token" not in json.dumps(body)


def test_status_empty_when_not_connected(client, monkeypatch):
    monkeypatch.setattr(shopify_api, "get_sources", lambda tenant_id: [])
    resp = client.get("/api/shopify/status", params={"tenant_id": "org_test"})
    assert resp.status_code == 200
    assert resp.json() == {"sources": []}


def test_status_requires_tenant_id(client):
    assert client.get("/api/shopify/status").status_code == 422


def test_install_tracks_authorize_url_ready(client, fake_redis, monkeypatch):
    tracked = []
    monkeypatch.setattr(shopify_api, "track_connect",
                        lambda *a, **kw: tracked.append((a, kw)))

    client.get("/api/shopify/install",
               params={"shop": "galaxiq-braexaal.myshopify.com",
                       "tenant_id": "org_test"},
               follow_redirects=False)

    assert tracked, "install must record that an authorize URL was issued"
    assert "oauth_authorize_url_ready" in str(tracked[0])


def test_callback_records_the_integration(client, fake_redis, happy_path, monkeypatch):
    recorded = {}
    monkeypatch.setattr(shopify_api, "upsert_integration",
                        lambda **kw: recorded.update(kw) or "int-1")
    monkeypatch.setattr(shopify_api, "track_connect", lambda *a, **kw: None)

    state = _seed_state(fake_redis)
    resp = _callback(client, shop="galaxiq-braexaal.myshopify.com",
                     code="c", state=state, hmac="x")

    assert resp.status_code == 307
    assert recorded["provider"] == "shopify"
    assert recorded["auth_type"] == "oauth2"
    assert recorded["metadata"]["shop_domain"] == "galaxiq-braexaal.myshopify.com"
    # Ciphertext, never a raw shpat_ token.
    assert not recorded["access_token_ciphertext"].startswith("shpat_")


def test_callback_reports_a_tenant_without_a_user(client, fake_redis, happy_path,
                                                  monkeypatch):
    from app.services.integrations.platform import TenantHasNoUserError

    def boom(**kw):
        raise TenantHasNoUserError("no userId")

    monkeypatch.setattr(shopify_api, "upsert_integration", boom)
    monkeypatch.setattr(shopify_api, "track_connect", lambda *a, **kw: None)

    state = _seed_state(fake_redis)
    resp = _callback(client, shop="galaxiq-braexaal.myshopify.com",
                     code="c", state=state, hmac="x")

    assert resp.status_code == 409
    assert "no_owning_user" in resp.headers["location"]


def test_a_tracker_failure_does_not_fail_the_connection(client, fake_redis,
                                                        happy_path, monkeypatch):
    def boom(*a, **kw):
        raise RuntimeError("tracker table unreachable")

    monkeypatch.setattr(shopify_api, "track_connect", boom)
    monkeypatch.setattr(shopify_api, "upsert_integration", lambda **kw: "int-1")

    state = _seed_state(fake_redis)
    resp = _callback(client, shop="galaxiq-braexaal.myshopify.com",
                     code="c", state=state, hmac="x")
    assert resp.status_code == 307
