"""GIBS Colormap v1.3 XML parser and natural-language summary generator.

Parses NASA GIBS colormap XML documents into structured models and generates
human-readable explanations of what the colors represent. This is the key
differentiator -- most GIBS tools just show tiles, but we tell you what
the colors actually mean.
"""

import re

import defusedxml.ElementTree as DefusedET

from mcgibs.models import ColorMap, ColorMapEntry, ColorMapSet, LegendEntry

# --- Unit conversion helpers ---

_KELVIN_OFFSET = 273.15


def _kelvin_to_celsius(k: float) -> float:
    """Convert Kelvin to Celsius."""
    return k - _KELVIN_OFFSET


# Map of units that benefit from inline conversion in explanations.
# key = canonical unit string (lowercase), value = (converter, target_unit)
_UNIT_CONVERTERS: dict[str, tuple] = {
    "k": (_kelvin_to_celsius, "C"),
    "kelvin": (_kelvin_to_celsius, "C"),
}


# --- Color naming ---


def _describe_rgb(rgb: tuple[int, int, int]) -> str:
    """Return an approximate human-friendly color name for an RGB triple.

    Not meant to be authoritative -- just helpful enough that someone
    reading the explanation can picture the color without seeing it.
    """
    r, g, b = rgb

    # Greyscale shortcuts
    brightness = (r + g + b) / 3
    max_c = max(r, g, b)
    spread = max_c - min(r, g, b)

    if spread < 20:
        if brightness < 40:
            return "black"
        if brightness < 100:
            return "dark grey"
        if brightness < 180:
            return "grey"
        if brightness < 230:
            return "light grey"
        return "white"

    # Determine hue (simplified)
    if max_c == 0:
        return "black"

    # Saturation proxy (0-1)
    saturation = spread / max_c

    # Lightness proxy
    lightness = brightness / 255

    prefix = ""
    if lightness < 0.3:
        prefix = "dark "
    elif lightness > 0.75 and saturation < 0.5:
        prefix = "light "
    elif lightness > 0.8:
        prefix = "pale "

    # Hue angle (degrees)
    if spread == 0:
        hue = 0.0
    else:
        fr = (max_c - r) / spread
        fg = (max_c - g) / spread
        fb = (max_c - b) / spread
        if r == max_c:
            hue = fb - fg
        elif g == max_c:
            hue = 2.0 + fr - fb
        else:
            hue = 4.0 + fg - fr
        hue = (hue * 60) % 360

    # Map hue to name
    if hue < 15 or hue >= 345:
        name = "red"
    elif hue < 40:
        name = "orange"
    elif hue < 70:
        name = "yellow"
    elif hue < 85:
        name = "yellow-green"
    elif hue < 160:
        name = "green"
    elif hue < 185:
        name = "cyan"
    elif hue < 250:
        name = "blue"
    elif hue < 290:
        name = "purple"
    elif hue < 330:
        name = "magenta"
    else:
        name = "pink"

    # Adjust for low-saturation warm tones
    if saturation < 0.3 and name in ("red", "orange", "yellow"):
        name = "tan" if lightness > 0.5 else "brown"

    return f"{prefix}{name}".strip()


# --- Interval notation parser ---

_INTERVAL_RE = re.compile(
    r"""
    [\[\(]                # opening bracket
    \s*
    ([+\-]?(?:INF|\d+(?:\.\d+)?(?:[eE][+\-]?\d+)?))  # low value or -INF
    \s*,\s*
    ([+\-]?(?:INF|\d+(?:\.\d+)?(?:[eE][+\-]?\d+)?))  # high value or +INF
    \s*
    [\]\)]                # closing bracket
    """,
    re.VERBOSE | re.IGNORECASE,
)

_SINGLE_VALUE_RE = re.compile(r"[\[\(]\s*([+\-]?\d+(?:\.\d+)?(?:[eE][+\-]?\d+)?)\s*[\]\)]")


def _parse_interval_value(interval: str) -> tuple[float | None, float | None]:
    """Parse GIBS interval notation into (low, high) numeric bounds.

    Handles these forms:
      "[200.0,200.5)"  -> (200.0, 200.5)
      "[-INF,200.0)"   -> (None, 200.0)
      "[320.0,+INF)"   -> (320.0, None)
      "[42]"           -> (42.0, 42.0)
    """
    if not interval:
        return (None, None)

    interval = interval.strip()

    m = _INTERVAL_RE.match(interval)
    if m:
        low_str, high_str = m.group(1), m.group(2)
        low = None if "INF" in low_str.upper() else float(low_str)
        high = None if "INF" in high_str.upper() else float(high_str)
        return (low, high)

    # Single-value bracket: "[42]"
    m = _SINGLE_VALUE_RE.match(interval)
    if m:
        val = float(m.group(1))
        return (val, val)

    # Last resort: try bare number
    try:
        val = float(interval)
        return (val, val)
    except ValueError:
        return (None, None)


