"""Tests for M6-T05: Tibber API client (app/integrations/tibber/client.py).

Covers:
  1. ``fetch_price_range``: happy path parses nodes into PricePoint objects with
     UTC-aware starts_at; does not assume a fixed number of nodes (tested with 3).
  2. ``fetch_price_range``: HTTP 401 → TibberAuthError.
  3. ``fetch_price_range``: HTTP 403 → TibberAuthError.
  4. ``fetch_price_range``: httpx.TimeoutException → TibberError.
  5. ``fetch_price_range``: generic HTTP error → TibberError.
  6. Token does not appear in any raised exception's string representation.
  7. ``fetch_price_range``: home_id selection (picks correct home).
  8. ``fetch_current_price``: happy path returns a single PricePoint.
  9. ``fetch_current_price``: HTTP 401 → TibberAuthError.
  10. GraphQL-level errors in response body → TibberError.

All HTTP calls are intercepted via ``httpx.MockTransport`` (httpx built-in) so
no network access is required.
"""

from __future__ import annotations

import json
from datetime import UTC, datetime, timedelta

import httpx
import pytest

from app.integrations.tibber.client import (
    PricePoint,
    TibberAuthError,
    TibberError,
    fetch_current_price,
    fetch_price_range,
)


# ---------------------------------------------------------------------------
# Helpers / fixture builders
# ---------------------------------------------------------------------------

_FAKE_TOKEN = "fake-secret-token-do-not-log"

# A minimal set of 3 price nodes to verify that the parser does not assume a
# fixed number of nodes (e.g. 96).
_THREE_NODES = [
    {
        "startsAt": "2026-06-23T00:00:00.000+02:00",
        "total": 0.25,
        "energy": 0.20,
        "tax": 0.05,
        "currency": "EUR",
        "level": "NORMAL",
    },
    {
        "startsAt": "2026-06-23T00:15:00.000+02:00",
        "total": 0.22,
        "energy": 0.18,
        "tax": 0.04,
        "currency": "EUR",
        "level": "CHEAP",
    },
    {
        "startsAt": "2026-06-23T00:30:00.000+02:00",
        "total": 0.30,
        "energy": 0.24,
        "tax": 0.06,
        "currency": "EUR",
        "level": None,
    },
]

_PRICE_RANGE_RESPONSE = {
    "data": {
        "viewer": {
            "homes": [
                {
                    "id": "home-id-1",
                    "currentSubscription": {
                        "priceInfoRange": {
                            "nodes": _THREE_NODES,
                        }
                    },
                }
            ]
        }
    }
}

_CURRENT_PRICE_RESPONSE = {
    "data": {
        "viewer": {
            "homes": [
                {
                    "id": "home-id-1",
                    "currentSubscription": {
                        "priceInfo": {
                            "current": {
                                "startsAt": "2026-06-23T10:00:00.000+02:00",
                                "total": 0.28,
                                "energy": 0.22,
                                "tax": 0.06,
                                "currency": "EUR",
                                "level": "EXPENSIVE",
                            }
                        }
                    },
                }
            ]
        }
    }
}


def _make_transport(status_code: int, body: dict | None = None, raise_timeout: bool = False):
    """Create an ``httpx.MockTransport`` that returns the given response.

    If *raise_timeout* is True, the transport raises ``httpx.TimeoutException``
    instead of returning a response.
    """

    def _handler(request: httpx.Request) -> httpx.Response:
        if raise_timeout:
            raise httpx.TimeoutException("timed out", request=request)
        payload = json.dumps(body or {}).encode()
        return httpx.Response(
            status_code=status_code,
            content=payload,
            headers={"Content-Type": "application/json"},
        )

    return httpx.MockTransport(_handler)


# ---------------------------------------------------------------------------
# fetch_price_range — happy path
# ---------------------------------------------------------------------------


def test_fetch_price_range_parses_nodes(monkeypatch):
    """Successful response with 3 nodes is parsed into 3 PricePoint objects."""
    transport = _make_transport(200, _PRICE_RANGE_RESPONSE)

    def _patched_post(url, *, json, headers, timeout):  # noqa: A002
        client = httpx.Client(transport=transport)
        return client.post(url, json=json, headers=headers, timeout=timeout)

    monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post)

    points = fetch_price_range(_FAKE_TOKEN)

    assert len(points) == 3

    # All starts_at must be UTC-aware.
    for p in points:
        assert isinstance(p, PricePoint)
        assert p.starts_at.tzinfo is not None
        assert p.starts_at.tzinfo == UTC or p.starts_at.utcoffset() == timedelta(0)

    # First node: 2026-06-23T00:00:00+02:00 → 2026-06-22T22:00:00Z
    first = points[0]
    assert first.starts_at == datetime(2026, 6, 22, 22, 0, 0, tzinfo=UTC)
    assert first.total == pytest.approx(0.25)
    assert first.energy == pytest.approx(0.20)
    assert first.tax == pytest.approx(0.05)
    assert first.currency == "EUR"
    assert first.level == "NORMAL"
    assert first.resolution == "QUARTER_HOURLY"

    # Third node has level=None in the raw API response.
    third = points[2]
    assert third.level is None


