"""Tests for the chart engine (Session 2A+2B Phase 4).

Covers point normalization, projection, polyline/bezier path building,
marker shape dispatch, and the public ``build_chart_svg`` entry point.
"""

from __future__ import annotations

from datetime import UTC, datetime

import pytest

from hyperweave.render.chart_engine import (
    ChartPoint,
    Viewport,
    _build_area_path,
    _build_area_polygon_points,
    _build_bezier_path,
    _build_markers,
    _build_milestones,
    _build_polyline_points,
    _build_x_date_labels,
    _format_y_tick,
    _nice_y_ticks,
    _normalize_points,
    _project_points,
    build_chart_svg,
)

# ── Fixtures ──────────────────────────────────────────────────────────


@pytest.fixture()
def sample_viewport() -> Viewport:
    return Viewport(x=100, y=100, w=600, h=200)


@pytest.fixture()
def sample_points_dict() -> list[dict[str, object]]:
    """Six evenly-spaced points with a growing star count."""
    return [
        {"date": "2025-01-01", "count": 100},
        {"date": "2025-04-01", "count": 320},
        {"date": "2025-07-01", "count": 680},
        {"date": "2025-10-01", "count": 1200},
        {"date": "2026-01-01", "count": 2100},
        {"date": "2026-04-01", "count": 2850},
    ]


# ── _normalize_points ─────────────────────────────────────────────────


def test_normalize_points_dict_form(sample_points_dict: list[dict[str, object]]) -> None:
    pts = _normalize_points(sample_points_dict)
    assert len(pts) == 6
    assert pts[0].value == 100
    assert pts[-1].value == 2850
    # Sorted chronologically
    assert all(pts[i].date <= pts[i + 1].date for i in range(len(pts) - 1))


def test_normalize_points_accepts_tuples() -> None:
    pts = _normalize_points(
        [
            (datetime(2025, 1, 1, tzinfo=UTC), 100),
            (datetime(2025, 6, 1, tzinfo=UTC), 500),
        ],
    )
    assert len(pts) == 2
    assert pts[0].value == 100
    assert pts[1].value == 500


def test_normalize_points_skips_invalid_entries() -> None:
    pts = _normalize_points(
        [
            {"date": "2025-01-01", "count": 100},
            "not a dict",
            {"date": "invalid-date", "count": 200},  # unparseable, skipped
            {"date": "2025-06-01", "count": "not-a-number"},  # bad value, skipped
            {"date": "2025-12-01", "count": 500},
        ],
    )
    # Only the two valid entries survive.
    assert len(pts) == 2
    assert [p.value for p in pts] == [100, 500]


def test_normalize_points_handles_z_suffix() -> None:
    pts = _normalize_points([{"date": "2025-01-01T00:00:00Z", "count": 42}])
    assert len(pts) == 1
    assert pts[0].date.tzinfo is not None


# ── _project_points ───────────────────────────────────────────────────


def test_project_points_empty_returns_empty(sample_viewport: Viewport) -> None:
    assert _project_points([], sample_viewport) == []


def test_project_points_single_returns_center(sample_viewport: Viewport) -> None:
    pt = ChartPoint(date=datetime(2025, 6, 1, tzinfo=UTC), value=500)
    out = _project_points([pt], sample_viewport)
    assert out == [(400, 200)]  # center of the viewport


def test_project_points_range_hits_corners(
    sample_viewport: Viewport,
    sample_points_dict: list[dict[str, object]],
) -> None:
    pts = _normalize_points(sample_points_dict)
    projected = _project_points(pts, sample_viewport)
    # First point starts at left edge of viewport.
    assert projected[0][0] == sample_viewport.x
    # Last point ends at right edge.
    assert projected[-1][0] == sample_viewport.x + sample_viewport.w
    # Max-value point should sit at the top of the viewport (flipped Y).
    assert projected[-1][1] == sample_viewport.y
    # Min-value point should sit at the bottom.
    assert projected[0][1] == sample_viewport.y + sample_viewport.h


# ── Path builders ─────────────────────────────────────────────────────


def test_build_polyline_points() -> None:
    out = _build_polyline_points([(10, 20), (30, 40), (50, 60)])
    assert out == "10,20 30,40 50,60"


def test_build_polyline_points_empty() -> None:
    assert _build_polyline_points([]) == ""


def test_build_bezier_path_starts_with_M() -> None:
    out = _build_bezier_path([(10, 20), (30, 40), (50, 60)])
    assert out.startswith("M10,20")
    assert "C" in out  # cubic bezier control points present


