//! scatter チャート: 線形 x × 線形 y 軸に点(円)を描く。
//! カテゴリ系の `common::compute` は x をカテゴリ前提にするため、ここでは
//! 線形フレームを自前で組む。共有できる凡例/定数/テーマは `common` を再利用する。

use super::common::{
    LEGEND_BAND, OUTER_PAD, TEXT_BASELINE_RATIO, TITLE_BAND, TITLE_FONT, X_LABEL_BAND,
    X_LABEL_CENTER_RATIO, draw_vertical_legend, legend_band_width_vertical, legend_entry_width,
};
use crate::ir::{AxisSpec, ChartKind, ChartSpec, Color, LegendPos, Point};
use crate::num::fmt_num;
use crate::scale::{LinearScale, NiceTicks, nice_ticks};
use crate::scene::{Anchor, Prim, Scene};
use crate::text::TextMeasurer;

/// scatter のマーカー既定半径。chart.js scatter の pointRadius 既定値 ~3.0。
const DEFAULT_POINT_R: f64 = 3.0;

/// bubble で `point.r` が無い場合の既定半径。bubble は通常 r を持つが保険。
const DEFAULT_BUBBLE_R: f64 = 5.0;

/// 単一データ点の画素空間情報（scatter/line/bubble 共用）。
/// モデル geometry とレンダラが共有する単一の真実源。
#[derive(Debug, Clone, PartialEq)]
pub struct PointBox {
    pub series: usize,
    pub index: usize,
    pub kind: &'static str, // "scatter" | "line" | "bubble"
    pub cx: f64,
    pub cy: f64,
    pub r: f64,
}

/// scatter/bubble の自前フレーム（`common::compute` を使わない線形軸系）。
#[derive(Debug, Clone)]
pub struct ScatterLayout {
    pub xs: LinearScale,
    pub ys: LinearScale,
    pub x_ticks: NiceTicks,
    pub y_ticks: NiceTicks,
    pub plot_left: f64,
    pub plot_right: f64,
    pub plot_top: f64,
    pub plot_bottom: f64,
}

/// scatter/bubble チャートのフレームを計算して返す。
/// `build` のインライン計算と同一の式（単一の真実源）。
pub fn compute_scatter_layout(spec: &ChartSpec, m: &TextMeasurer) -> ScatterLayout {
    let label_font = spec.theme.font_size;
    let (xmin, xmax) = axis_domain(spec, &spec.x_axis, |p| p.x);
    let (ymin, ymax) = axis_domain(spec, &spec.y_axis, |p| p.y);
    let x_ticks = nice_ticks(xmin, xmax, 10);
    let y_ticks = nice_ticks(ymin, ymax, 10);
    let mut max_y_w = 0.0_f32;
    for &t in &y_ticks.ticks {
        let w = m.width(&crate::num::fmt_num(t), label_font as f32);
        if w > max_y_w {
            max_y_w = w;
        }
    }
    let y_axis_w = max_y_w as f64 + 10.0;
    let legend = has_legend(spec);
    let title_band = if spec.title.is_some() {
        TITLE_BAND
    } else {
        0.0
    };
    let legend_top = if legend && spec.legend == LegendPos::Top {
        LEGEND_BAND
    } else {
        0.0
    };
    let legend_bottom = if legend && spec.legend == LegendPos::Bottom {
        LEGEND_BAND
    } else {
        0.0
    };
    // series_names の割り当ては凡例が左右にあるときだけ必要なため遅延評価する。
    let (legend_left, legend_right_w) =
        if legend && (spec.legend == LegendPos::Left || spec.legend == LegendPos::Right) {
            let series_names: Vec<String> = spec.series.iter().map(|s| s.name.clone()).collect();
            let w = legend_band_width_vertical(m, &series_names, label_font);
            if spec.legend == LegendPos::Left {
                (w, 0.0)
            } else {
                (0.0, w)
            }
        } else {
            (0.0, 0.0)
        };
    let plot_left = OUTER_PAD + y_axis_w + legend_left;
    let plot_right = spec.width - OUTER_PAD - legend_right_w;
    let plot_top = OUTER_PAD + title_band + legend_top;
    let plot_bottom = spec.height - OUTER_PAD - X_LABEL_BAND - legend_bottom;
    ScatterLayout {
        xs: LinearScale::new(x_ticks.min, x_ticks.max, plot_left, plot_right),
        ys: LinearScale::new(y_ticks.min, y_ticks.max, plot_bottom, plot_top),
        x_ticks,
        y_ticks,
        plot_left,
        plot_right,
        plot_top,
        plot_bottom,
    }
}

