"""WMTS GetCapabilities XML parser for NASA GIBS layers."""

import logging
import xml.etree.ElementTree as ET

import defusedxml.ElementTree as DefusedET

from mcgibs.constants import WMTS_NS
from mcgibs.models import BBox, LayerInfo, TimeDimension

log = logging.getLogger(__name__)

# Precompute fully-qualified namespace prefixes for element lookups.
# ElementTree findall/find with namespace dicts works for "prefix:Local" syntax,
# but child elements that inherit a default namespace need the {uri}Local form.
_WMTS_URI = WMTS_NS["wmts"]
_OWS_URI = WMTS_NS["ows"]
_XLINK_URI = WMTS_NS["xlink"]


def _text(el: ET.Element | None) -> str:
    """Safely extract text content, returning empty string for None."""
    if el is None:
        return ""
    return (el.text or "").strip()


def _find(parent: ET.Element, local_name: str, ns_uri: str | None = None) -> ET.Element | None:
    """Find child element, trying namespaced then unnamespaced.

    When the WMTS document declares a default namespace, child elements like
    <Default>, <Value>, <Format>, <TileMatrixSet>, etc. inherit it even though
    they appear unqualified in the raw XML.  This helper tries the namespaced
    form first (which matches real GIBS documents), then falls back to plain
    local name (for documents without a default namespace).
    """
    if ns_uri:
        el = parent.find(f"{{{ns_uri}}}{local_name}")
        if el is not None:
            return el
    return parent.find(local_name)


def _findall(parent: ET.Element, local_name: str, ns_uri: str | None = None) -> list[ET.Element]:
    """Find all child elements, combining namespaced and unnamespaced results."""
    results: list[ET.Element] = []
    seen_ids: set[int] = set()

    if ns_uri:
        for el in parent.findall(f"{{{ns_uri}}}{local_name}"):
            seen_ids.add(id(el))
            results.append(el)

    for el in parent.findall(local_name):
        if id(el) not in seen_ids:
            results.append(el)

    return results


def _parse_time_dimension(dim_el: ET.Element) -> TimeDimension | None:
    """Parse a WMTS Dimension element with ows:Identifier == 'Time'.

    Value strings follow the ISO 8601 interval pattern:
      "start/end/period"  e.g. "1980-01-01/2023-11-01/P1M"

    Single-date values or multi-range values are stored as-is.
    """
    ident = _text(_find(dim_el, "Identifier", _OWS_URI))
    if ident.lower() != "time":
        return None

    # <Default> and <Value> inherit the wmts default namespace
    default = _text(_find(dim_el, "Default", _WMTS_URI))
    value = _text(_find(dim_el, "Value", _WMTS_URI))

    start = None
    end = None
    period = None

    if value:
        # Value may contain comma-separated ranges; take the last (broadest) one
        last_range = value.split(",")[-1].strip()
        parts = last_range.split("/")
        if len(parts) == 3:
            start, end, period = parts
        elif len(parts) == 2:
            start, end = parts

    return TimeDimension(
        default=default or None,
        value=value or None,
        start=start or None,
        end=end or None,
        period=period or None,
    )


def _parse_bbox(layer_el: ET.Element) -> BBox | None:
    """Extract WGS84BoundingBox from a Layer element."""
    bb = _find(layer_el, "WGS84BoundingBox", _OWS_URI)
    if bb is None:
        return None

    lower = _text(_find(bb, "LowerCorner", _OWS_URI))
    upper = _text(_find(bb, "UpperCorner", _OWS_URI))
    if not lower or not upper:
        return None

    try:
        west, south = (float(v) for v in lower.split())
        east, north = (float(v) for v in upper.split())
    except (ValueError, TypeError):
        log.warning("Malformed bounding box: lower=%r upper=%r", lower, upper)
        return None

    return BBox(west=west, south=south, east=east, north=north)


def _extract_colormap_id_from_href(href: str) -> str | None:
    """Extract the colormap identifier from a GIBS colormap URL.

    GIBS metadata hrefs look like:
      https://gibs.earthdata.nasa.gov/colormaps/v1.3/AMSR2_Sea_Ice_Concentration.xml

    Multiple resolution layers often share a single colormap file, so the
    colormap filename may differ from the layer identifier.
    """
    if not href:
        return None
    filename = href.rstrip("/").rsplit("/", 1)[-1]
    if filename.endswith(".xml"):
        return filename[:-4]
    return None


def _detect_colormap(layer_el: ET.Element, identifier: str) -> tuple[bool, str | None]:
    """Detect whether a layer has a colormap.

    GIBS layers with colormaps typically have:
     - Metadata with xlink:role containing "colormap" (includes the URL)
     - A LegendURL element inside a Style

    Returns (has_colormap, colormap_id).  The colormap_id is extracted from
    the metadata href when available, falling back to the layer identifier.
    """
    # Check metadata first — the href carries the actual colormap filename
    for meta in _findall(layer_el, "Metadata", _OWS_URI):
        role = meta.get(f"{{{_XLINK_URI}}}role", "")
        href = meta.get(f"{{{_XLINK_URI}}}href", "")
        if "colormap" in role.lower() or "colormap" in href.lower():
            colormap_id = _extract_colormap_id_from_href(href) or identifier
            return True, colormap_id

    # Fallback: check for LegendURL in any Style
    for style in _findall(layer_el, "Style", _WMTS_URI):
        if _find(style, "LegendURL", _WMTS_URI) is not None:
            return True, identifier

    return False, None