def test_fetch_price_range_does_not_assume_node_count(monkeypatch):
    """Parser handles an arbitrary number of nodes (not hardcoded to 96)."""
    # Build a response with a single node only.
    one_node_response = {
        "data": {
            "viewer": {
                "homes": [
                    {
                        "id": "home-id-1",
                        "currentSubscription": {
                            "priceInfoRange": {
                                "nodes": [_THREE_NODES[0]],
                            }
                        },
                    }
                ]
            }
        }
    }
    transport = _make_transport(200, one_node_response)

    def _patched_post(url, *, json, headers, timeout):  # noqa: A002
        client = httpx.Client(transport=transport)
        return client.post(url, json=json, headers=headers, timeout=timeout)

    monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post)

    points = fetch_price_range(_FAKE_TOKEN)
    assert len(points) == 1


def test_fetch_price_range_home_id_selection(monkeypatch):
    """When home_id is specified, the matching home is selected."""
    two_homes_response = {
        "data": {
            "viewer": {
                "homes": [
                    {
                        "id": "home-id-first",
                        "currentSubscription": {
                            "priceInfoRange": {
                                "nodes": [_THREE_NODES[0]],
                            }
                        },
                    },
                    {
                        "id": "home-id-second",
                        "currentSubscription": {
                            "priceInfoRange": {
                                "nodes": [_THREE_NODES[1], _THREE_NODES[2]],
                            }
                        },
                    },
                ]
            }
        }
    }
    transport = _make_transport(200, two_homes_response)

    def _patched_post(url, *, json, headers, timeout):  # noqa: A002
        client = httpx.Client(transport=transport)
        return client.post(url, json=json, headers=headers, timeout=timeout)

    monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post)

    # Selecting the second home should return 2 nodes.
    points = fetch_price_range(_FAKE_TOKEN, home_id="home-id-second")
    assert len(points) == 2

    # Selecting the first home (default when home_id=None) returns 1 node.
    points_default = fetch_price_range(_FAKE_TOKEN)
    assert len(points_default) == 1


# ---------------------------------------------------------------------------
# fetch_price_range — authentication errors
# ---------------------------------------------------------------------------


@pytest.mark.parametrize("status_code", [401, 403])
def test_fetch_price_range_auth_error(monkeypatch, status_code):
    """HTTP 401 or 403 raises TibberAuthError (subclass of TibberError)."""
    transport = _make_transport(status_code, {})

    def _patched_post(url, *, json, headers, timeout):  # noqa: A002
        client = httpx.Client(transport=transport)
        return client.post(url, json=json, headers=headers, timeout=timeout)

    monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post)

    with pytest.raises(TibberAuthError):
        fetch_price_range(_FAKE_TOKEN)


def test_fetch_price_range_auth_error_is_tibber_error(monkeypatch):
    """TibberAuthError is a subclass of TibberError for broad catches."""
    transport = _make_transport(401, {})

    def _patched_post(url, *, json, headers, timeout):  # noqa: A002
        client = httpx.Client(transport=transport)
        return client.post(url, json=json, headers=headers, timeout=timeout)

    monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post)

    with pytest.raises(TibberError):
        fetch_price_range(_FAKE_TOKEN)


# ---------------------------------------------------------------------------
# fetch_price_range — network / timeout errors
# ---------------------------------------------------------------------------


def test_fetch_price_range_timeout_raises_tibber_error(monkeypatch):
    """httpx.TimeoutException is wrapped in TibberError."""
    transport = _make_transport(200, raise_timeout=True)

    def _patched_post(url, *, json, headers, timeout):  # noqa: A002
        client = httpx.Client(transport=transport)
        return client.post(url, json=json, headers=headers, timeout=timeout)

    monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post)

    with pytest.raises(TibberError):
        fetch_price_range(_FAKE_TOKEN)


def test_fetch_price_range_timeout_not_auth_error(monkeypatch):
    """A timeout should raise TibberError but NOT TibberAuthError."""
    transport = _make_transport(200, raise_timeout=True)

    def _patched_post(url, *, json, headers, timeout):  # noqa: A002
        client = httpx.Client(transport=transport)
        return client.post(url, json=json, headers=headers, timeout=timeout)

    monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post)

    with pytest.raises(TibberError) as exc_info:
        fetch_price_range(_FAKE_TOKEN)
    assert not isinstance(exc_info.value, TibberAuthError)


