"""Tests for chart helper functions in charts.py."""

import json
from datetime import datetime, timedelta

import matplotlib.dates as mdates
import matplotlib.pyplot as plt
import pytest

from meshmon.charts import (
    CHART_THEMES,
    PERIOD_CONFIG,
    ChartStatistics,
    DataPoint,
    TimeSeries,
    _aggregate_bins,
    _configure_x_axis,
    _hex_to_rgba,
    _inject_data_attributes,
    calculate_statistics,
)

BASE_TIME = datetime(2024, 1, 1, 12, 0, 0)
BASE_DAY_START = datetime(2024, 1, 1, 0, 0, 0)


class TestHexToRgba:
    """Test _hex_to_rgba function."""

    def test_6_char_hex(self):
        """6-character hex (RGB) converts with alpha 1.0."""
        r, g, b, a = _hex_to_rgba("ff0000")
        assert r == pytest.approx(1.0)
        assert g == pytest.approx(0.0)
        assert b == pytest.approx(0.0)
        assert a == pytest.approx(1.0)

    def test_8_char_hex(self):
        """8-character hex (RGBA) converts with proper alpha."""
        r, g, b, a = _hex_to_rgba("ff000080")  # Red with 50% alpha
        assert r == pytest.approx(1.0)
        assert g == pytest.approx(0.0)
        assert b == pytest.approx(0.0)
        assert a == pytest.approx(128 / 255)

    def test_white(self):
        """White color converts correctly."""
        r, g, b, a = _hex_to_rgba("ffffff")
        assert r == pytest.approx(1.0)
        assert g == pytest.approx(1.0)
        assert b == pytest.approx(1.0)
        assert a == pytest.approx(1.0)

    def test_black(self):
        """Black color converts correctly."""
        r, g, b, a = _hex_to_rgba("000000")
        assert r == pytest.approx(0.0)
        assert g == pytest.approx(0.0)
        assert b == pytest.approx(0.0)
        assert a == pytest.approx(1.0)

    def test_transparent(self):
        """Fully transparent converts correctly."""
        r, g, b, a = _hex_to_rgba("00000000")
        assert a == pytest.approx(0.0)

    def test_theme_area_color(self):
        """Theme area colors with alpha parse correctly."""
        # Light theme area: "b4530926" (15% opacity)
        r, g, b, a = _hex_to_rgba("b4530926")
        assert a == pytest.approx(0x26 / 255)  # 0x26 = 38

        # Dark theme area: "f59e0b33" (20% opacity)
        r, g, b, a = _hex_to_rgba("f59e0b33")
        assert a == pytest.approx(0x33 / 255)  # 0x33 = 51


class TestAggregateBins:
    """Test _aggregate_bins function."""

    def test_empty_list(self):
        """Empty list returns empty list."""
        result = _aggregate_bins([], 3600)
        assert result == []

    def test_single_point(self):
        """Single point returns single aggregated point."""
        ts = datetime(2024, 1, 1, 12, 30, 0)
        points = [(ts, 100.0)]
        result = _aggregate_bins(points, 3600)  # 1-hour bins
        assert len(result) == 1
        assert result[0][1] == 100.0

    def test_points_same_bin(self):
        """Points in same bin are averaged."""
        ts = datetime(2024, 1, 1, 12, 0, 0)
        points = [
            (ts + timedelta(minutes=10), 100.0),
            (ts + timedelta(minutes=20), 200.0),
            (ts + timedelta(minutes=30), 300.0),
        ]
        result = _aggregate_bins(points, 3600)  # 1-hour bins
        assert len(result) == 1
        assert result[0][1] == pytest.approx(200.0)  # Mean of 100, 200, 300

    def test_points_different_bins(self):
        """Points in different bins stay separate."""
        ts = datetime(2024, 1, 1, 12, 0, 0)
        points = [
            (ts, 100.0),  # Hour 12 bin
            (ts + timedelta(hours=1), 200.0),  # Hour 13 bin
            (ts + timedelta(hours=2), 300.0),  # Hour 14 bin
        ]
        result = _aggregate_bins(points, 3600)  # 1-hour bins
        assert len(result) == 3
        assert result[0][1] == 100.0
        assert result[1][1] == 200.0
        assert result[2][1] == 300.0

    def test_bin_center_timestamp(self):
        """Result timestamps are at bin center."""
        ts = datetime(2024, 1, 1, 12, 0, 0)
        points = [(ts, 100.0)]
        result = _aggregate_bins(points, 3600)  # 1-hour bins
        # Bin starts at 12:00, center should be at 12:30
        assert result[0][0].minute == 30

    def test_30_minute_bins(self):
        """30-minute bins aggregate correctly."""
        ts = datetime(2024, 1, 1, 12, 0, 0)
        points = [
            (ts + timedelta(minutes=5), 100.0),  # First 30-min bin
            (ts + timedelta(minutes=10), 110.0),
            (ts + timedelta(minutes=35), 200.0),  # Second 30-min bin
            (ts + timedelta(minutes=40), 210.0),
        ]
        result = _aggregate_bins(points, 1800)  # 30-minute bins
        assert len(result) == 2
        assert result[0][1] == pytest.approx(105.0)  # Mean of 100, 110
        assert result[1][1] == pytest.approx(205.0)  # Mean of 200, 210

    def test_sorted_output(self):
        """Output is sorted by timestamp."""
        ts = datetime(2024, 1, 1, 12, 0, 0)
        # Input in reverse order
        points = [
            (ts + timedelta(hours=2), 300.0),
            (ts, 100.0),
            (ts + timedelta(hours=1), 200.0),
        ]
        result = _aggregate_bins(points, 3600)
        timestamps = [r[0] for r in result]
        assert timestamps == sorted(timestamps)