# --- XML parsing ---


def _parse_rgb(raw: str) -> tuple[int, int, int]:
    """Parse "r,g,b" string into an integer triple.

    Returns (0, 0, 0) for malformed input rather than crashing.
    """
    parts = raw.split(",")
    if len(parts) < 3:
        return (0, 0, 0)
    try:
        return (int(parts[0]), int(parts[1]), int(parts[2]))
    except (ValueError, IndexError):
        return (0, 0, 0)


def parse_colormap(xml_text: str) -> ColorMapSet:
    """Parse a GIBS Colormap v1.3 XML document into a ColorMapSet.

    Expects the root element to be <ColorMaps> containing one or more
    <ColorMap> children. Each <ColorMap> contains <ColorMapEntry> elements
    and an optional <Legend> with <LegendEntry> children.
    """
    root = DefusedET.fromstring(xml_text)

    maps: list[ColorMap] = []

    # Handle both with and without namespace
    colormap_elements = root.findall("ColorMap")
    if not colormap_elements:
        colormap_elements = root.findall(".//{*}ColorMap")

    for cm_elem in colormap_elements:
        title = cm_elem.get("title", "")
        units = cm_elem.get("units", "")

        # Parse entries — GIBS v1.3 wraps them in <Entries>, but some
        # documents have <ColorMapEntry> as direct children of <ColorMap>.
        entries: list[ColorMapEntry] = []
        entry_elements = cm_elem.findall("Entries/ColorMapEntry")
        if not entry_elements:
            entry_elements = cm_elem.findall("ColorMapEntry")
        for entry_elem in entry_elements:
            rgb_raw = entry_elem.get("rgb", "0,0,0")
            entries.append(
                ColorMapEntry(
                    rgb=_parse_rgb(rgb_raw),
                    transparent=entry_elem.get("transparent", "false").lower() == "true",
                    nodata=entry_elem.get("nodata", "false").lower() == "true",
                    value=entry_elem.get("value"),
                    source_value=entry_elem.get("sourceValue"),
                    label=entry_elem.get("label"),
                    ref=entry_elem.get("ref"),
                )
            )

        # Parse legend
        legend_entries: list[LegendEntry] = []
        legend_type = "continuous"

        legend_elem = cm_elem.find("Legend")
        if legend_elem is not None:
            legend_type = legend_elem.get("type", "continuous")
            for le_elem in legend_elem.findall("LegendEntry"):
                rgb_raw = le_elem.get("rgb", "0,0,0")
                legend_entries.append(
                    LegendEntry(
                        rgb=_parse_rgb(rgb_raw),
                        tooltip=le_elem.get("tooltip", ""),
                        show_tick=le_elem.get("showTick", "false").lower() == "true",
                        show_label=le_elem.get("showLabel", "false").lower() == "true",
                        id=le_elem.get("id"),
                    )
                )

        maps.append(
            ColorMap(
                title=title,
                units=units,
                entries=entries,
                legend=legend_entries,
                legend_type=legend_type,
            )
        )

    return ColorMapSet(maps=maps)


# --- Natural-language explanation ---


def _format_value(val: float, units: str) -> str:
    """Format a numeric value with optional unit conversion."""
    units_lower = units.lower().strip()
    converter_info = _UNIT_CONVERTERS.get(units_lower)

    # Round for readability
    if abs(val) >= 100:
        display = f"{val:.0f}"
    elif abs(val) >= 1:
        display = f"{val:.1f}"
    else:
        display = f"{val:.2f}"

    result = f"{display} {units}" if units else display

    if converter_info:
        converter, target_unit = converter_info
        converted = converter(val)
        if abs(converted) >= 100:
            conv_display = f"{converted:.0f}"
        elif abs(converted) >= 1:
            conv_display = f"{converted:.1f}"
        else:
            conv_display = f"{converted:.2f}"
        result += f" ({conv_display} {target_unit})"

    return result


def _pick_sample_indices(n: int, count: int = 5) -> list[int]:
    """Pick evenly-spaced sample indices from a range of n items.

    Returns up to `count` indices including first and last.
    """
    if n <= 0:
        return []
    if n <= count:
        return list(range(n))
    step = (n - 1) / (count - 1)
    return [round(i * step) for i in range(count)]


def _entry_value_midpoint(entry: ColorMapEntry) -> float | None:
    """Extract the representative numeric value for a colormap entry.

    For ranges, returns the midpoint. For open-ended intervals, returns
    the finite bound.
    """
    if not entry.value:
        return None
    low, high = _parse_interval_value(entry.value)
    if low is not None and high is not None:
        return (low + high) / 2
    if low is not None:
        return low
    if high is not None:
        return high
    return None


