#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
"""Pure sizing heuristics shared by Lark chart helper scripts."""

from __future__ import annotations

import math
import unicodedata
from typing import Any


MINIMUM_SIZES = {
    "column": (640, 400),
    "line": (640, 400),
    "area": (640, 400),
    "bar": (720, 420),
    "combo": (720, 420),
    "pie": (720, 440),
}
SUPPORTED_CHART_TYPES = {
    "column",
    "bar",
    "line",
    "area",
    "pie",
    "scatter",
    "combo",
    "radar",
    "bubble",
    "waterfall",
    "pareto",
}
DEFAULT_MINIMUM_SIZE = (640, 400)
MAX_CHART_WIDTH = 1600
MAX_CHART_HEIGHT = 720
MAX_ASPECT_RATIO = 2.6
COMBO_SERIES_TYPES = {"column", "line", "area", "scatter"}
COMBO_SERIES_Y_AXES = {"left", "right"}


def display_units(value: Any) -> int:
    """Estimate visible text width; CJK/full-width characters count double."""
    lines = str(value if value is not None else "").splitlines() or [""]
    return max(
        sum(2 if unicodedata.east_asian_width(char) in {"W", "F", "A"} else 1 for char in line)
        for line in lines
    )


def _round_up(value: float, step: int = 40) -> int:
    return int(math.ceil(value / step) * step)


def _round_down(value: float, step: int = 40) -> int:
    return int(math.floor(value / step) * step)


def _percentile(values: list[int], ratio: float) -> int:
    if not values:
        return 0
    ordered = sorted(values)
    return ordered[max(0, math.ceil(len(ordered) * ratio) - 1)]


def _has_clustered_small_slices(values: list[float]) -> bool:
    positive = [value for value in values if value > 0]
    total = sum(positive)
    if not total:
        return False
    shares = [value / total for value in positive]
    return max(shares, default=0) >= 0.75 and sum(share < 0.05 for share in shares) >= 3


def minimum_chart_size(chart_type: str) -> dict[str, int]:
    width, height = MINIMUM_SIZES.get(str(chart_type).lower(), DEFAULT_MINIMUM_SIZE)
    return {"width": width, "height": height}


def estimate_legend_rows(items: list[str], width: int) -> int:
    if not items:
        return 0
    available = max(240, width - 80)
    used = 0
    rows = 1
    for item in items:
        item_width = min(320, 34 + display_units(item) * 7)
        if used and used + item_width > available:
            rows += 1
            used = 0
        used += item_width
    return rows


def effective_category_labels(
    categories: list[Any], *, aggregate_categories: bool = True
) -> list[str]:
    labels = [str(value if value is not None else "") for value in categories]
    if aggregate_categories:
        return list(dict.fromkeys(labels))
    return labels


def effective_series_types(
    chart_type: str,
    series_count: int,
    series_types: list[str] | None = None,
) -> list[str]:
    chart_type = str(chart_type).lower()
    if chart_type != "combo":
        if series_types:
            raise ValueError("series_types is only valid for combo charts")
        return [chart_type] * series_count
    if series_types is None:
        return ["column", *(["line"] * max(0, series_count - 1))]
    normalized = [str(value).strip().lower() for value in series_types]
    if len(normalized) != series_count:
        raise ValueError("series_types length must match series_names")
    invalid = [value for value in normalized if value not in COMBO_SERIES_TYPES]
    if invalid:
        raise ValueError(f"unsupported combo series type: {invalid[0]}")
    return normalized


def effective_series_y_axes(
    chart_type: str,
    series_count: int,
    series_y_axes: list[str] | None = None,
) -> list[str]:
    chart_type = str(chart_type).lower()
    if chart_type != "combo":
        if series_y_axes:
            raise ValueError("series_y_axes is only valid for combo charts")
        return ["left"] * series_count
    if series_y_axes is None:
        return ["left", *(["right"] * max(0, series_count - 1))]
    normalized = [str(value).strip().lower() for value in series_y_axes]
    if len(normalized) != series_count:
        raise ValueError("series_y_axes length must match series_names")
    invalid = [value for value in normalized if value not in COMBO_SERIES_Y_AXES]
    if invalid:
        raise ValueError(f"unsupported combo series Y axis: {invalid[0]}")
    return normalized