def _parse_layer(layer_el: ET.Element) -> LayerInfo | None:
    """Parse a single WMTS Layer element into a LayerInfo."""
    identifier = _text(_find(layer_el, "Identifier", _OWS_URI))
    if not identifier:
        return None

    title = _text(_find(layer_el, "Title", _OWS_URI))
    abstract = _text(_find(layer_el, "Abstract", _OWS_URI))
    bbox = _parse_bbox(layer_el)

    # Formats
    formats = []
    for fmt_el in _findall(layer_el, "Format", _WMTS_URI):
        fmt = _text(fmt_el)
        if fmt and fmt not in formats:
            formats.append(fmt)

    # TileMatrixSetLink -> TileMatrixSet
    tile_matrix_sets = []
    for link_el in _findall(layer_el, "TileMatrixSetLink", _WMTS_URI):
        tms = _text(_find(link_el, "TileMatrixSet", _WMTS_URI))
        if tms and tms not in tile_matrix_sets:
            tile_matrix_sets.append(tms)

    # Time dimension
    time_dim = None
    for dim_el in _findall(layer_el, "Dimension", _WMTS_URI):
        time_dim = _parse_time_dimension(dim_el)
        if time_dim is not None:
            break

    # ResourceURL template
    resource_url_template = None
    for res_el in _findall(layer_el, "ResourceURL", _WMTS_URI):
        tpl = res_el.get("template", "")
        if tpl:
            resource_url_template = tpl
            break

    # Legend URL from Style
    legend_url = None
    for style in _findall(layer_el, "Style", _WMTS_URI):
        leg = _find(style, "LegendURL", _WMTS_URI)
        if leg is not None:
            legend_url = leg.get(f"{{{_XLINK_URI}}}href", "")
            break

    has_colormap, colormap_id = _detect_colormap(layer_el, identifier)

    return LayerInfo(
        identifier=identifier,
        title=title,
        abstract=abstract,
        formats=formats,
        tile_matrix_sets=tile_matrix_sets,
        time=time_dim,
        bbox=bbox,
        has_colormap=has_colormap,
        colormap_id=colormap_id,
        legend_url=legend_url or None,
        resource_url_template=resource_url_template,
    )


def parse_capabilities(xml_text: str) -> dict[str, LayerInfo]:
    """Parse WMTS GetCapabilities XML into a dict of layer_id -> LayerInfo.

    Handles the ~5MB GetCapabilities document from NASA GIBS by iterating
    through all Layer elements found under the wmts:Contents element.
    """
    root = DefusedET.fromstring(xml_text)

    layers: dict[str, LayerInfo] = {}

    contents = _find(root, "Contents", _WMTS_URI)
    if contents is None:
        log.warning("No Contents element found in capabilities document")
        return layers

    for layer_el in _findall(contents, "Layer", _WMTS_URI):
        info = _parse_layer(layer_el)
        if info is not None:
            layers[info.identifier] = info

    log.info("Parsed %d layers from WMTS capabilities", len(layers))
    return layers


def _score_match(layer: LayerInfo, query_lower: str) -> int:
    """Score a layer's relevance to a search query.

    Higher score = better match. Returns 0 if no match at all.
    Scoring tiers:
      - Title exact word match:       100
      - Identifier exact word match:   80
      - Title substring match:         60
      - Identifier substring match:    40
      - Other field matches:           20
    """
    score = 0
    title_lower = layer.title.lower()
    ident_lower = layer.identifier.lower()

    terms = query_lower.split()

    for term in terms:
        # Title word match (highest value -- most user-visible)
        title_words = title_lower.replace("-", " ").replace("_", " ").split()
        if term in title_words:
            score += 100
        elif term in title_lower:
            score += 60

        # Identifier word match
        ident_words = ident_lower.replace("-", " ").replace("_", " ").split()
        if term in ident_words:
            score += 80
        elif term in ident_lower:
            score += 40

        # Enriched metadata matches
        for field_val in (layer.measurement, layer.instrument, layer.platform, layer.description):
            if field_val and term in field_val.lower():
                score += 20

    return score


def search_layers(
    index: dict[str, LayerInfo],
    query: str,
    measurement: str | None = None,
    period: str | None = None,
    ongoing: bool | None = None,
    limit: int = 20,
) -> list[LayerInfo]:
    """Search the layer index with keyword matching and optional filters.

    Parameters
    ----------
    index:
        Dict of layer_id -> LayerInfo as returned by parse_capabilities().
    query:
        Free-text search string. Matched case-insensitively against title,
        identifier, measurement, instrument, and platform fields.
    measurement:
        Filter to layers whose measurement field matches (case-insensitive substring).
    period:
        Filter to layers whose period field matches, e.g. "Daily", "Monthly".
    ongoing:
        If True, only layers marked as ongoing. If False, only ended layers.
        None skips this filter.
    limit:
        Maximum number of results to return.
    """
    query_lower = query.strip().lower()
    candidates: list[tuple[int, LayerInfo]] = []

    for layer in index.values():
        # Apply hard filters first (skip scoring if filtered out)
        if measurement is not None:
            layer_meas = (layer.measurement or "").lower()
            if measurement.lower() not in layer_meas:
                continue

        if period is not None:
            layer_period = (layer.period or "").lower()
            if period.lower() != layer_period:
                continue

        if ongoing is not None and (layer.ongoing is None or layer.ongoing != ongoing):
            continue

        # Score against the query
        if query_lower:
            score = _score_match(layer, query_lower)
            if score <= 0:
                continue
        else:
            # No query text -- all non-filtered layers match equally
            score = 1

        candidates.append((score, layer))

    # Sort by score descending, then alphabetically by title for ties
    candidates.sort(key=lambda pair: (-pair[0], pair[1].title.lower()))

    return [layer for _, layer in candidates[:limit]]