def test_build_bezier_path_no_degenerate_segments_on_close_points() -> None:
    """When adjacent anchors are close in x, control handles must not cross.

    Regression for the chrome "flat-then-vertical" bug: the previous
    horizontal-handle implementation used ``dx = max(4, (x_cur - x_prev) // 3)``,
    which produced ``c2.x < c1.x`` when points were close. Catmull-Rom tangents
    scale with local chord length and avoid this.
    """
    import re

    # Points clustered tightly in x (simulates slow-growth early period).
    points = [(80, 410), (81, 390), (88, 368), (103, 348), (128, 325), (830, 169)]
    path = _build_bezier_path(points)
    segments = re.findall(r"C(\d+),(\d+) (\d+),(\d+) (\d+),(\d+)", path)
    assert len(segments) == len(points) - 1
    for c1x, _, c2x, _, _, _ in segments:
        assert int(c2x) >= int(c1x), f"degenerate segment: c1x={c1x}, c2x={c2x} (c2 must not precede c1)"


def test_build_bezier_path_tangents_follow_neighbor_slope() -> None:
    """Tangents should respect data slope — not force horizontal tangent at every anchor.

    With the old horizontal-handle approach, the first segment's c1 always had
    ``c1.y == y_prev``. For data that rises, the new Catmull-Rom code shifts c1
    toward the next point's y, producing a non-horizontal start.
    """
    # Rising data: y decreases (flipped SVG coords mean smaller y = higher on chart).
    points = [(0, 400), (100, 300), (200, 200), (300, 100)]
    path = _build_bezier_path(points)
    import re

    first_segment = re.match(r"M0,400 C(\d+),(\d+)", path)
    assert first_segment is not None
    c1y = int(first_segment.group(2))
    # Old code produced c1y == 400 (horizontal tangent). New code should
    # tilt toward y_next=300, so c1y < 400.
    assert c1y < 400, f"expected tangent to tilt upward, got c1y={c1y}"


def test_build_area_polygon_closes_to_baseline() -> None:
    pts = [(10, 50), (30, 30), (50, 20)]
    out = _build_area_polygon_points(pts, baseline_y=100)
    # Appends (last_x, baseline) then (first_x, baseline) to close the shape.
    assert "50,100" in out
    assert "10,100" in out


def test_build_area_path_closes_with_L_commands() -> None:
    pts = [(10, 50), (30, 30), (50, 20)]
    out = _build_area_path(pts, baseline_y=100)
    assert out.endswith("Z")
    assert " L50,100" in out
    assert " L10,100" in out


# ── Marker builders ───────────────────────────────────────────────────


def test_build_markers_square_emits_crosshair() -> None:
    """Square (rect) markers carry crosshair geometry (half + cross) in the render dict."""
    out = _build_markers([(50, 50), (100, 200)], shape="square", size=10)
    # "square" is a legacy alias for "rect"; non-endpoint marker gets crosshair fields.
    assert out[0] == {
        "shape": "rect",
        "x": 50,
        "y": 50,
        "size": 10,
        "is_endpoint": False,
        "half": 5,
        "neg_half": -5,
        "cross": 2,
        "neg_cross": -2,
        "cross_center": 0,
        "cellular_half": 5,
        "cellular_neg_half": -5,
    }
    # Final marker is the endpoint beacon (3-nested-square variant).
    assert out[-1]["is_endpoint"] is True
    assert out[-1]["shape"] == "rect"
    assert {"s1", "s2", "s3", "h1", "h2", "h3"} <= set(out[-1])


def test_build_markers_circle_emits_circle_radius() -> None:
    out = _build_markers([(50, 50), (100, 200)], shape="circle", size=6)
    # Non-endpoint circle carries r = size // 2, clamped >= 1.
    assert out[0] == {
        "shape": "circle",
        "x": 50,
        "y": 50,
        "size": 6,
        "is_endpoint": False,
        "r": 3,
        "cellular_half": 3,
        "cellular_neg_half": -3,
    }
    # Circle endpoints use the dedicated concentric-circle partial.
    assert out[-1]["shape"] == "circle"
    assert out[-1]["is_endpoint"] is True
    assert {"r1", "r2", "r3"} <= set(out[-1])