/// scatter/bubble の全点を返す（renderer とモデルの単一の真実源）。
/// 非有限座標はスキップ。bubble は `PointBox.r` に実ピクセル半径を格納。
pub fn scatter_points(spec: &ChartSpec, layout: &ScatterLayout) -> Vec<PointBox> {
    let kind = match &spec.kind {
        ChartKind::Bubble => "bubble",
        _ => "scatter",
    };
    let mut pts = Vec::new();
    for (sidx, ser) in spec.series.iter().enumerate() {
        for (i, p) in ser.points.iter().enumerate() {
            if !p.x.is_finite() || !p.y.is_finite() {
                continue;
            }
            pts.push(PointBox {
                series: sidx,
                index: i,
                kind,
                cx: layout.xs.map(p.x),
                cy: layout.ys.map(p.y),
                r: point_radius(&spec.kind, p, ser.point_radius),
            });
        }
    }
    pts
}

/// 1 点の半径を返す。bubble はデータの第3次元 `point.r` を優先し、無ければ
/// dataset の `pointRadius`、それも無ければ既定値。scatter は dataset の `pointRadius`
/// (chart.js の指定)を使い、無指定なら既定値。非有限/負の半径は不正な SVG を避けるため
/// それぞれの既定値にフォールバックする。
fn point_radius(kind: &ChartKind, point: &Point, dataset_radius: Option<f64>) -> f64 {
    let valid = |r: f64, fallback: f64| {
        if r.is_finite() && r >= 0.0 {
            r
        } else {
            fallback
        }
    };
    match kind {
        ChartKind::Bubble => {
            let r = point.r.or(dataset_radius).unwrap_or(DEFAULT_BUBBLE_R);
            valid(r, DEFAULT_BUBBLE_R)
        }
        _ => valid(dataset_radius.unwrap_or(DEFAULT_POINT_R), DEFAULT_POINT_R),
    }
}

/// 凡例の有無(Top/Bottom/Left/Right かつ名前付き系列が 1 つ以上)。
fn has_legend(spec: &ChartSpec) -> bool {
    matches!(
        spec.legend,
        LegendPos::Top | LegendPos::Bottom | LegendPos::Left | LegendPos::Right
    ) && spec.series.iter().any(|s| !s.name.is_empty())
}

/// 全系列の全点から 1 軸ぶんのドメインを求める。`select` で x/y を選ぶ。
/// 非有限値は無視し、有限値が無ければ 0.0..1.0 にフォールバックする(NaN/panic 回避)。
/// nice_ticks 側が min==max(縮退)を吸収するため、ここでは追加の拡張はしない。
/// `axis_spec` の suggested_min/suggested_max はドメインを広げるだけ(データが優先)。
pub(crate) fn axis_domain(
    spec: &ChartSpec,
    axis_spec: &AxisSpec,
    select: impl Fn(&Point) -> f64,
) -> (f64, f64) {
    let mut lo = f64::INFINITY;
    let mut hi = f64::NEG_INFINITY;
    for s in &spec.series {
        for p in &s.points {
            let v = select(p);
            if v.is_finite() {
                if v < lo {
                    lo = v;
                }
                if v > hi {
                    hi = v;
                }
            }
        }
    }
    // データなし: suggested を初期シードとして使う(chart.js 互換)。suggested もなければ 0..1。
    if !lo.is_finite() || !hi.is_finite() {
        lo = axis_spec
            .suggested_min
            .filter(|s| s.is_finite())
            .unwrap_or(0.0);
        hi = axis_spec
            .suggested_max
            .filter(|s| s.is_finite())
            .unwrap_or(if lo == 0.0 { 1.0 } else { lo + 1.0 });
        if axis_spec.begin_at_zero {
            lo = lo.min(0.0);
            hi = hi.max(0.0);
        }
        return (lo, if hi > lo { hi } else { lo + 1.0 });
    }
    // begin_at_zero でドメインに 0 を含める。
    if axis_spec.begin_at_zero {
        lo = lo.min(0.0);
        hi = hi.max(0.0);
    }
    // suggestedMin/suggestedMax: データが優先、suggested はドメインを広げるだけ。
    // 非有限値（Infinity/NaN）は nice_ticks で無限 range を生じさせるため無視する。
    if let Some(s) = axis_spec.suggested_min {
        if s.is_finite() && s < lo {
            lo = s;
        }
    }
    if let Some(s) = axis_spec.suggested_max {
        if s.is_finite() && s > hi {
            hi = s;
        }
    }
    (lo, hi)
}