class TestConfigureXAxis:
    """Test _configure_x_axis function."""

    def test_day_period_format(self):
        """Day period uses HH:MM format with 4-hour intervals."""
        fig, ax = plt.subplots()
        try:
            _configure_x_axis(ax, "day")
            formatter = ax.xaxis.get_major_formatter()
            locator = ax.xaxis.get_major_locator()
            assert isinstance(formatter, mdates.DateFormatter)
            assert formatter.fmt == "%H:%M"
            assert isinstance(locator, mdates.HourLocator)
            ticks = locator.tick_values(
                BASE_DAY_START, BASE_DAY_START + timedelta(days=1)
            )
            tick_times = [
                mdates.num2date(tick).replace(tzinfo=None) for tick in ticks
            ]
            assert tick_times[1] - tick_times[0] == timedelta(hours=4)
        finally:
            plt.close(fig)

    def test_week_period_format(self):
        """Week period uses weekday format with daily intervals."""
        fig, ax = plt.subplots()
        try:
            _configure_x_axis(ax, "week")
            formatter = ax.xaxis.get_major_formatter()
            locator = ax.xaxis.get_major_locator()
            assert isinstance(formatter, mdates.DateFormatter)
            assert formatter.fmt == "%a"
            assert isinstance(locator, mdates.DayLocator)
            ticks = locator.tick_values(
                BASE_DAY_START, BASE_DAY_START + timedelta(days=7)
            )
            tick_times = [
                mdates.num2date(tick).replace(tzinfo=None) for tick in ticks
            ]
            assert tick_times[1] - tick_times[0] == timedelta(days=1)
        finally:
            plt.close(fig)

    def test_month_period_format(self):
        """Month period uses day-of-month format with 5-day intervals."""
        fig, ax = plt.subplots()
        try:
            _configure_x_axis(ax, "month")
            formatter = ax.xaxis.get_major_formatter()
            locator = ax.xaxis.get_major_locator()
            assert isinstance(formatter, mdates.DateFormatter)
            assert formatter.fmt == "%d"
            assert isinstance(locator, mdates.DayLocator)
            ticks = locator.tick_values(
                BASE_DAY_START, BASE_DAY_START + timedelta(days=31)
            )
            tick_times = [
                mdates.num2date(tick).replace(tzinfo=None) for tick in ticks
            ]
            assert tick_times[1] - tick_times[0] == timedelta(days=5)
        finally:
            plt.close(fig)

    def test_year_period_format(self):
        """Year period uses month abbreviation format."""
        fig, ax = plt.subplots()
        try:
            _configure_x_axis(ax, "year")
            formatter = ax.xaxis.get_major_formatter()
            locator = ax.xaxis.get_major_locator()
            assert isinstance(formatter, mdates.DateFormatter)
            assert formatter.fmt == "%b"
            assert isinstance(locator, mdates.MonthLocator)
            ticks = locator.tick_values(
                BASE_DAY_START, BASE_DAY_START + timedelta(days=365)
            )
            tick_times = [
                mdates.num2date(tick).replace(tzinfo=None) for tick in ticks
            ]
            assert len(tick_times) > 1
            assert all(tick.day == 1 for tick in tick_times)
            for current, nxt in zip(tick_times, tick_times[1:], strict=False):
                expected_month = 1 if current.month == 12 else current.month + 1
                expected_year = current.year + (1 if current.month == 12 else 0)
                assert (nxt.year, nxt.month) == (expected_year, expected_month)
        finally:
            plt.close(fig)

    def test_unknown_period_defaults_to_year(self):
        """Unknown period defaults to year format."""
        fig, ax = plt.subplots()
        try:
            _configure_x_axis(ax, "unknown")
            formatter = ax.xaxis.get_major_formatter()
            locator = ax.xaxis.get_major_locator()
            assert isinstance(formatter, mdates.DateFormatter)
            assert formatter.fmt == "%b"
            assert isinstance(locator, mdates.MonthLocator)
        finally:
            plt.close(fig)