def test_build_markers_diamond_emits_rotation_geometry() -> None:
    """Diamond markers carry half for the rotated-rect partial."""
    out = _build_markers([(50, 50), (100, 200)], shape="diamond", size=4)
    assert out[0]["shape"] == "diamond"
    assert out[0]["is_endpoint"] is False
    assert out[0]["half"] == 2
    # Final diamond is the endpoint (chrome 2-layer rotated rects).
    assert out[-1]["shape"] == "diamond"
    assert out[-1]["is_endpoint"] is True
    assert {"s1", "s2", "h1", "h2"} <= set(out[-1])


def test_build_markers_endpoint_is_last_only() -> None:
    """Only the final entry is the endpoint; the rest are regular markers."""
    out = _build_markers([(10, 10), (20, 20), (30, 30)], shape="square", size=10)
    assert len(out) == 3
    assert sum(1 for m in out if m["is_endpoint"]) == 1
    assert out[-1]["is_endpoint"] is True
    assert all(not m["is_endpoint"] for m in out[:-1])


def test_build_markers_single_point_emits_endpoint_only() -> None:
    """A single-point series should not double-stamp origin = endpoint."""
    out = _build_markers([(42, 99)], shape="diamond", size=6)
    assert len(out) == 1
    assert out[0]["is_endpoint"] is True
    assert (out[0]["x"], out[0]["y"]) == (42, 99)


def test_build_markers_keeps_per_point_markers_at_density_cutoff() -> None:
    projected = [(x, 100 - x) for x in range(24)]
    out = _build_markers(projected, shape="square", size=10)
    assert len(out) == 24
    assert sum(1 for m in out if m["is_endpoint"]) == 1
    assert (out[-1]["x"], out[-1]["y"]) == projected[-1]


def test_build_markers_collapses_high_frequency_series_to_origin_and_endpoint() -> None:
    projected = [(x, 100 - x) for x in range(25)]
    out = _build_markers(projected, shape="square", size=10)
    assert len(out) == 2
    assert out[0]["is_endpoint"] is False
    assert (out[0]["x"], out[0]["y"]) == projected[0]
    assert out[-1]["is_endpoint"] is True
    assert (out[-1]["x"], out[-1]["y"]) == projected[-1]


def test_build_markers_empty_returns_empty_list() -> None:
    assert _build_markers([], shape="rect", size=5) == []


# ── Milestones ────────────────────────────────────────────────────────


def test_build_milestones_marks_crossings(sample_viewport: Viewport) -> None:
    pts = _normalize_points(
        [
            {"date": "2025-01-01", "count": 100},
            {"date": "2025-06-01", "count": 600},  # crosses 500
            {"date": "2025-12-01", "count": 1100},  # crosses 1000
            {"date": "2026-06-01", "count": 2500},  # crosses 2000
        ],
    )
    projected = _project_points(pts, sample_viewport)
    out = _build_milestones(pts, projected, sample_viewport, thresholds=[500, 1000, 2000])
    assert isinstance(out, list)
    labels = [ms["label"] for ms in out]
    # With sample_viewport ~600px wide and points spread across 4 timestamps, all
    # three thresholds cross at well-spaced x positions → none get de-overlapped.
    # v0.3.3 chart fidelity: labels include the crossing-date suffix per the
    # brutalist light scholar prototype's `1K · APR 25` cadence, so labels
    # start with the threshold tag followed by " · " and the `MMM YY` date
    # suffix.
    assert any(label.startswith("500 · ") for label in labels)
    assert any(label.startswith("1K · ") for label in labels)
    assert any(label.startswith("2K · ") for label in labels)
    # Structural guarantees every milestone dict needs
    for ms in out:
        assert {"x", "y", "bottom_y", "label", "value"} <= set(ms)


def test_build_milestones_empty_when_no_thresholds(sample_viewport: Viewport) -> None:
    pts = _normalize_points([{"date": "2025-01-01", "count": 100}])
    projected = _project_points(pts, sample_viewport)
    assert _build_milestones(pts, projected, sample_viewport, thresholds=[]) == []


def test_build_milestones_deoverlaps_clustered_crossings(sample_viewport: Viewport) -> None:
    """When multiple thresholds cross on nearly-adjacent x positions, only the
    highest-value crossing survives — prevents the openclaw-style
    '500/1K/5K/10K all stacked on top of each other' illegibility.

    Milestones are processed value-descending, so high-significance crossings
    win conflicts. A 10K crossing beats a 500 crossing in the same cluster;
    the earlier x-position order kept the low-value milestone and dropped the
    late high-value one."""
    # Two data points very close in x so all crossings cluster in the gap.
    pts = _normalize_points(
        [
            {"date": "2025-01-01", "count": 100},
            {"date": "2025-01-02", "count": 20000},  # one-day jump through all thresholds
        ],
    )
    projected = _project_points(pts, sample_viewport)
    thresholds = [500, 1000, 2000, 5000, 10000, 15000]
    out = _build_milestones(pts, projected, sample_viewport, thresholds=thresholds)
    # All 6 crossings happen in one tight cluster; de-overlap keeps ≤ 2 labels.
    assert 1 <= len(out) <= 2
    # Value-descending iteration: the highest threshold wins the cluster.
    assert out[0]["value"] == 15000