def _describe_temperature_feel(celsius: float) -> str:
    """Return a short qualitative descriptor for a temperature in Celsius."""
    if celsius < -40:
        return "extreme cold"
    if celsius < -20:
        return "severe cold"
    if celsius < -5:
        return "cold"
    if celsius < 5:
        return "near freezing"
    if celsius < 20:
        return "mild"
    if celsius < 30:
        return "warm"
    if celsius < 40:
        return "hot"
    return "extreme heat"


def _describe_entry(entry: ColorMapEntry, units: str) -> str:
    """Build a single bullet-point description for one colormap entry."""
    color_name = _describe_rgb(entry.rgb)

    if entry.label:
        return f"{color_name} = {entry.label}"

    if not entry.value:
        return color_name

    low, high = _parse_interval_value(entry.value)
    units_lower = units.lower().strip()
    is_temp = units_lower in ("k", "kelvin")

    # Open-ended low: below threshold
    if low is None and high is not None:
        val_str = _format_value(high, units)
        desc = f"{color_name} = below {val_str}"
        if is_temp:
            desc += f" -- {_describe_temperature_feel(_kelvin_to_celsius(high))}"
        return desc

    # Open-ended high: above threshold
    if low is not None and high is None:
        val_str = _format_value(low, units)
        desc = f"{color_name} = above {val_str}"
        if is_temp:
            desc += f" -- {_describe_temperature_feel(_kelvin_to_celsius(low))}"
        return desc

    # Bounded range
    if low is not None and high is not None:
        mid = (low + high) / 2
        val_str = _format_value(mid, units)
        desc = f"{color_name} = ~{val_str}"
        if is_temp:
            desc += f" -- {_describe_temperature_feel(_kelvin_to_celsius(mid))}"
        return desc

    return color_name


def _gradient_summary(data_entries: list[ColorMapEntry]) -> str:
    """Produce a one-line summary of the overall color gradient direction."""
    if len(data_entries) < 2:
        return ""

    first_color = _describe_rgb(data_entries[0].rgb)
    last_color = _describe_rgb(data_entries[-1].rgb)

    # Find a midpoint color for richer description
    mid_idx = len(data_entries) // 2
    mid_color = _describe_rgb(data_entries[mid_idx].rgb)

    if first_color == last_color:
        return f"Color gradient passes through {mid_color} tones."

    return (
        f"Color gradient runs from {first_color}s (low values) "
        f"through {mid_color}s to {last_color}s (high values)."
    )


def explain_colormap(colormap_set: ColorMapSet) -> str:
    """Generate a natural-language explanation of a GIBS colormap.

    This is the key function -- it turns raw XML colormap data into
    something a human (or an LLM composing a response) can understand
    without needing to stare at an RGB table.
    """
    data_map = colormap_set.data_map
    if data_map is None:
        return "No colormap data available."

    # Filter to non-transparent, non-nodata entries for analysis
    data_entries = [e for e in data_map.entries if not e.transparent and not e.nodata]

    if not data_entries:
        return "This colormap contains only no-data / transparent entries."

    title = data_map.title or "Untitled layer"
    units = data_map.units or ""

    # Check if this is a classification (categorical) colormap
    has_numeric_values = any(_entry_value_midpoint(e) is not None for e in data_entries)

    if not has_numeric_values:
        # Classification / categorical colormap
        lines = [f"{title}:"]
        if data_map.legend_type == "classification":
            lines[0] = f"{title} (classification):"

        for entry in data_entries:
            label = entry.label or ""
            color_name = _describe_rgb(entry.rgb)
            if label:
                lines.append(f"  - {color_name} = {label}")
            else:
                lines.append(f"  - {color_name}")

        lines.append("")
        lines.append("This layer uses a classification colormap with distinct categories.")
        return "\n".join(lines)

    # Continuous / discrete numeric colormap
    header = title
    if units:
        header += f" ({units})"
    header += ":"

    sample_indices = _pick_sample_indices(len(data_entries))
    sampled = [data_entries[i] for i in sample_indices]

    lines = [header]
    for entry in sampled:
        bullet = _describe_entry(entry, units)
        lines.append(f"  - {bullet}")

    # Append gradient summary
    gradient = _gradient_summary(data_entries)
    if gradient:
        lines.append("")
        lines.append(gradient)

    # Note entry count for context
    total = len(data_entries)
    shown = len(sampled)
    if total > shown:
        lines.append(f"({total} color stops total, {shown} key values shown)")

    # Add no-data note if present in the full set
    nodata_entries = []
    for cm in colormap_set.maps:
        for e in cm.entries:
            if e.nodata:
                label = e.label or "No Data"
                nodata_entries.append(label)
    if nodata_entries:
        lines.append(f"Transparent / no-data: {', '.join(nodata_entries)}")

    return "\n".join(lines)
