"""Tests for boxtime.clients.cointime_client — dual-backend Cointime."""

import datetime
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from boxtime.clients.cointime_client import (
    Cointime,
    DEFAULT_SUPABASE_KEY,
    DEFAULT_SUPABASE_URL,
    _ts_to_date,
)
from boxtime.models.cointime.cointime_data_point import CointimeDataPoint
from boxtime.models.cointime.cointime_series import CointimeSeries


# ── helpers ──────────────────────────────────────────────────────────

def _sb_response(data):
    """Create a fake Supabase APIResponse."""
    resp = MagicMock()
    resp.data = data
    return resp


def _chain(mock_sb, data):
    """Set up a fluent query-builder chain returning *data*."""
    chain = MagicMock()
    chain.select.return_value = chain
    chain.eq.return_value = chain
    chain.gte.return_value = chain
    chain.lte.return_value = chain
    chain.order.return_value = chain
    chain.range.return_value = chain
    chain.maybe_single.return_value = chain
    chain.single.return_value = chain
    chain.execute.return_value = _sb_response(data)
    mock_sb.table.return_value = chain
    return chain


# =====================================================================
# Constructor & backend selection
# =====================================================================


def test_default_constructor():
    ct = Cointime()
    assert ct._use_asyncpg is False
    assert ct._supabase_url == DEFAULT_SUPABASE_URL
    assert ct._supabase_key == DEFAULT_SUPABASE_KEY


def test_asyncpg_constructor():
    ct = Cointime(database_url="postgresql://x:5432/db")
    assert ct._use_asyncpg is True
    assert ct._database_url == "postgresql://x:5432/db"


def test_custom_supabase():
    ct = Cointime(supabase_url="https://custom.supabase.co", supabase_key="k")
    assert ct._use_asyncpg is False
    assert ct._supabase_url == "https://custom.supabase.co"


# =====================================================================
# asyncpg backend
# =====================================================================


async def test_pg_coinblocks_created(pg_cointime, mock_pool):
    mock_pool.fetchrow.return_value = {"cbc": 50_000}
    result = await pg_cointime._pg_coinblocks_created(1000)
    assert result == 50_000


async def test_pg_coinblocks_created_missing(pg_cointime, mock_pool):
    mock_pool.fetchrow.return_value = None
    result = await pg_cointime._pg_coinblocks_created(999)
    assert result is None


async def test_pg_total_coinblocks_created(pg_cointime, mock_pool):
    mock_pool.fetchrow.return_value = {"total": 100_000}
    result = await pg_cointime._pg_total_coinblocks_created(0, 100)
    assert result == 100_000


async def test_pg_total_coinblocks_created_empty(pg_cointime, mock_pool):
    mock_pool.fetchrow.return_value = {"total": None}
    result = await pg_cointime._pg_total_coinblocks_created(0, 100)
    assert result is None


async def test_pg_coinblocks_destroyed(pg_cointime, mock_pool):
    mock_pool.fetchrow.return_value = {"cbd": 5_000}
    result = await pg_cointime._pg_coinblocks_destroyed(1000)
    assert result == 5_000


async def test_pg_total_coinblocks_destroyed(pg_cointime, mock_pool):
    mock_pool.fetchrow.return_value = {"total": 50_000}
    result = await pg_cointime._pg_total_coinblocks_destroyed(0, 100)
    assert result == 50_000


async def test_pg_coinblocks_stored(pg_cointime, mock_pool):
    mock_pool.fetchrow.return_value = {"cbs": 45_000}
    result = await pg_cointime._pg_coinblocks_stored(1000)
    assert result == 45_000


async def test_pg_total_coinblocks_stored(pg_cointime, mock_pool):
    mock_pool.fetchrow.return_value = {"total": 90_000}
    result = await pg_cointime._pg_total_coinblocks_stored(0, 100)
    assert result == 90_000


async def test_pg_get_price(pg_cointime, mock_pool):
    mock_pool.fetchrow.return_value = {"price_usd": 1.23}
    result = await pg_cointime._pg_get_price(datetime.date(2024, 1, 1))
    assert result == 1.23