def test_build_milestones_keeps_all_when_well_spaced(sample_viewport: Viewport) -> None:
    """Milestones spread out beyond 40px pixel gap should all survive de-overlap."""
    # Four data points, one per crossing, evenly spaced across the viewport.
    pts = _normalize_points(
        [
            {"date": "2025-01-01", "count": 100},
            {"date": "2025-04-01", "count": 600},  # crosses 500
            {"date": "2025-07-01", "count": 1200},  # crosses 1000
            {"date": "2025-10-01", "count": 2500},  # crosses 2000
        ],
    )
    projected = _project_points(pts, sample_viewport)
    out = _build_milestones(pts, projected, sample_viewport, thresholds=[500, 1000, 2000])
    assert len(out) == 3  # all three crossings survive


# ── build_chart_svg (public entry point) ──────────────────────────────


def test_build_chart_svg_miter_angular(
    sample_viewport: Viewport,
    sample_points_dict: list[dict[str, object]],
) -> None:
    """Brutalist-style chart: polyline with miter joins, square markers, solid area."""
    result = build_chart_svg(
        sample_points_dict,
        sample_viewport,
        structural={
            "stroke_linejoin": "miter",
            "data_point_shape": "square",
            "data_point_size": 5,
            "fill_density": "solid-area",
        },
    )
    # polyline is now a structured dict; miter selects polyline kind.
    assert result["polyline"]["kind"] == "polyline"
    assert "points" in result["polyline"]
    # area is structured too; solid-area selects polygon kind.
    assert result["area"]["kind"] == "polygon"
    assert "points" in result["area"]
    # markers stays list[dict] (v0.2.7 contract unchanged).
    assert isinstance(result["markers"], list)
    assert result["markers"]
    assert all(m["shape"] == "rect" for m in result["markers"])


def test_build_chart_svg_round_smooth(
    sample_viewport: Viewport,
    sample_points_dict: list[dict[str, object]],
) -> None:
    """Chrome-style chart: bezier path, diamond markers, smooth gradient area."""
    result = build_chart_svg(
        sample_points_dict,
        sample_viewport,
        structural={
            "stroke_linejoin": "round",
            "data_point_shape": "diamond",
            "data_point_size": 6,
            "fill_density": "bezier-smooth",
        },
    )
    # Round linejoin → path kind; smooth fill → path area too.
    assert result["polyline"]["kind"] == "path"
    assert "d" in result["polyline"]
    assert result["area"]["kind"] == "path"
    assert "Z" in result["area"]["d"]  # closed path
    assert isinstance(result["markers"], list)
    assert all(m["shape"] == "diamond" for m in result["markers"])


def test_build_chart_svg_empty_points_safe(sample_viewport: Viewport) -> None:
    """No points → data specs are None/empty, but scaffolding still present."""
    result = build_chart_svg([], sample_viewport, structural={})
    assert result["area"] is None
    assert result["polyline"] is None
    assert result["markers"] == []
    # Axes and gridlines are always drawn regardless of data.
    assert len(result["axes"]) == 2  # L-frame = 2 lines
    assert len(result["gridlines"]) > 0
    # Empty-state: labels collapse to a single "0" on the baseline, no X labels,
    # and no overlay unless the caller requested one.
    assert len(result["y_labels"]) == 1
    assert result["y_labels"][0]["text"] == "0"
    assert result["x_labels"] == []
    assert result["empty_state"] is None


def test_build_chart_svg_does_not_emit_default_milestones_when_none_supplied(
    sample_viewport: Viewport,
) -> None:
    """Milestones remain opt-in for v0.3.11.

    Automatic milestone selection is deferred with the broader chart marker
    architecture work so labels cannot collide with chart headers by default.
    """
    points = [
        {"date": "2025-01-01", "count": 100},
        {"date": "2025-06-01", "count": 600},
        {"date": "2025-12-01", "count": 1500},
        {"date": "2026-06-01", "count": 2800},
    ]
    result = build_chart_svg(points, sample_viewport, structural={}, milestones=None)
    assert result["milestones"] == []