pub fn build(spec: &ChartSpec, m: &TextMeasurer) -> Scene {
    let ink = spec.theme.text_color;
    let label_font = spec.theme.font_size;

    let layout = compute_scatter_layout(spec, m);
    let xs = layout.xs.clone();
    let ys = layout.ys.clone();
    let plot_left = layout.plot_left;
    let plot_right = layout.plot_right;
    let plot_top = layout.plot_top;
    let plot_bottom = layout.plot_bottom;

    // グリッド描画用 ticks は compute_scatter_layout で計算済み。
    let x_ticks = &layout.x_ticks;
    let y_ticks = &layout.y_ticks;

    // 凡例描画用フラグ(フレーム計算ではなく表示用)。
    let legend = has_legend(spec);
    let title_band = if spec.title.is_some() {
        TITLE_BAND
    } else {
        0.0
    };
    let legend_right = if legend && spec.legend == LegendPos::Right {
        let series_names: Vec<String> = spec.series.iter().map(|s| s.name.clone()).collect();
        legend_band_width_vertical(m, &series_names, label_font)
    } else {
        0.0
    };

    let mut items: Vec<Prim> = Vec::new();

    // 1. タイトル。
    if let Some(title) = &spec.title {
        items.push(Prim::Text {
            x: spec.width / 2.0,
            y: OUTER_PAD + TITLE_FONT,
            size: TITLE_FONT,
            anchor: Anchor::Middle,
            fill: ink,
            content: title.clone(),
        });
    }

    // 2. 横グリッド + y 目盛りラベル(右寄せ)。
    for &t in &y_ticks.ticks {
        let y = ys.map(t);
        items.push(Prim::Line {
            x1: plot_left,
            y1: y,
            x2: plot_right,
            y2: y,
            stroke: spec.theme.grid_color,
            stroke_width: 1.0,
        });
        items.push(Prim::Text {
            x: plot_left - 6.0,
            y: y + label_font * TEXT_BASELINE_RATIO,
            size: label_font,
            anchor: Anchor::End,
            fill: ink,
            content: fmt_num(t),
        });
    }

    // 3. 縦グリッド + x 目盛りラベル(軸下に中央寄せ)。
    for &t in &x_ticks.ticks {
        let x = xs.map(t);
        items.push(Prim::Line {
            x1: x,
            y1: plot_top,
            x2: x,
            y2: plot_bottom,
            stroke: spec.theme.grid_color,
            stroke_width: 1.0,
        });
        items.push(Prim::Text {
            x,
            y: plot_bottom + X_LABEL_BAND * X_LABEL_CENTER_RATIO,
            size: label_font,
            anchor: Anchor::Middle,
            fill: ink,
            content: fmt_num(t),
        });
    }

    // 4. 軸ベースライン(x 下辺 + y 左辺)。
    items.push(Prim::Line {
        x1: plot_left,
        y1: plot_bottom,
        x2: plot_right,
        y2: plot_bottom,
        stroke: ink,
        stroke_width: 1.0,
    });
    items.push(Prim::Line {
        x1: plot_left,
        y1: plot_top,
        x2: plot_left,
        y2: plot_bottom,
        stroke: ink,
        stroke_width: 1.0,
    });

    // 5. 点(円)。共有 scatter_points(単一真実源)から描画。
    for b in scatter_points(spec, &layout) {
        let ser = &spec.series[b.series];
        items.push(Prim::Circle {
            cx: b.cx,
            cy: b.cy,
            r: b.r,
            fill: ser.fill_at(b.index),
            stroke: ser.stroke_at(b.index),
            stroke_width: ser.stroke_width,
        });
    }

    // 6. 凡例(Top/Bottom: 横並び。draw_frame と同じ配置)。
    if legend && matches!(spec.legend, LegendPos::Top | LegendPos::Bottom) {
        let mut total = 0.0_f64;
        for (k, ser) in spec.series.iter().enumerate() {
            let ew = legend_entry_width(m, &ser.name, label_font);
            total += ew;
            if k == spec.series.len() - 1 {
                total -= 16.0;
            }
        }
        let start_x = (spec.width - total) / 2.0;
        let legend_cy = if spec.legend == LegendPos::Top {
            OUTER_PAD + title_band + LEGEND_BAND / 2.0
        } else {
            spec.height - OUTER_PAD - LEGEND_BAND / 2.0
        };
        let mut cursor = start_x;
        for ser in &spec.series {
            items.push(Prim::Rect {
                x: cursor,
                y: legend_cy - 6.0,
                w: 12.0,
                h: 12.0,
                fill: ser.fill_at(0),
            });
            items.push(Prim::Text {
                x: cursor + 16.0,
                y: legend_cy + label_font * TEXT_BASELINE_RATIO,
                size: label_font,
                anchor: Anchor::Start,
                fill: ink,
                content: ser.name.clone(),
            });
            cursor += legend_entry_width(m, &ser.name, label_font);
        }
    }

    // 6b. 凡例(Left/Right: 縦並び)。
    if legend && matches!(spec.legend, LegendPos::Left | LegendPos::Right) {
        let entries: Vec<(String, Color)> = spec
            .series
            .iter()
            .map(|s| (s.name.clone(), s.fill_at(0)))
            .collect();
        let band_x = if spec.legend == LegendPos::Left {
            OUTER_PAD
        } else {
            spec.width - OUTER_PAD - legend_right
        };
        draw_vertical_legend(
            &mut items,
            &entries,
            band_x,
            plot_top,
            plot_bottom,
            ink,
            label_font,
        );
    }

    Scene {
        width: spec.width,
        height: spec.height,
        items,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::font::DEFAULT_FONT;
    use crate::ir::{AxisSpec, ChartKind, ChartSpec, LegendPos, Point, Series, SeriesType};
    use crate::text::TextMeasurer;

    fn make_scatter_spec(points: &[(f64, f64)]) -> ChartSpec {
        let palette = crate::palette::PALETTE.to_vec();
        ChartSpec {
            kind: ChartKind::Scatter,
            categories: vec![],
            series: vec![Series {
                name: String::new(),
                values: vec![],
                points: points
                    .iter()
                    .map(|&(x, y)| Point { x, y, r: None })
                    .collect(),
                fill: vec![palette[0]],
                stroke: vec![],
                stroke_width: 1.0,
                area: false,
                tension: 0.0,
                series_type: SeriesType::Bar,
                point_radius: None,
                box_points: vec![],
                tree: vec![],
            }],
            x_axis: AxisSpec {
                title: None,
                min: None,
                max: None,
                suggested_min: None,
                suggested_max: None,
                begin_at_zero: false,
                offset: false,
                grid: true,
            },
            y_axis: AxisSpec {
                title: None,
                min: None,
                max: None,
                suggested_min: None,
                suggested_max: None,
                begin_at_zero: false,
                offset: false,
                grid: true,
            },
            legend: LegendPos::None,
            title: None,
            width: 600.0,
            height: 400.0,
            data_labels: false,
            theme: crate::ir::Theme::default(),
        }
    }

    #[test]
    fn axis_domain_suggested_min_expands_below_data() {
        // x データが [1.0, 10.0]、suggested_min=-5.0 → ドメインが -5.0 まで広がる。
        let mut spec = make_scatter_spec(&[(1.0, 0.0), (10.0, 0.0)]);
        spec.x_axis.suggested_min = Some(-5.0);
        let (lo, _hi) = axis_domain(&spec, &spec.x_axis, |p| p.x);
        assert_eq!(
            lo, -5.0,
            "suggested_min=-5 はドメインを正確に -5.0 に設定すべき: 実際 lo={lo}"
        );
    }

    #[test]
    fn axis_domain_suggested_min_noop_when_data_lower() {
        // x データが [1.0, 10.0]、suggested_min=5.0 → データ(1.0)が優先されるので no-op。
        let mut spec = make_scatter_spec(&[(1.0, 0.0), (10.0, 0.0)]);
        spec.x_axis.suggested_min = Some(5.0);
        let (lo, _hi) = axis_domain(&spec, &spec.x_axis, |p| p.x);
        assert_eq!(
            lo, 1.0,
            "suggested_min=5 はデータの下端(1.0)を維持すべき: 実際 lo={lo}"
        );
    }

    #[test]
    fn axis_domain_suggested_max_expands_above_data() {
        // x データが [1.0, 10.0]、suggested_max=15.0 → ドメインが 15.0 まで広がる。
        let mut spec = make_scatter_spec(&[(1.0, 0.0), (10.0, 0.0)]);
        spec.x_axis.suggested_max = Some(15.0);
        let (_lo, hi) = axis_domain(&spec, &spec.x_axis, |p| p.x);
        assert_eq!(
            hi, 15.0,
            "suggested_max=15 はドメインを正確に 15.0 に設定すべき: 実際 hi={hi}"
        );
    }

    #[test]
    fn axis_domain_suggested_max_noop_when_data_higher() {
        // x データが [1.0, 10.0]、suggested_max=5.0 → データ(10.0)が優先されるので no-op。
        let mut spec = make_scatter_spec(&[(1.0, 0.0), (10.0, 0.0)]);
        spec.x_axis.suggested_max = Some(5.0);
        let (_lo, hi) = axis_domain(&spec, &spec.x_axis, |p| p.x);
        assert_eq!(
            hi, 10.0,
            "suggested_max=5 はデータの上端(10.0)を縮小してはいけない: 実際 hi={hi}"
        );
    }

    #[test]
    fn scatter_points_covers_all_series_and_indices() {
        let spec = make_scatter_spec(&[(1.0, 2.0), (3.0, 4.0), (5.0, 6.0)]);
        let m = TextMeasurer::new(DEFAULT_FONT).unwrap();
        let layout = compute_scatter_layout(&spec, &m);
        let pts = scatter_points(&spec, &layout);
        assert_eq!(pts.len(), 3);
        for (i, p) in pts.iter().enumerate() {
            assert_eq!(p.series, 0);
            assert_eq!(p.index, i);
            assert_eq!(p.kind, "scatter");
        }
    }

    #[test]
    fn scatter_points_cx_monotone_with_x_values() {
        let spec = make_scatter_spec(&[(1.0, 0.0), (5.0, 0.0), (10.0, 0.0)]);
        let m = TextMeasurer::new(DEFAULT_FONT).unwrap();
        let layout = compute_scatter_layout(&spec, &m);
        let pts = scatter_points(&spec, &layout);
        assert!(pts[0].cx < pts[1].cx && pts[1].cx < pts[2].cx);
    }

    #[test]
    fn scatter_points_skips_non_finite() {
        let spec = make_scatter_spec(&[(1.0, 2.0), (f64::NAN, 3.0), (5.0, 6.0)]);
        let m = TextMeasurer::new(DEFAULT_FONT).unwrap();
        let layout = compute_scatter_layout(&spec, &m);
        let pts = scatter_points(&spec, &layout);
        assert_eq!(pts.len(), 2);
    }
}