async def test_pg_get_price_missing(pg_cointime, mock_pool):
    mock_pool.fetchrow.return_value = None
    result = await pg_cointime._pg_get_price(datetime.date(2020, 1, 1))
    assert result is None


async def test_pg_get_price_range(pg_cointime, mock_pool):
    mock_pool.fetch.return_value = [
        {"date": datetime.date(2024, 1, 1), "price_usd": 1.5},
        {"date": datetime.date(2024, 1, 2), "price_usd": 1.6},
    ]
    result = await pg_cointime._pg_get_price_range(
        datetime.date(2024, 1, 1), datetime.date(2024, 1, 2)
    )
    assert len(result) == 2
    assert result[0] == (datetime.date(2024, 1, 1), 1.5)


async def test_pg_get_price_range_empty(pg_cointime, mock_pool):
    mock_pool.fetch.return_value = []
    result = await pg_cointime._pg_get_price_range(
        datetime.date(2020, 1, 1), datetime.date(2020, 1, 2)
    )
    assert result is None


async def test_pg_get_coinblocks_no_price(pg_cointime, mock_pool):
    """Test per-height data without price (default)."""
    mock_pool.fetch.return_value = [
        {"height": 720, "timestamp": 1700000000000, "value": 50_000},
        {"height": 721, "timestamp": 1700000100000, "value": 50_100},
    ]
    result = await pg_cointime._pg_get_coinblocks("cbc", 0, 1000, with_price=False)
    assert isinstance(result, CointimeSeries)
    assert len(result) == 2
    assert result.prices is None
    assert result.heights == [720, 721]
    assert result.values == [50_000, 50_100]
    # Test iteration
    points = list(result)
    assert isinstance(points[0], CointimeDataPoint)
    assert points[0].price is None


async def test_pg_get_coinblocks_with_price(pg_cointime, mock_pool):
    """Test daily grouping with price enrichment."""
    # Two blocks on same day, one on next day
    mock_pool.fetch.return_value = [
        {"height": 720, "timestamp": 1700000000000, "value": 50_000},  # 2023-11-14
        {"height": 721, "timestamp": 1700003600000, "value": 50_100},  # 2023-11-14 (same day)
        {"height": 730, "timestamp": 1700086400000, "value": 51_000},  # 2023-11-15
    ]
    mock_pool.fetch.side_effect = [
        mock_pool.fetch.return_value,  # First call for cointime rows
        [
            {"date": datetime.date(2023, 11, 14), "price_usd": 1.5},
            {"date": datetime.date(2023, 11, 15), "price_usd": 1.6},
        ],  # Second call for prices
    ]
    result = await pg_cointime._pg_get_coinblocks("cbc", 0, 1000, with_price=True)
    assert isinstance(result, CointimeSeries)
    assert len(result) == 2  # One per day
    assert result.heights == [720, 730]  # First block of each day
    assert result.prices == [1.5, 1.6]
    # Test iteration
    points = list(result)
    assert points[0].height == 720
    assert points[0].price == 1.5


async def test_pg_get_coinblocks_empty(pg_cointime, mock_pool):
    mock_pool.fetch.return_value = []
    result = await pg_cointime._pg_get_coinblocks("cbc", 0, 100)
    assert result is None


async def test_pg_get_max_height(pg_cointime, mock_pool):
    mock_pool.fetchrow.return_value = {"max_h": 1_200_000}
    result = await pg_cointime._pg_get_max_height()
    assert result == 1_200_000


async def test_pg_get_max_height_empty(pg_cointime, mock_pool):
    mock_pool.fetchrow.return_value = {"max_h": None}
    result = await pg_cointime._pg_get_max_height()
    assert result is None


# =====================================================================
# Supabase backend
# =====================================================================


def test_sb_coinblocks_created(sb_cointime, mock_sb):
    _chain(mock_sb, {"cbc": 50_000})
    result = sb_cointime.coinblocks_created(1000)
    assert result == 50_000