def test_build_chart_svg_empty_with_message_renders_overlay(
    sample_viewport: Viewport,
) -> None:
    """Zero-data path with an empty_message emits a structured empty-state dict."""
    result = build_chart_svg([], sample_viewport, structural={}, empty_message="NEW REPO · NO STARS YET")
    assert result["empty_state"] is not None
    assert result["empty_state"]["text"] == "NEW REPO · NO STARS YET"
    assert "x" in result["empty_state"]
    assert "y" in result["empty_state"]


def test_build_chart_svg_respects_milestones(
    sample_viewport: Viewport,
    sample_points_dict: list[dict[str, object]],
) -> None:
    result = build_chart_svg(
        sample_points_dict,
        sample_viewport,
        structural={"stroke_linejoin": "miter"},
        milestones=[500, 1000, 2000],
    )
    assert "milestones" in result
    assert isinstance(result["milestones"], list)
    assert len(result["milestones"]) >= 1


# ── Nice ticks (Y-axis tick computation) ─────────────────────────────


@pytest.mark.parametrize(
    "v_max,expected",
    [
        (0, [0]),
        (1, [0, 1]),
        (6, [0, 2, 4, 6]),
        (30, [0, 10, 20, 30]),
        (2850, [0, 1000, 2000, 3000]),
        (15000, [0, 5000, 10000, 15000]),
    ],
)
def test_nice_y_ticks_across_bands(v_max: int, expected: list[int]) -> None:
    """Tick algorithm produces round values from 0 up to >= v_max across orders of magnitude."""
    assert _nice_y_ticks(v_max) == expected


def test_format_y_tick_integer_and_k_notation() -> None:
    """< 1000 → integer; 1000..9999 → K notation with no trailing zeros; 10K+ → integer K."""
    assert _format_y_tick(0) == "0"
    assert _format_y_tick(6) == "6"
    assert _format_y_tick(500) == "500"
    assert _format_y_tick(1000) == "1K"
    assert _format_y_tick(1500) == "1.5K"
    assert _format_y_tick(2000) == "2K"
    assert _format_y_tick(10000) == "10K"


# ── X-axis year labels ────────────────────────────────────────────────


def test_x_date_labels_monthly_granularity_for_year_one_span(sample_viewport: Viewport) -> None:
    """Data spanning ~10 months → monthly format ("Mon Year"), not yearly."""
    pts = _normalize_points(
        [
            {"date": "2025-01-15", "count": 1},
            {"date": "2025-06-01", "count": 4},
            {"date": "2025-11-20", "count": 6},
        ]
    )
    labels = _build_x_date_labels(pts, sample_viewport)
    # All labels should use "Mon YYYY" format (e.g., "Jan 2025", "Apr 2025").
    assert labels, "expected at least one label for a 10-month span"
    for label in labels:
        # Match "Abc 2025" pattern — three-letter month followed by space + year.
        parts = label["text"].split()
        assert len(parts) == 2
        assert parts[1] == "2025"


def test_x_date_labels_yearly_for_multi_year_range(sample_viewport: Viewport) -> None:
    """Three-year span → yearly format (2024, 2025, 2026)."""
    pts = _normalize_points(
        [
            {"date": "2024-01-01", "count": 1},
            {"date": "2025-06-01", "count": 100},
            {"date": "2026-04-01", "count": 500},
        ]
    )
    labels = _build_x_date_labels(pts, sample_viewport)
    texts = [label["text"] for label in labels]
    # Every label is a 4-digit year.
    for text in texts:
        assert text.isdigit() and len(text) == 4
    # First and last are the endpoints of the data.
    assert labels[0]["anchor"] == "start"
    assert labels[0]["x"] == sample_viewport.x
    # Data starts in 2024 and ends in 2026; endpoints must reflect that.
    assert texts[0] == "2024"
    assert texts[-1] == "2026"