def recommend_chart_size(
    *,
    chart_type: str,
    categories: list[Any],
    series_names: list[str],
    data_labels: str = "none",
    legend_position: str = "bottom",
    title: str = "",
    values: list[float] | None = None,
    aggregate_categories: bool = True,
    series_types: list[str] | None = None,
    series_y_axes: list[str] | None = None,
) -> dict[str, Any]:
    chart_type = str(chart_type).lower()
    if chart_type not in SUPPORTED_CHART_TYPES:
        raise ValueError(f"unsupported chart type: {chart_type}")
    category_text = effective_category_labels(
        categories,
        aggregate_categories=aggregate_categories,
    )
    category_count = len(category_text)
    series_count = max(1, len(series_names))
    normalized_series_types = effective_series_types(
        chart_type,
        series_count,
        series_types,
    )
    normalized_series_y_axes = effective_series_y_axes(
        chart_type,
        series_count,
        series_y_axes,
    )
    column_series_count = sum(value == "column" for value in normalized_series_types)
    line_like_series_count = series_count - column_series_count
    label_units = [display_units(value) for value in category_text]
    max_units = max(label_units, default=0)
    p75_units = _percentile(label_units, 0.75)
    max_lines = max((len(value.splitlines()) for value in category_text), default=1)
    labels_enabled = str(data_labels or "").lower() not in {"", "none"}
    minimum = minimum_chart_size(chart_type)
    width = float(minimum["width"])
    height = float(minimum["height"])
    reasons: list[str] = []
    advice: list[str] = []

    if chart_type == "pie":
        label_reserve = max(150, min(360, max_units * 7 + 60))
        width = max(width, 420 + 2 * label_reserve)
        if labels_enabled:
            reasons.append("outside_slice_labels")
        if values and _has_clustered_small_slices(values):
            height += 40
            reasons.append("clustered_small_slices")
        if category_count > 8:
            advice.append("prefer_bar_or_top_n")
        size_alone_is_insufficient = category_count > 12
    elif chart_type == "bar":
        width = max(width, 420 + max_units * 7)
        height = max(height, 190 + category_count * 36)
        height_limited = _round_up(height) > MAX_CHART_HEIGHT
        size_alone_is_insufficient = category_count > 24
        if height_limited:
            reasons.append("maximum_height_limited")
        if size_alone_is_insufficient:
            advice.extend(["use_top_n", "split_chart"])
    else:
        reserve = (
            230
            if chart_type == "combo" and "right" in normalized_series_y_axes
            else 170
        )
        line_dominant_combo = chart_type == "combo" and column_series_count == 0
        base_slot = 44 if chart_type in {"line", "area"} or line_dominant_combo else 52
        text_slot = 20 + p75_units * 7 * 0.72
        slot = max(base_slot, min(180, text_slot))
        if column_series_count <= 1 and category_count >= 10:
            # With many categories, Sheet rotates X-axis labels. Reserving each
            # label's full horizontal text width makes single-series charts
            # disproportionately wide; density checks below still expand when
            # data labels would actually collide.
            slot = min(slot, 68)
        if column_series_count > 1:
            slot = max(slot, 44 + 12 * min(column_series_count - 1, 4))
        if chart_type == "combo" and line_like_series_count > 1:
            slot += min(12, 4 * (line_like_series_count - 1))
        if labels_enabled:
            slot += min(24, 4 * series_count)
        width = max(width, reserve + category_count * slot)
        if p75_units > 12:
            height += 40
            reasons.append("long_category_labels")
        if max_lines > 1:
            height += min(120, 40 * (max_lines - 1))
            reasons.append("multiline_category_labels")
        size_alone_is_insufficient = (
            category_count > 20
            and (p75_units > 12 or series_count > 3 or labels_enabled)
        )
        if size_alone_is_insufficient:
            advice.extend(["prefer_bar_or_top_n", "split_chart"])

    width = min(MAX_CHART_WIDTH, _round_up(width))
    aspect_width_limit = max(minimum["width"], _round_down(height * MAX_ASPECT_RATIO))
    if width > aspect_width_limit:
        width = aspect_width_limit
        reasons.append("aspect_ratio_limited")
    legend_items = category_text if chart_type == "pie" else series_names
    legend_rows = 0
    if str(legend_position).lower() != "hidden":
        legend_rows = estimate_legend_rows(legend_items, width)
        if legend_rows > 1:
            height += (legend_rows - 1) * 32
            reasons.append("multi_row_legend")

    if title:
        reasons.append("chart_title")

    height = min(MAX_CHART_HEIGHT, _round_up(height))
    if size_alone_is_insufficient:
        if chart_type == "pie":
            height = max(height, 520)
        elif chart_type != "bar":
            width = max(width, 1200)
            height = max(height, 520)

    return {
        "minimum_size": minimum,
        "recommended_size": {"width": width, "height": height},
        "create_flags": {"width": width, "height": height},
        "evidence": {
            "chart_type": chart_type,
            "series_count": series_count,
            "series_types": normalized_series_types,
            "series_y_axes": normalized_series_y_axes,
            "column_series_count": column_series_count,
            "max_category_display_units": max_units,
            "p75_category_display_units": p75_units,
            "max_category_line_count": max_lines,
            "legend_rows": legend_rows,
            "data_labels": data_labels,
            "aggregate_categories": aggregate_categories,
            "recommended_aspect_ratio": round(width / height, 2),
        },
        "reasons": list(dict.fromkeys(reasons)),
        "layout_advice": list(dict.fromkeys(advice)),
        "size_alone_is_insufficient": size_alone_is_insufficient,
    }