def test_sb_coinblocks_created_missing(sb_cointime, mock_sb):
    _chain(mock_sb, None)
    result = sb_cointime.coinblocks_created(999)
    assert result is None


def test_sb_total_coinblocks_created(sb_cointime, mock_sb):
    _chain(mock_sb, [{"sum": 100_000}])
    result = sb_cointime.total_coinblocks_created(0, 100)
    assert result == 100_000


def test_sb_total_coinblocks_created_empty(sb_cointime, mock_sb):
    _chain(mock_sb, [])
    result = sb_cointime.total_coinblocks_created(0, 100)
    assert result is None


def test_sb_coinblocks_destroyed(sb_cointime, mock_sb):
    _chain(mock_sb, {"cbd": 5_000})
    result = sb_cointime.coinblocks_destroyed(1000)
    assert result == 5_000


def test_sb_total_coinblocks_destroyed(sb_cointime, mock_sb):
    _chain(mock_sb, [{"sum": 50_000}])
    result = sb_cointime.total_coinblocks_destroyed(0, 100)
    assert result == 50_000


def test_sb_coinblocks_stored(sb_cointime, mock_sb):
    _chain(mock_sb, {"cbs": 45_000})
    result = sb_cointime.coinblocks_stored(1000)
    assert result == 45_000


def test_sb_total_coinblocks_stored(sb_cointime, mock_sb):
    _chain(mock_sb, [{"sum": 90_000}])
    result = sb_cointime.total_coinblocks_stored(0, 100)
    assert result == 90_000


def test_sb_get_price(sb_cointime, mock_sb):
    _chain(mock_sb, {"price_usd": 1.23})
    result = sb_cointime.get_price(datetime.date(2024, 1, 1))
    assert result == 1.23


def test_sb_get_price_missing(sb_cointime, mock_sb):
    _chain(mock_sb, None)
    result = sb_cointime.get_price(datetime.date(2020, 1, 1))
    assert result is None


def test_sb_get_price_range(sb_cointime, mock_sb):
    _chain(mock_sb, [
        {"date": "2024-01-01", "price_usd": 1.5},
        {"date": "2024-01-02", "price_usd": 1.6},
    ])
    result = sb_cointime.get_price_range(
        datetime.date(2024, 1, 1), datetime.date(2024, 1, 2)
    )
    assert len(result) == 2
    assert result[0] == (datetime.date(2024, 1, 1), 1.5)


def test_sb_get_price_range_empty(sb_cointime, mock_sb):
    _chain(mock_sb, [])
    result = sb_cointime.get_price_range(
        datetime.date(2020, 1, 1), datetime.date(2020, 1, 2)
    )
    assert result is None


def test_sb_get_max_height(sb_cointime, mock_sb):
    _chain(mock_sb, [{"max": 1_200_000}])
    result = sb_cointime.get_max_height()
    assert result == 1_200_000


def test_sb_get_max_height_empty(sb_cointime, mock_sb):
    _chain(mock_sb, [])
    result = sb_cointime.get_max_height()
    assert result is None


# =====================================================================
# Supabase price-enriched (Python-side grouping)
# =====================================================================


def test_sb_get_coinblocks_no_price(sb_cointime, mock_sb):
    """Supabase path: per-height data without price (default)."""
    chain = _chain(mock_sb, [
        {"height": 720, "timestamp": 1700000000000, "cbc": 50_000},
        {"height": 721, "timestamp": 1700000100000, "cbc": 50_100},
    ])
    result = sb_cointime.get_coinblocks_created(0, 1000, with_price=False)
    assert isinstance(result, CointimeSeries)
    assert len(result) == 2
    assert result.prices is None
    assert result.heights == [720, 721]
    assert result.values == [50_000, 50_100]