def test_x_date_labels_daily_for_viral_two_week_span(sample_viewport: Viewport) -> None:
    """2-week span (caveman case) → daily format ("Apr 05", "Apr 12"...).

    This is the regression test that locks in the fix for the
    JuliusBrussee/caveman rendering — the old year-only labels showed a
    single lonely "2026" mid-axis; daily granularity makes the 2-week
    adoption curve legible.
    """
    pts = _normalize_points(
        [
            {"date": "2026-04-05T00:00:00Z", "count": 1},
            {"date": "2026-04-10T00:00:00Z", "count": 15_000},
            {"date": "2026-04-19T00:00:00Z", "count": 40_000},
        ]
    )
    labels = _build_x_date_labels(pts, sample_viewport)
    # Every label uses "Mon DD" format — no year suffix, no single "2026".
    for label in labels:
        parts = label["text"].split()
        assert len(parts) == 2, f"expected 'Mon DD' format, got {label['text']!r}"
        # Day portion is zero-padded 2-digit integer.
        assert parts[1].isdigit() and len(parts[1]) == 2


def test_x_date_labels_every_other_year_for_ancient_repo(sample_viewport: Viewport) -> None:
    """Repo spanning >10 years → every-other-year ticks to keep the axis readable."""
    pts = _normalize_points(
        [
            {"date": "2010-06-01", "count": 1},
            {"date": "2026-06-01", "count": 500_000},
        ]
    )
    labels = _build_x_date_labels(pts, sample_viewport)
    texts = [label["text"] for label in labels]
    # Endpoints always preserved.
    assert texts[0] == "2010"
    # Endpoint is dropped/merged into the last-kept if too close; 2026 should be present.
    assert "2026" in texts


def test_x_date_labels_deoverlap_drops_clustered_middles(sample_viewport: Viewport) -> None:
    """De-overlap pass: temporally-close middle labels are dropped; endpoints preserved.

    With a narrow viewport (just 80px wide) and a 3-year span, the yearly
    labels at jan-1 boundaries would collide. Only endpoints survive.
    """
    narrow_vp = Viewport(x=0, y=0, w=80, h=50)
    pts = _normalize_points(
        [
            {"date": "2024-01-01", "count": 1},
            {"date": "2025-06-01", "count": 100},
            {"date": "2026-04-01", "count": 500},
        ]
    )
    labels = _build_x_date_labels(pts, narrow_vp)
    # Under severe width pressure, only the endpoints survive.
    assert len(labels) <= 2
    texts = [label["text"] for label in labels]
    assert "2024" in texts
    assert "2026" in texts


def test_x_date_labels_preserve_endpoints_unconditionally(sample_viewport: Viewport) -> None:
    """Even when middle labels are dropped, first and last labels persist."""
    pts = _normalize_points(
        [
            {"date": "2024-01-01", "count": 1},
            {"date": "2026-04-01", "count": 500},
        ]
    )
    labels = _build_x_date_labels(pts, sample_viewport)
    # Two-point input with wide viewport keeps both endpoints.
    assert len(labels) >= 2
    assert labels[0]["anchor"] == "start"
    assert labels[-1]["anchor"] == "end"


def test_x_date_labels_single_point_full_date(sample_viewport: Viewport) -> None:
    """Single data point → one centered full-date label ("Apr 05, 2026")."""
    pts = _normalize_points([{"date": "2026-04-05", "count": 1}])
    labels = _build_x_date_labels(pts, sample_viewport)
    assert len(labels) == 1
    assert labels[0]["text"] == "Apr 05, 2026"
    assert labels[0]["anchor"] == "middle"


def test_x_date_labels_empty_points() -> None:
    """No points → no labels (caller should render an empty-state overlay instead)."""
    vp = Viewport(x=0, y=0, w=100, h=50)
    assert _build_x_date_labels([], vp) == []


# ── X-axis label visual overlap regression ───────────────────────────
#
# These tests exist because of a v0.3.0 bug where short-history repos
# (e.g. openclaw/openclaw, ~6 months) rendered "Apr 2026" and "May 2026"
# colliding into unreadable text. Root cause: the de-overlap pass measured
# center-to-center distance against a fixed 48px constant, but each label
# is ~58px wide, so adjacent labels with centers 48px apart overlap by 10px.
#
# Each test uses a local bounds-checker that mirrors the implementation's
# width estimator. Independence from chart_engine internals means the
# overlap assertion catches regressions even if internal helpers drift.


# Width estimate uses real measure_text against the widest milestone CSS
# (brutalist: JetBrains Mono 9px/800/0.12em) to match production. The
# previous len * 7.5 estimate over-counted by ~11px on a 12-char label,
# producing false-positive collisions that suppressed legitimate labels.
# Keeping the test bound aligned with production keeps
# the overlap check honest.
_TEST_EDGE_PADDING_PX: float = 6.0


