"""Tests for the Tibber electricity price provider."""

import json
from unittest.mock import Mock, patch

import numpy as np
import pandas as pd
import pytest
import pytest_asyncio

from akkudoktoreos.core.cache import CacheFileStore
from akkudoktoreos.prediction.elecprice import ElecPriceCommonSettings
from akkudoktoreos.prediction.elecpricetibber import (
    TIBBER_PRICE_QUERY_QUARTER_HOURLY,
    ElecPriceTibber,
    ElecPriceTibberCommonSettings,
    TibberGraphQLResponse,
)
from akkudoktoreos.utils.datetimeutil import to_datetime


class _FakeEms:
    start_datetime = to_datetime("2026-07-09T00:00:00+00:00")


def _price(starts_at: str, total: float) -> dict[str, object]:
    return {"startsAt": starts_at, "total": total}


def _tibber_payload(
    prices: list[dict[str, object]],
    *,
    home_id: str = "home-1",
    include_other_home: bool = False,
    include_history_range: bool = True,
) -> dict[str, object]:
    homes: list[dict[str, object]] = []
    if include_other_home:
        homes.append(
            {
                "id": "other-home",
                "currentSubscription": {
                    "priceInfo": {"today": [_price("2026-07-09T00:00:00+00:00", 0.999)]}
                },
            }
        )

    subscription: dict[str, object] = {"priceInfo": {"today": prices[:2], "tomorrow": prices[2:]}}
    if include_history_range:
        subscription["priceInfoRange"] = {"nodes": prices}

    homes.append({"id": home_id, "currentSubscription": subscription})
    return {"data": {"viewer": {"homes": homes}}}


@pytest.fixture
def provider(config_eos):
    """Create a fresh Tibber electricity price provider."""
    config_eos.elecprice = ElecPriceCommonSettings(
        provider="ElecPriceTibber",
        tibber=ElecPriceTibberCommonSettings(access_token="token-123", home_id="home-1"),
    )
    config_eos.prediction.hours = 6
    provider = ElecPriceTibber()
    assert provider.enabled()
    provider._db_reset_state()
    return provider


@pytest.fixture
def tibber_provider(provider, monkeypatch):
    """Create a Tibber provider with a deterministic EMS start time."""
    monkeypatch.setattr("akkudoktoreos.core.coreabc.get_ems", lambda: _FakeEms())
    return provider


@pytest.fixture
def cache_store():
    """Create a cache store for tests that touch cached methods."""
    return CacheFileStore()


@pytest.fixture
def tibber_response_dict():
    """Sample Tibber GraphQL response."""
    return _tibber_payload(
        [
            _price("2026-07-07T01:00:00.000+02:00", 0.2970716),
            _price("2026-07-07T00:00:00.000+02:00", 0.3109662),
            _price("2026-07-08T00:00:00.000+02:00", 0.30468),
        ],
        include_other_home=True,
    )


@pytest.fixture
def tibber_response(tibber_response_dict):
    """Validated sample Tibber GraphQL response."""
    return TibberGraphQLResponse.model_validate(tibber_response_dict)