def test_sb_get_coinblocks_with_price(sb_cointime, mock_sb):
    """Supabase path: daily grouping with price enrichment."""
    call_count = [0]

    def side_effect(table_name):
        call_count[0] += 1
        chain = MagicMock()
        chain.select.return_value = chain
        chain.eq.return_value = chain
        chain.gte.return_value = chain
        chain.lte.return_value = chain
        chain.order.return_value = chain
        chain.range.return_value = chain
        chain.maybe_single.return_value = chain

        if table_name == "cointime":
            # Two blocks on same day, one on next day
            chain.execute.return_value = _sb_response([
                {"height": 720, "timestamp": 1700000000000, "cbc": 50_000},  # 2023-11-14
                {"height": 721, "timestamp": 1700003600000, "cbc": 50_100},  # 2023-11-14
                {"height": 730, "timestamp": 1700086400000, "cbc": 51_000},  # 2023-11-15
            ])
        else:  # erg_prices
            chain.execute.return_value = _sb_response([
                {"date": "2023-11-14", "price_usd": 1.5},
                {"date": "2023-11-15", "price_usd": 1.6},
            ])
        return chain

    mock_sb.table.side_effect = side_effect
    result = sb_cointime.get_coinblocks_created(0, 1000, with_price=True)
    assert isinstance(result, CointimeSeries)
    assert len(result) == 2  # One per day
    assert result.heights == [720, 730]  # First block of each day
    assert result.prices == [1.5, 1.6]


def test_sb_get_coinblocks_empty(sb_cointime, mock_sb):
    _chain(mock_sb, [])
    result = sb_cointime.get_coinblocks_created(0, 100)
    assert result is None


# =====================================================================
# Sync wrappers (asyncpg path)
# =====================================================================


def test_pg_coinblocks_created_sync(pg_cointime, mock_pool):
    mock_pool.fetchrow.return_value = {"cbc": 42}
    result = pg_cointime.coinblocks_created(100)
    assert result == 42


def test_pg_total_coinblocks_created_sync(pg_cointime, mock_pool):
    mock_pool.fetchrow.return_value = {"total": 999}
    result = pg_cointime.total_coinblocks_created(0, 10)
    assert result == 999


def test_pg_coinblocks_destroyed_sync(pg_cointime, mock_pool):
    mock_pool.fetchrow.return_value = {"cbd": 7}
    result = pg_cointime.coinblocks_destroyed(100)
    assert result == 7


def test_pg_coinblocks_stored_sync(pg_cointime, mock_pool):
    mock_pool.fetchrow.return_value = {"cbs": 35}
    result = pg_cointime.coinblocks_stored(100)
    assert result == 35


def test_pg_get_price_sync(pg_cointime, mock_pool):
    mock_pool.fetchrow.return_value = {"price_usd": 2.0}
    result = pg_cointime.get_price(datetime.date(2024, 6, 1))
    assert result == 2.0


def test_pg_get_max_height_sync(pg_cointime, mock_pool):
    mock_pool.fetchrow.return_value = {"max_h": 500_000}
    result = pg_cointime.get_max_height()
    assert result == 500_000


# =====================================================================
# close()
# =====================================================================


def test_close_asyncpg(pg_cointime, mock_pool):
    pg_cointime.close()
    assert pg_cointime._pool is None


def test_close_supabase(sb_cointime, mock_sb):
    sb_cointime.close()
    assert sb_cointime._sb is None


def test_close_no_backend():
    ct = Cointime()
    ct.close()  # should not raise


# =====================================================================
# _ts_to_date helper
# =====================================================================


def test_ts_to_date():
    d = _ts_to_date(1700000000000)
    assert isinstance(d, datetime.date)
    assert d == datetime.date(2023, 11, 14)


# =====================================================================
# CointimeDataPoint model
# =====================================================================


def test_cointime_data_point():
    dp = CointimeDataPoint(
        height=1000, timestamp=1700000000000, value=50_000, price=1.5
    )
    assert dp.height == 1000
    assert dp.value == 50_000
    assert dp.price == 1.5