def _bounds_for(label: dict[str, object]) -> tuple[float, float]:
    """Return (left, right) pixel edges of a generated label."""
    from hyperweave.core.text import measure_text

    x = float(label["x"])  # type: ignore[arg-type]
    text = label["text"]
    assert isinstance(text, str)
    width = measure_text(text, font_family="JetBrains Mono", font_size=9, font_weight=800, letter_spacing_em=0.12)
    anchor = label.get("anchor", "middle")
    if anchor == "start":
        return (x, x + width)
    if anchor == "end":
        return (x - width, x)
    return (x - width / 2, x + width / 2)


def _assert_no_visual_overlap(labels: list[dict[str, object]]) -> None:
    """Pairwise check: each label's right edge + padding must precede the next's left edge."""
    for i in range(len(labels) - 1):
        _, right_i = _bounds_for(labels[i])
        left_next, _ = _bounds_for(labels[i + 1])
        assert left_next >= right_i + _TEST_EDGE_PADDING_PX, (
            f"Label {labels[i]['text']!r} (right edge {right_i:.1f}) overlaps with "
            f"{labels[i + 1]['text']!r} (left edge {left_next:.1f}) — "
            f"need {_TEST_EDGE_PADDING_PX}px gap"
        )


# Realistic viewports from the chart resolver — pin tests to actual production widths.
_BRUTALIST_VP = Viewport(x=80, y=150, w=760, h=245)
_CELLULAR_VP = Viewport(x=72, y=80, w=580, h=246)


def test_x_date_labels_no_visual_overlap_for_six_month_history() -> None:
    """6 months of stargazer data on a brutalist chart — the openclaw bug case.

    Monthly granularity triggers (~30-day step) and produces ~6-7 candidates.
    Pre-fix: adjacent monthly labels rendered with overlapping bounding boxes.
    Post-fix: width-aware de-overlap drops middles until all bounds clear.
    """
    pts = _normalize_points(
        [
            {"date": "2025-12-01", "count": 1},
            {"date": "2026-01-15", "count": 12},
            {"date": "2026-02-20", "count": 28},
            {"date": "2026-03-25", "count": 45},
            {"date": "2026-04-15", "count": 60},
            {"date": "2026-05-15", "count": 78},
        ]
    )
    labels = _build_x_date_labels(pts, _BRUTALIST_VP)
    assert len(labels) >= 2, "endpoints must always survive"
    _assert_no_visual_overlap(labels)


def test_x_date_labels_no_visual_overlap_for_one_month_history() -> None:
    """Extreme short case (~30 days). Monthly granularity gives 1-2 candidates;
    edge case where endpoint preservation must still avoid overlap."""
    pts = _normalize_points(
        [
            {"date": "2026-04-10", "count": 1},
            {"date": "2026-05-09", "count": 15},
        ]
    )
    labels = _build_x_date_labels(pts, _BRUTALIST_VP)
    assert len(labels) >= 1
    _assert_no_visual_overlap(labels)


def test_x_date_labels_no_visual_overlap_for_one_year_history() -> None:
    """Medium case (~12 months). Monthly granularity, ~12 candidates;
    width-aware de-overlap should keep ~4-6 evenly-spaced survivors."""
    pts = _normalize_points(
        [
            {"date": "2025-05-01", "count": 1},
            {"date": "2025-08-01", "count": 50},
            {"date": "2025-11-01", "count": 200},
            {"date": "2026-02-01", "count": 600},
            {"date": "2026-05-01", "count": 1100},
        ]
    )
    labels = _build_x_date_labels(pts, _BRUTALIST_VP)
    assert len(labels) >= 2
    _assert_no_visual_overlap(labels)


def test_x_date_labels_quarterly_for_fifteen_month_history() -> None:
    """15-month histories should use even quarterly labels, not 6mo + endpoint."""
    pts = _normalize_points(
        [
            {"date": "2025-02-01", "count": 1},
            {"date": "2025-08-01", "count": 50},
            {"date": "2026-02-01", "count": 200},
            {"date": "2026-05-01", "count": 400},
        ]
    )
    labels = _build_x_date_labels(pts, Viewport(x=80, y=160, w=750, h=250))
    assert [label["text"] for label in labels] == [
        "Feb 2025",
        "May 2025",
        "Aug 2025",
        "Nov 2025",
        "Feb 2026",
        "May 2026",
    ]
    gaps = [labels[idx + 1]["x"] - labels[idx]["x"] for idx in range(len(labels) - 1)]
    assert max(gaps) - min(gaps) <= 6