class TestInjectDataAttributes:
    """Test _inject_data_attributes function."""

    def test_adds_root_svg_attributes(self):
        """Adds data attributes to root SVG element."""
        ts = self._create_sample_timeseries()
        svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 280">'

        result = _inject_data_attributes(svg, ts, "light")

        assert 'data-metric="bat"' in result
        assert 'data-period="day"' in result
        assert 'data-theme="light"' in result
        assert 'data-x-start="' in result
        assert 'data-x-end="' in result
        assert 'data-y-min="' in result
        assert 'data-y-max="' in result
        assert 'data-points="' in result

    def test_data_points_json_format(self):
        """Data points are JSON-encoded in attribute."""
        ts = self._create_sample_timeseries()
        svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 280">'

        result = _inject_data_attributes(svg, ts, "light")

        # Extract data-points value and decode
        import re
        match = re.search(r'data-points="([^"]+)"', result)
        assert match is not None
        points_json = match.group(1).replace('&quot;', '"')
        points = json.loads(points_json)

        assert len(points) == 3
        assert all("ts" in p and "v" in p for p in points)

    def test_uses_provided_x_range(self):
        """Uses provided x_start and x_end for axis range."""
        ts = self._create_sample_timeseries()
        svg = '<svg xmlns="http://www.w3.org/2000/svg">'
        x_start = datetime(2024, 1, 1, 0, 0, 0)
        x_end = datetime(2024, 1, 2, 0, 0, 0)

        result = _inject_data_attributes(
            svg, ts, "light", x_start=x_start, x_end=x_end
        )

        assert f'data-x-start="{int(x_start.timestamp())}"' in result
        assert f'data-x-end="{int(x_end.timestamp())}"' in result

    def test_uses_provided_y_range(self):
        """Uses provided y_min and y_max for axis range."""
        ts = self._create_sample_timeseries()
        svg = '<svg xmlns="http://www.w3.org/2000/svg">'

        result = _inject_data_attributes(svg, ts, "light", y_min=0.0, y_max=100.0)

        assert 'data-y-min="0.0"' in result
        assert 'data-y-max="100.0"' in result

    def test_escapes_quotes_in_json(self):
        """JSON quotes are properly escaped as &quot;"""
        ts = self._create_sample_timeseries()
        svg = '<svg xmlns="http://www.w3.org/2000/svg">'

        result = _inject_data_attributes(svg, ts, "light")

        # Ensure raw JSON double quotes are escaped
        assert '"ts":' not in result  # Should be &quot;ts&quot;:
        assert '&quot;ts&quot;:' in result

    def _create_sample_timeseries(self) -> TimeSeries:
        """Create sample time series for testing."""
        now = BASE_TIME
        return TimeSeries(
            metric="bat",
            role="repeater",
            period="day",
            points=[
                DataPoint(timestamp=now - timedelta(hours=2), value=3.8),
                DataPoint(timestamp=now - timedelta(hours=1), value=3.9),
                DataPoint(timestamp=now, value=4.0),
            ],
        )


class TestChartStatistics:
    """Test ChartStatistics dataclass."""

    def test_to_dict_empty(self):
        """Empty statistics convert to dict with None values."""
        stats = ChartStatistics()
        result = stats.to_dict()
        assert result == {"min": None, "avg": None, "max": None, "current": None}

    def test_to_dict_with_values(self):
        """Statistics with values convert correctly."""
        stats = ChartStatistics(
            min_value=1.0, avg_value=2.0, max_value=3.0, current_value=2.5
        )
        result = stats.to_dict()
        assert result == {"min": 1.0, "avg": 2.0, "max": 3.0, "current": 2.5}