class TestElecPriceTibber:
    """Tests for ElecPriceTibber provider."""

    def test_provider_id(self, provider):
        """Provider ID is stable."""
        assert provider.provider_id() == "ElecPriceTibber"


    def test_enabled_only_for_configured_provider(self, provider, config_eos):
        """Provider is enabled only when configured as active elecprice provider."""
        assert provider.enabled()

        config_eos.elecprice.provider = "ElecPriceFixed"

        assert not provider.enabled()


    def test_config_structure_accepts_tibber_settings(self):
        """The requested nested Tibber config structure is accepted."""
        settings = ElecPriceCommonSettings.model_validate(
            {
                "provider": "ElecPriceTibber",
                "tibber": {
                    "access_token": "token-123",
                    "home_id": "home-1",
                },
            }
        )

        assert settings.provider == "ElecPriceTibber"
        assert settings.tibber.access_token == "token-123"
        assert settings.tibber.home_id == "home-1"


    def test_missing_access_token_raises(self, provider, config_eos):
        """A Tibber access token is required before making requests."""
        config_eos.elecprice.tibber.access_token = None

        with pytest.raises(ValueError, match="Tibber access_token is required"):
            provider._request_forecast(force_update=True)


    def test_select_home_uses_first_subscription_when_home_id_is_omitted(
        self, provider, config_eos, tibber_response
    ):
        """If no home id is configured, the first subscribed Tibber home is used."""
        config_eos.elecprice.tibber.home_id = None

        home = provider._select_home(tibber_response)

        assert home.id == "other-home"


    def test_graphql_errors_raise(self, provider):
        """GraphQL errors are surfaced as ValueError."""
        with pytest.raises(ValueError, match="Tibber GraphQL error"):
            provider._validate_data(json.dumps({"errors": [{"message": "Authentication failed"}]}))


    def test_unknown_home_id_raises(self, provider, config_eos, tibber_response):
        """Configured home id must exist in the Tibber response."""
        config_eos.elecprice.tibber.home_id = "missing-home"

        with pytest.raises(ValueError, match="Tibber home_id not found"):
            provider._select_home(tibber_response)


    def test_parse_data_combines_sorts_and_converts_total(self, provider, tibber_response):
        """Today, tomorrow, and history prices are sorted and converted to EUR/Wh."""
        series = provider._parse_data(tibber_response)

        assert list(series.index) == [
            to_datetime("2026-07-07T00:00:00.000+02:00", in_timezone="Europe/Berlin"),
            to_datetime("2026-07-07T01:00:00.000+02:00", in_timezone="Europe/Berlin"),
            to_datetime("2026-07-08T00:00:00.000+02:00", in_timezone="Europe/Berlin"),
        ]
        assert series.iloc[0] == pytest.approx(0.0003109662)
        assert series.iloc[1] == pytest.approx(0.0002970716)
        assert series.iloc[2] == pytest.approx(0.00030468)


    def test_tibber_normalize_series_preserves_quarter_hour_resolution(self, provider):
        """Quarter-hour Tibber prices keep their native 15-min resolution (no averaging).

        EOS resamples onto the optimization grid on demand, so the provider must store the
        native step size instead of pre-aggregating quarter-hour prices to hourly values.
        """
        index = pd.date_range("2026-07-09T00:00:00+00:00", periods=8, freq="15min")
        values = [0.10, 0.30, 0.50, 0.70, 1.0, 1.4, 1.8, 2.2]
        series = pd.Series(values, index=index)

        normalized = provider._normalize_series(series)

        # Every 15-min point survives, values untouched, still on a 15-min grid.
        assert normalized.tolist() == pytest.approx(values)
        deltas = normalized.index.to_series().diff().dropna().dt.total_seconds().unique().tolist()
        assert deltas == [900.0]
        assert provider._resolution_seconds(normalized) == 900


    def test_tibber_normalize_series_deduplicates_timestamps(self, provider):
        """Duplicate timestamps are collapsed (mean) without changing the resolution."""
        index = pd.DatetimeIndex(
            [
                "2026-07-09T00:00:00+00:00",
                "2026-07-09T00:00:00+00:00",
                "2026-07-09T01:00:00+00:00",
            ]
        )
        series = pd.Series([0.10, 0.30, 0.50], index=index)

        normalized = provider._normalize_series(series)

        assert len(normalized) == 2
        assert normalized.iloc[0] == pytest.approx(0.20)
        assert normalized.iloc[1] == pytest.approx(0.50)


    def test_empty_tomorrow_stores_only_today_and_warns(self, provider):
        """An empty tomorrow list does not create fake values before forecasting."""
        response = TibberGraphQLResponse.model_validate(
            _tibber_payload([_price("2026-07-07T00:00:00.000+02:00", 0.3109662)])
        )

        with patch("akkudoktoreos.prediction.elecpricetibber.logger.warning") as mock_warning:
            series = provider._parse_data(response)

        assert len(series) == 1
        mock_warning.assert_called_once_with("Tibber tomorrow prices not available yet")


    @patch("requests.post")
    def test_request_forecast_uses_tibber_graphql_api(
        self,
        mock_post,
        provider,
        tibber_response_dict,
        cache_store,
    ):
        """Request uses Tibber URL, bearer token, and GraphQL query body."""
        cache_store.clear(clear_all=True)
        mock_response = Mock()
        mock_response.content = json.dumps(tibber_response_dict).encode()
        mock_post.return_value = mock_response

        response = provider._request_forecast(force_update=True)

        assert isinstance(response, TibberGraphQLResponse)
        mock_post.assert_called_once()

        _, kwargs = mock_post.call_args
        assert mock_post.call_args.args[0] == "https://api.tibber.com/v1-beta/gql"
        assert kwargs["headers"]["Authorization"] == "Bearer token-123"
        assert kwargs["headers"]["Content-Type"] == "application/json"
        assert "query" in kwargs["json"]
        assert "TibberPriceInfo" in kwargs["json"]["query"]
        assert "priceInfoRange" in kwargs["json"]["query"]
        assert "QUARTER_HOURLY" in kwargs["json"]["query"]
        assert "total" in kwargs["json"]["query"]
        assert "energy" in kwargs["json"]["query"]
        assert kwargs["timeout"] == 30


    def test_quarter_hour_query_sets_resolution_on_price_info(self):
        """Tibber defines resolution on priceInfo, not on today or tomorrow."""
        compact_query = " ".join(TIBBER_PRICE_QUERY_QUARTER_HOURLY.split())

        assert "priceInfo(resolution: QUARTER_HOURLY)" in compact_query
        assert "today(resolution:" not in compact_query
        assert "tomorrow(resolution:" not in compact_query
        assert "priceInfoRange(resolution: QUARTER_HOURLY, last: 672)" in compact_query


    @pytest.mark.asyncio
    async def test_tibber_update_extrapolates_missing_hours_with_seasonal_history(
        self, tibber_provider, monkeypatch
    ):
        """Missing Tibber future hours are forecast from seasonal price history."""
        data = TibberGraphQLResponse.model_validate(
            _tibber_payload(
                [
                    _price("2026-07-09T00:00:00+00:00", 0.30),
                    _price("2026-07-09T01:00:00+00:00", 0.42),
                    _price("2026-07-09T02:00:00+00:00", 0.36),
                ]
            )
        )
        monkeypatch.setattr(tibber_provider, "_request_forecast", lambda **_: data)
        monkeypatch.setattr(
            tibber_provider,
            "_predict_ets",
            lambda history, seasonal_periods, hours: np.full(hours, 0.0005),
        )

        history = pd.Series(
            data=np.linspace(0.0002, 0.0004, 169),
            index=pd.date_range("2026-07-01T23:00:00+00:00", periods=169, freq="1h"),
        )
        await tibber_provider.key_from_series("elecprice_marketprice_wh", history)

        await tibber_provider._update_data(force_update=True)

        prices = await tibber_provider.key_to_array(
            key="elecprice_marketprice_wh",
            start_datetime=to_datetime("2026-07-09T00:00:00+00:00"),
            end_datetime=to_datetime("2026-07-09T06:00:00+00:00"),
            fill_method="ffill",
        )

        assert prices.tolist() == pytest.approx([0.0003, 0.00042, 0.00036, 0.0005, 0.0005, 0.0005])

    @pytest.mark.asyncio
    async def test_tibber_update_uses_eos_storage_history_when_api_history_is_missing(
        self, tibber_provider, monkeypatch
    ):
        """Stored EOS price history can provide enough data for weekly seasonal ETS."""
        data = TibberGraphQLResponse.model_validate(
            _tibber_payload(
                [
                    _price("2026-07-09T00:00:00+00:00", 0.30),
                    _price("2026-07-09T01:00:00+00:00", 0.42),
                    _price("2026-07-09T02:00:00+00:00", 0.36),
                ],
                include_history_range=False,
            )
        )
        monkeypatch.setattr(tibber_provider, "_request_forecast", lambda **_: data)
        forecast_call = {}

        def fake_predict_ets(history, seasonal_periods, hours):
            forecast_call["seasonal_periods"] = seasonal_periods
            forecast_call["history_hours"] = len(history)
            return np.full(hours, 0.0007)

        monkeypatch.setattr(tibber_provider, "_predict_ets", fake_predict_ets)

        stored_history = pd.Series(
            data=np.linspace(0.0002, 0.0004, 900),
            index=pd.date_range("2026-06-01T00:00:00+00:00", periods=900, freq="1h"),
        )
        await tibber_provider.key_from_series("elecprice_marketprice_wh", stored_history)

        await tibber_provider._update_data(force_update=True)

        assert forecast_call["seasonal_periods"] == 168
        assert forecast_call["history_hours"] > 840

    @pytest.mark.asyncio
    async def test_tibber_update_preserves_quarter_hour_resolution_and_slots(self, tibber_provider, monkeypatch):
        """15-minute Tibber prices are stored natively and extrapolated on the slot grid.

        Proves the resolution-agnostic path: (a) the native 15-min resolution survives
        storage, (b) the ETS extrapolation scales the seasonal window into slots
        (daily-only history -> 24*4 = 96 seasonal periods), and (c) the forecast index is
        spaced at 15-minute steps.
        """
        data = TibberGraphQLResponse.model_validate(
            _tibber_payload(
                [
                    _price("2026-07-09T00:00:00+00:00", 0.30),
                    _price("2026-07-09T00:15:00+00:00", 0.42),
                    _price("2026-07-09T00:30:00+00:00", 0.36),
                ],
                include_history_range=False,
            )
        )
        monkeypatch.setattr(tibber_provider, "_request_forecast", lambda **_: data)
        forecast_call = {}

        def fake_predict_ets(history, seasonal_periods, hours):
            forecast_call["seasonal_periods"] = seasonal_periods
            forecast_call["history_slots"] = len(history)
            forecast_call["forecast_slots"] = hours
            return np.full(hours, 0.0009)

        monkeypatch.setattr(tibber_provider, "_predict_ets", fake_predict_ets)

        # A bit more than one week of quarter-hour history: enough for the daily seasonal
        # window (> 24*7*4 = 672 slots) but below the weekly one (<= 24*35*4 = 3360 slots).
        stored_history = pd.Series(
            data=np.linspace(0.0002, 0.0004, 800),
            index=pd.date_range("2026-07-01T00:00:00+00:00", periods=800, freq="15min"),
        )
        await tibber_provider.key_from_series("elecprice_marketprice_wh", stored_history)

        await tibber_provider._update_data(force_update=True)

        # (b) Daily seasonal window scaled into 15-min slots.
        assert forecast_call["seasonal_periods"] == 96
        assert 672 < forecast_call["history_slots"] <= 3360
        # prediction.hours (6) * slots_per_hour (4) - covered slots (2 -> 00:00..00:30) = 22
        assert forecast_call["forecast_slots"] == 22

        # (a)+(c) Stored records keep the native 15-min grid across today and the forecast.
        stored = await tibber_provider.key_to_raw_series(
            "elecprice_marketprice_wh",
            start_datetime=to_datetime("2026-07-09T00:00:00+00:00"),
            end_datetime=to_datetime("2026-07-09T06:15:00+00:00"),
        )
        steps = stored.index.to_series().diff().dropna().dt.total_seconds().unique().tolist()
        assert steps == [900.0]
        # 00:00..06:00 inclusive at 15-min steps = 25 points (3 API + 22 forecast).
        assert len(stored) == 25