def test_x_date_labels_quarterly_even_when_span_starts_mid_month() -> None:
    """Calendar labels stay evenly spaced when endpoints are not month starts."""
    pts = _normalize_points(
        [
            {"date": "2025-02-24", "count": 1},
            {"date": "2025-08-10", "count": 50},
            {"date": "2026-02-12", "count": 200},
            {"date": "2026-05-21", "count": 400},
        ]
    )
    labels = _build_x_date_labels(pts, Viewport(x=72, y=80, w=580, h=246))
    assert [label["text"] for label in labels] == [
        "Feb 2025",
        "May 2025",
        "Aug 2025",
        "Nov 2025",
        "Feb 2026",
        "May 2026",
    ]
    gaps = [labels[idx + 1]["x"] - labels[idx]["x"] for idx in range(len(labels) - 1)]
    assert max(gaps) - min(gaps) <= 1


def test_x_date_labels_no_visual_overlap_for_two_year_history() -> None:
    """Long case (2+ years). Yearly granularity, ~3 candidates; should
    continue rendering without overlap — regression check on the
    pre-fix working path."""
    pts = _normalize_points(
        [
            {"date": "2024-01-01", "count": 1},
            {"date": "2024-07-01", "count": 500},
            {"date": "2025-01-01", "count": 2000},
            {"date": "2025-07-01", "count": 5000},
            {"date": "2026-01-01", "count": 8000},
            {"date": "2026-05-01", "count": 9500},
        ]
    )
    labels = _build_x_date_labels(pts, _BRUTALIST_VP)
    assert len(labels) >= 2
    _assert_no_visual_overlap(labels)


def test_x_date_labels_no_visual_overlap_on_cellular_viewport() -> None:
    """Cellular paradigm has a smaller viewport (580px vs brutalist's 760px).
    The worst-case (brutalist) width estimate must still produce non-overlapping
    labels on the narrower canvas."""
    pts = _normalize_points(
        [
            {"date": "2025-12-01", "count": 1},
            {"date": "2026-01-15", "count": 12},
            {"date": "2026-02-20", "count": 28},
            {"date": "2026-03-25", "count": 45},
            {"date": "2026-04-15", "count": 60},
            {"date": "2026-05-15", "count": 78},
        ]
    )
    labels = _build_x_date_labels(pts, _CELLULAR_VP)
    assert len(labels) >= 2
    _assert_no_visual_overlap(labels)


# ── Zero-time-span defense (bug 2 reproducer) ────────────────────────


def test_project_points_identical_timestamps_distributes_by_index(
    sample_viewport: Viewport,
) -> None:
    """All-same-timestamp points must not collapse to vp.x (bug 2 defense)."""
    same_date = datetime(2025, 5, 1, tzinfo=UTC)
    pts = [ChartPoint(date=same_date, value=i + 1) for i in range(4)]
    projected = _project_points(pts, sample_viewport)
    xs = [x for (x, _) in projected]
    # All x coords must be distinct — evenly spread across the viewport width.
    assert len(set(xs)) == 4
    # First point at left edge, last at right edge.
    assert xs[0] == sample_viewport.x
    assert xs[-1] == sample_viewport.x + sample_viewport.w


def test_project_points_v_min_override_uses_zero_baseline(
    sample_viewport: Viewport,
) -> None:
    """v_min=0 override means low-value points sit partway up, not at the baseline."""
    pts = _normalize_points(
        [
            {"date": "2025-01-01", "count": 100},
            {"date": "2025-12-01", "count": 200},
        ]
    )
    with_override = _project_points(pts, sample_viewport, v_min=0, v_max=1000)
    without_override = _project_points(pts, sample_viewport)
    # Without override: first point (value=100, data_min=100) sits at baseline.
    assert without_override[0][1] == sample_viewport.y + sample_viewport.h
    # With v_min=0, v_max=1000: value=100 is 10% of range, so sits 90% down from top.
    assert with_override[0][1] < sample_viewport.y + sample_viewport.h
    assert with_override[0][1] > sample_viewport.y


# ── Zero-star truthfulness: no placeholder leakage ───────────────────


def test_build_chart_svg_no_placeholder_1200_leak(sample_viewport: Viewport) -> None:
    """Zero-data renders must never leak the old 120→1200 placeholder numbers."""
    result = build_chart_svg([], sample_viewport, structural={})
    rendered = "".join(str(v) for v in result.values())
    assert "1200" not in rendered
    assert "1,200" not in rendered