def test_fetch_price_range_http_500_raises_tibber_error(monkeypatch):
    """An unexpected 5xx response raises TibberError."""
    transport = _make_transport(500, {"error": "internal"})

    def _patched_post(url, *, json, headers, timeout):  # noqa: A002
        client = httpx.Client(transport=transport)
        return client.post(url, json=json, headers=headers, timeout=timeout)

    monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post)

    with pytest.raises(TibberError):
        fetch_price_range(_FAKE_TOKEN)


# ---------------------------------------------------------------------------
# Token safety — token must not appear in exception messages
# ---------------------------------------------------------------------------


def test_token_not_in_auth_error_message(monkeypatch):
    """The API token must not appear in TibberAuthError's string representation."""
    transport = _make_transport(401, {})

    def _patched_post(url, *, json, headers, timeout):  # noqa: A002
        client = httpx.Client(transport=transport)
        return client.post(url, json=json, headers=headers, timeout=timeout)

    monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post)

    with pytest.raises(TibberAuthError) as exc_info:
        fetch_price_range(_FAKE_TOKEN)

    # The token must not appear anywhere in the exception string.
    assert _FAKE_TOKEN not in str(exc_info.value)
    assert _FAKE_TOKEN not in repr(exc_info.value)


def test_token_not_in_timeout_error_message(monkeypatch):
    """The API token must not appear in TibberError's string representation on timeout."""
    transport = _make_transport(200, raise_timeout=True)

    def _patched_post(url, *, json, headers, timeout):  # noqa: A002
        client = httpx.Client(transport=transport)
        return client.post(url, json=json, headers=headers, timeout=timeout)

    monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post)

    with pytest.raises(TibberError) as exc_info:
        fetch_price_range(_FAKE_TOKEN)

    assert _FAKE_TOKEN not in str(exc_info.value)
    assert _FAKE_TOKEN not in repr(exc_info.value)


# ---------------------------------------------------------------------------
# GraphQL errors in response body
# ---------------------------------------------------------------------------


def test_graphql_errors_raise_tibber_error(monkeypatch):
    """A 200 response that contains a GraphQL 'errors' field raises TibberError."""
    graphql_error_body = {
        "errors": [{"message": "Some GraphQL error"}],
        "data": None,
    }
    transport = _make_transport(200, graphql_error_body)

    def _patched_post(url, *, json, headers, timeout):  # noqa: A002
        client = httpx.Client(transport=transport)
        return client.post(url, json=json, headers=headers, timeout=timeout)

    monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post)

    with pytest.raises(TibberError):
        fetch_price_range(_FAKE_TOKEN)


# ---------------------------------------------------------------------------
# fetch_current_price — happy path
# ---------------------------------------------------------------------------


def test_fetch_current_price_returns_single_price_point(monkeypatch):
    """Successful response returns a single UTC-aware PricePoint."""
    transport = _make_transport(200, _CURRENT_PRICE_RESPONSE)

    def _patched_post(url, *, json, headers, timeout):  # noqa: A002
        client = httpx.Client(transport=transport)
        return client.post(url, json=json, headers=headers, timeout=timeout)

    monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post)

    point = fetch_current_price(_FAKE_TOKEN)

    assert isinstance(point, PricePoint)
    # 2026-06-23T10:00:00+02:00 → 2026-06-23T08:00:00Z
    assert point.starts_at == datetime(2026, 6, 23, 8, 0, 0, tzinfo=UTC)
    assert point.total == pytest.approx(0.28)
    assert point.energy == pytest.approx(0.22)
    assert point.tax == pytest.approx(0.06)
    assert point.currency == "EUR"
    assert point.level == "EXPENSIVE"
    assert point.resolution == "QUARTER_HOURLY"
    # UTC-aware check
    assert point.starts_at.tzinfo is not None


# ---------------------------------------------------------------------------
# fetch_current_price — authentication errors
# ---------------------------------------------------------------------------


def test_fetch_current_price_auth_error(monkeypatch):
    """HTTP 401 on current-price query raises TibberAuthError."""
    transport = _make_transport(401, {})

    def _patched_post(url, *, json, headers, timeout):  # noqa: A002
        client = httpx.Client(transport=transport)
        return client.post(url, json=json, headers=headers, timeout=timeout)

    monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post)

    with pytest.raises(TibberAuthError):
        fetch_current_price(_FAKE_TOKEN)