class TestCalculateStatistics:
    """Test calculate_statistics function."""

    def test_empty_timeseries(self):
        """Empty time series returns empty statistics."""
        ts = TimeSeries(metric="bat", role="repeater", period="day", points=[])
        stats = calculate_statistics(ts)
        assert stats.min_value is None
        assert stats.avg_value is None
        assert stats.max_value is None
        assert stats.current_value is None

    def test_single_point(self):
        """Single point has min=max=avg=current."""
        ts = TimeSeries(
            metric="bat",
            role="repeater",
            period="day",
            points=[DataPoint(timestamp=BASE_TIME, value=3.8)],
        )
        stats = calculate_statistics(ts)
        assert stats.min_value == 3.8
        assert stats.avg_value == 3.8
        assert stats.max_value == 3.8
        assert stats.current_value == 3.8

    def test_multiple_points(self):
        """Multiple points calculate correct statistics."""
        ts = TimeSeries(
            metric="bat",
            role="repeater",
            period="day",
            points=[
                DataPoint(timestamp=BASE_TIME - timedelta(hours=2), value=3.0),
                DataPoint(timestamp=BASE_TIME - timedelta(hours=1), value=4.0),
                DataPoint(timestamp=BASE_TIME, value=5.0),
            ],
        )
        stats = calculate_statistics(ts)
        assert stats.min_value == 3.0
        assert stats.max_value == 5.0
        assert stats.avg_value == pytest.approx(4.0)
        assert stats.current_value == 5.0  # Last point

    def test_current_is_last_point(self):
        """Current value is the most recent (last) point."""
        ts = TimeSeries(
            metric="bat",
            role="repeater",
            period="day",
            points=[
                DataPoint(timestamp=BASE_TIME - timedelta(hours=2), value=100.0),
                DataPoint(timestamp=BASE_TIME - timedelta(hours=1), value=50.0),
                DataPoint(timestamp=BASE_TIME, value=75.0),
            ],
        )
        stats = calculate_statistics(ts)
        assert stats.current_value == 75.0


class TestTimeSeries:
    """Test TimeSeries dataclass."""

    def test_timestamps_property(self):
        """timestamps property returns list of timestamps."""
        ts = TimeSeries(
            metric="bat",
            role="repeater",
            period="day",
            points=[
                DataPoint(timestamp=BASE_TIME - timedelta(hours=1), value=3.8),
                DataPoint(timestamp=BASE_TIME, value=3.9),
            ],
        )
        timestamps = ts.timestamps
        assert len(timestamps) == 2
        assert all(isinstance(t, datetime) for t in timestamps)

    def test_values_property(self):
        """values property returns list of values."""
        ts = TimeSeries(
            metric="bat",
            role="repeater",
            period="day",
            points=[
                DataPoint(timestamp=BASE_TIME, value=3.8),
                DataPoint(timestamp=BASE_TIME + timedelta(minutes=1), value=3.9),
            ],
        )
        values = ts.values
        assert values == [3.8, 3.9]

    def test_is_empty_true(self):
        """is_empty returns True for empty points."""
        ts = TimeSeries(metric="bat", role="repeater", period="day", points=[])
        assert ts.is_empty is True

    def test_is_empty_false(self):
        """is_empty returns False for non-empty points."""
        ts = TimeSeries(
            metric="bat",
            role="repeater",
            period="day",
            points=[DataPoint(timestamp=BASE_TIME, value=3.8)],
        )
        assert ts.is_empty is False


class TestChartTheme:
    """Test ChartTheme dataclass and constants."""

    def test_light_theme_exists(self):
        """Light theme is defined."""
        assert "light" in CHART_THEMES
        light = CHART_THEMES["light"]
        assert light.name == "light"
        assert light.background
        assert light.line

    def test_dark_theme_exists(self):
        """Dark theme is defined."""
        assert "dark" in CHART_THEMES
        dark = CHART_THEMES["dark"]
        assert dark.name == "dark"
        assert dark.background
        assert dark.line

    def test_themes_have_different_colors(self):
        """Light and dark themes have different colors."""
        light = CHART_THEMES["light"]
        dark = CHART_THEMES["dark"]
        assert light.background != dark.background
        assert light.line != dark.line


class TestPeriodConfig:
    """Test PERIOD_CONFIG constants."""

    def test_all_periods_defined(self):
        """All expected periods are defined."""
        assert "day" in PERIOD_CONFIG
        assert "week" in PERIOD_CONFIG
        assert "month" in PERIOD_CONFIG
        assert "year" in PERIOD_CONFIG

    def test_day_has_no_binning(self):
        """Day period has no time binning."""
        assert PERIOD_CONFIG["day"].bin_seconds is None

    def test_week_has_30_min_bins(self):
        """Week period has 30-minute bins."""
        assert PERIOD_CONFIG["week"].bin_seconds == 1800

    def test_month_has_2_hour_bins(self):
        """Month period has 2-hour bins."""
        assert PERIOD_CONFIG["month"].bin_seconds == 7200

    def test_year_has_1_day_bins(self):
        """Year period has 1-day bins."""
        assert PERIOD_CONFIG["year"].bin_seconds == 86400

    def test_all_periods_have_lookback(self):
        """All periods have lookback duration defined."""
        for _period, cfg in PERIOD_CONFIG.items():
            assert cfg.lookback is not None
            assert isinstance(cfg.lookback, timedelta)