def test_cointime_data_point_optional_price():
    """Test that price defaults to None."""
    dp = CointimeDataPoint(
        height=1000, timestamp=1700000000000, value=50_000
    )
    assert dp.height == 1000
    assert dp.value == 50_000
    assert dp.price is None


# =====================================================================
# Lazy init
# =====================================================================


async def test_lazy_pool_init():
    ct = Cointime(database_url="postgresql://test:5432/test")
    assert ct._pool is None
    fake_pool = AsyncMock()
    with patch(
        "boxtime.clients.cointime_client.asyncpg.create_pool",
        new_callable=AsyncMock,
        return_value=fake_pool,
    ) as mock_create:
        pool = await ct._get_pool()
        assert pool is fake_pool
        mock_create.assert_called_once()
        pool2 = await ct._get_pool()
        assert pool2 is fake_pool
        assert mock_create.call_count == 1


def test_lazy_sb_init():
    ct = Cointime()
    assert ct._sb is None
    with patch(
        "boxtime.clients.cointime_client.create_client",
        return_value=MagicMock(),
    ) as mock_create:
        sb = ct._get_sb()
        assert sb is not None
        mock_create.assert_called_once_with(DEFAULT_SUPABASE_URL, DEFAULT_SUPABASE_KEY)
        sb2 = ct._get_sb()
        assert sb2 is sb
        assert mock_create.call_count == 1


# =====================================================================
# CointimeSeries model
# =====================================================================


def test_cointime_series():
    """Test CointimeSeries creation and properties."""
    series = CointimeSeries(
        heights=[100, 200, 300],
        timestamps=[1700000000000, 1700003600000, 1700007200000],
        values=[1000, 2000, 3000],
        prices=[1.5, 1.6, 1.7],
    )
    assert len(series) == 3
    assert series.heights == [100, 200, 300]
    assert series.values == [1000, 2000, 3000]
    assert series.prices == [1.5, 1.6, 1.7]
    # Test iteration
    points = list(series)
    assert len(points) == 3
    assert isinstance(points[0], CointimeDataPoint)
    assert points[0].height == 100
    assert points[0].price == 1.5


def test_cointime_series_no_prices():
    """Test CointimeSeries without prices."""
    series = CointimeSeries(
        heights=[100, 200],
        timestamps=[1700000000000, 1700003600000],
        values=[1000, 2000],
    )
    assert series.prices is None
    points = list(series)
    assert all(p.price is None for p in points)


def test_cointime_series_dates_property():
    """Test CointimeSeries dates property."""
    series = CointimeSeries(
        heights=[100],
        timestamps=[1700000000000],  # 2023-11-14
        values=[1000],
    )
    dates = series.dates
    assert len(dates) == 1
    assert dates[0] == datetime.date(2023, 11, 14)


# =====================================================================
# Cumulative total methods - asyncpg
# =====================================================================


async def test_pg_get_total_coinblocks_no_price(pg_cointime, mock_pool):
    """Test cumulative coinblocks without price."""
    # First call: baseline query, second: rows in range
    mock_pool.fetchrow.return_value = {"baseline": 100_000}
    mock_pool.fetch.return_value = [
        {"height": 100, "timestamp": 1700000000000, "value": 50_000},
        {"height": 101, "timestamp": 1700000100000, "value": 50_100},
    ]
    result = await pg_cointime._pg_get_total_coinblocks("cbc", 100, 101, with_price=False)
    assert isinstance(result, CointimeSeries)
    assert len(result) == 2
    # Cumulative: baseline + value at each height
    assert result.values == [150_000, 200_100]  # 100k + 50k, then + 50.1k
    assert result.prices is None


