"""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)


def reverse_lookup(
    colormap_set: ColorMapSet, rgb: tuple[int, int, int]
) -> dict:
    """Map an RGB pixel value back to its data value using the colormap.

    Finds the colormap entry with the closest RGB match and returns
    the associated data value, units, and qualitative description.
    """
    data_map = colormap_set.data_map
    if data_map is None:
        return {"error": "No colormap data available"}

    # Check for no-data / transparent match first
    for entry in data_map.entries:
        if (entry.nodata or entry.transparent) and entry.rgb == rgb:
            return {"nodata": True, "label": entry.label or "No Data"}

    # Find closest data entry by RGB distance
    best_dist = float("inf")
    best_entry = None
    for entry in data_map.entries:
        if entry.transparent or entry.nodata:
            continue
        dr = entry.rgb[0] - rgb[0]
        dg = entry.rgb[1] - rgb[1]
        db = entry.rgb[2] - rgb[2]
        dist = dr * dr + dg * dg + db * db
        if dist < best_dist:
            best_dist = dist
            best_entry = entry

    if best_entry is None:
        return {"error": "No matching colormap entry"}

    units = data_map.units or ""
    color_name = _describe_rgb(best_entry.rgb)
    result: dict = {
        "color": color_name,
        "quality": "exact" if best_dist == 0 else "approximate",
    }

    if best_entry.label:
        result["label"] = best_entry.label
        return result

    low, high = _parse_interval_value(best_entry.value or "")
    if low is not None and high is not None:
        mid = (low + high) / 2
        result["value"] = round(mid, 2)
        if units:
            result["units"] = units
        converter_info = _UNIT_CONVERTERS.get(units.lower().strip())
        if converter_info:
            converter, target_unit = converter_info
            result["converted"] = f"{converter(mid):.1f} {target_unit}"
        is_temp = units.lower().strip() in ("k", "kelvin")
        if is_temp:
            result["description"] = _describe_temperature_feel(_kelvin_to_celsius(mid))
    elif low is not None:
        result["value"] = round(low, 2)
        if units:
            result["units"] = units

    return result