async def test_pg_get_total_coinblocks_with_price(pg_cointime, mock_pool):
    """Test cumulative coinblocks with daily grouping and price."""
    # Baseline + rows + prices
    mock_pool.fetchrow.return_value = {"baseline": 0}
    mock_pool.fetch.side_effect = [
        [  # cointime rows - two days
            {"height": 100, "timestamp": 1700000000000, "value": 10_000},  # day 1
            {"height": 110, "timestamp": 1700086400000, "value": 20_000},  # day 2
        ],
        [  # prices
            {"date": datetime.date(2023, 11, 14), "price_usd": 1.5},
            {"date": datetime.date(2023, 11, 15), "price_usd": 1.6},
        ],
    ]
    result = await pg_cointime._pg_get_total_coinblocks("cbc", 100, 110, with_price=True)
    assert isinstance(result, CointimeSeries)
    assert len(result) == 2  # One per day
    assert result.heights == [100, 110]
    assert result.values == [10_000, 30_000]  # Cumulative
    assert result.prices == [1.5, 1.6]


async def test_pg_get_total_coinblocks_missing_price(pg_cointime, mock_pool):
    """Test that prices is None when any price is missing."""
    mock_pool.fetchrow.return_value = {"baseline": 0}
    mock_pool.fetch.side_effect = [
        [
            {"height": 100, "timestamp": 1700000000000, "value": 10_000},
            {"height": 110, "timestamp": 1700086400000, "value": 20_000},
        ],
        [  # Only one price available
            {"date": datetime.date(2023, 11, 14), "price_usd": 1.5},
        ],
    ]
    result = await pg_cointime._pg_get_total_coinblocks("cbc", 100, 110, with_price=True)
    assert result.prices is None  # Entire list is None if any missing


# =====================================================================
# Cumulative total methods - Supabase
# =====================================================================


def test_sb_get_total_coinblocks_no_price(sb_cointime, mock_sb):
    """Test cumulative coinblocks via Supabase without price."""
    call_count = [0]

    def side_effect(table_name):
        call_count[0] += 1
        chain = MagicMock()
        chain.select.return_value = chain
        chain.eq.return_value = chain
        chain.gte.return_value = chain
        chain.lte.return_value = chain
        chain.order.return_value = chain
        chain.range.return_value = chain
        chain.maybe_single.return_value = chain

        if table_name == "cointime":
            if call_count[0] == 1:  # First call: aggregate for baseline
                chain.execute.return_value = _sb_response([{"sum": 100_000}])
            else:  # Second call: rows
                chain.execute.return_value = _sb_response([
                    {"height": 100, "timestamp": 1700000000000, "cbc": 50_000},
                    {"height": 101, "timestamp": 1700000100000, "cbc": 50_100},
                ])
        return chain

    mock_sb.table.side_effect = side_effect
    result = sb_cointime.get_total_coinblocks_created(100, 101, with_price=False)
    assert isinstance(result, CointimeSeries)
    assert result.values == [150_000, 200_100]


def test_sb_get_total_coinblocks_with_price(sb_cointime, mock_sb):
    """Test cumulative coinblocks via Supabase with price."""
    call_count = [0]

    def side_effect(table_name):
        call_count[0] += 1
        chain = MagicMock()
        chain.select.return_value = chain
        chain.eq.return_value = chain
        chain.gte.return_value = chain
        chain.lte.return_value = chain
        chain.order.return_value = chain
        chain.range.return_value = chain
        chain.maybe_single.return_value = chain

        if table_name == "cointime":
            if call_count[0] == 1:  # Baseline
                chain.execute.return_value = _sb_response([{"sum": 0}])
            else:  # Rows
                chain.execute.return_value = _sb_response([
                    {"height": 100, "timestamp": 1700000000000, "cbc": 10_000},
                    {"height": 110, "timestamp": 1700086400000, "cbc": 20_000},
                ])
        else:  # erg_prices
            chain.execute.return_value = _sb_response([
                {"date": "2023-11-14", "price_usd": 1.5},
                {"date": "2023-11-15", "price_usd": 1.6},
            ])
        return chain

    mock_sb.table.side_effect = side_effect
    result = sb_cointime.get_total_coinblocks_created(100, 110, with_price=True)
    assert isinstance(result, CointimeSeries)
    assert len(result) == 2
    assert result.values == [10_000, 30_000]
    assert result.prices == [1.5, 1.6]
