"""Map interface with date range selection and marker drawing capabilities."""

from datetime import datetime

from loguru import logger
from nicegui import events, ui

from vresto.api import BoundingBox, CatalogSearch
from vresto.products import ProductsManager

# Global state for current selection
# Default date range: January 2020 (whole month)
default_from = "2020-01-01"
default_to = "2020-01-31"
current_state = {
    "bbox": None,
    "date_range": {"from": default_from, "to": default_to},
    "products": [],
    "selected_product": None,
}


def create_map_interface():
    """Create a beautiful interface with date range selection and interactive map."""
    # Header (carded for nicer appearance)
    with ui.card().classes("w-full p-4 mb-4 shadow-md rounded-lg"):
        ui.label("Sentinel Browser").classes("text-3xl font-bold text-center")

    # Create tab headers (underline appearance)
    with ui.tabs().props('appearance="underline"').classes("w-full mb-2") as tabs:
        map_tab = ui.tab("Map Search", icon="map")
        name_tab = ui.tab("Search by Name", icon="search")
        download_tab = ui.tab("Download Product", icon="download")
        local_tab = ui.tab("Product Analysis", icon="folder_open")

    # Create tab content panels with full separation
    # We'll capture key UI components so callers/tests can inspect them
    date_picker = None
    messages_column = None
    map_widget = None
    results_display = None
    name_search_filters = None
    name_results_display = None

    with ui.tab_panels(tabs, value=map_tab).classes("w-full"):
        with ui.tab_panel(map_tab):
            # Map search tab content
            with ui.row().classes("w-full gap-6"):
                # Left sidebar: Date picker and activity log
                date_picker, messages_column = _create_sidebar()

                # Map with draw controls
                map_widget = _create_map(messages_column)

                # Right sidebar: Search controls and results
                results_display = _create_results_panel(messages_column)

        with ui.tab_panel(name_tab):
            # Name search tab content
            with ui.row().classes("w-full gap-6"):
                # Left sidebar with search filters
                name_search_filters = _create_name_search_sidebar()

                # Results panel
                name_results_display = _create_name_search_results_panel(name_search_filters)

        with ui.tab_panel(download_tab):
            # Download product tab content
            _create_download_tab()

        with ui.tab_panel(local_tab):
            # Local downloaded products inspector
            _create_local_products_tab()

    return {
        "tabs": tabs,
        "messages_column": messages_column,
        "map": map_widget,
        "date_picker": date_picker,
        "results_display": results_display,
        "name_search_filters": name_search_filters,
        "name_results_display": name_results_display,
    }


def _create_sidebar():
    """Create the left sidebar with date picker and activity log."""
    with ui.column().classes("w-80"):
        # Date picker card
        date_picker, date_display = _create_date_picker()

        # Activity log card
        messages_column = _create_activity_log()

    # Set up date monitoring
    _setup_date_monitoring(date_picker, date_display, messages_column)

    return date_picker, messages_column


def _create_activity_log():
    """Create the activity log panel."""
    with ui.card().classes("w-full flex-1 p-3 shadow-sm rounded-lg"):
        ui.label("Activity Log").classes("text-lg font-semibold mb-3")

        with ui.scroll_area().classes("w-full h-96"):
            messages_column = ui.column().classes("w-full gap-2")

    return messages_column


def _create_date_picker():
    """Create the date picker component."""
    with ui.card().classes("w-full p-3 shadow-sm rounded-lg"):
        ui.label("Select date (or range)").classes("text-lg font-semibold mb-1")

        # Default to January 2020 (whole month) if not set
        date_from = current_state.get("date_range", {}).get("from", "2020-01-01")
        date_to = current_state.get("date_range", {}).get("to", "2020-01-31")

        # Set initial value as a dict for range mode
        date_picker = ui.date(value={"from": date_from, "to": date_to}).props("range")
        date_picker.classes("w-full")

        date_display = ui.label("").classes("text-sm text-blue-600 mt-3 font-medium")

    # Store initial date in global state
    current_state["date_range"] = {"from": date_from, "to": date_to}

    return date_picker, date_display


def _setup_date_monitoring(date_picker, date_display, messages_column):
    """Set up monitoring and logging for date changes."""
    last_logged = {"value": None}

    def add_message(text: str):
        """Add a message to the activity log."""
        with messages_column:
            ui.label(text).classes("text-sm text-gray-700 break-words")

    def check_date_change():
        """Check if date has changed and log it."""
        current_value = date_picker.value

        # Format date for display and comparison
        if isinstance(current_value, dict):
            value_str = f"{current_value.get('from', '')}-{current_value.get('to', '')}"
            start = current_value.get("from", "")
            end = current_value.get("to", "")
            date_display.text = f"📅 {start} to {end}"
            message = f"📅 Date range selected: {start} to {end}"
            # Update global state
            current_state["date_range"] = current_value
        else:
            value_str = str(current_value)
            date_display.text = f"📅 {current_value}"
            message = f"📅 Date selected: {current_value}"
            # Update global state
            current_state["date_range"] = {"from": current_value, "to": current_value}

        # Log only if value has changed
        if value_str != last_logged["value"]:
            last_logged["value"] = value_str
            logger.info(message)
            add_message(message)

    # Initialize date display immediately
    check_date_change()

    # Poll for changes periodically
    ui.timer(0.5, check_date_change)


# date monitoring removed — dates are handled automatically from product names


def _create_map(messages_column):
    """Create the map with drawing controls."""
    with ui.card().classes("flex-1"):
        ui.label("Mark the location").classes("text-lg font-semibold mb-3")

        # Configure drawing tools
        draw_control = {
            "draw": {
                "marker": True,
            },
            "edit": {
                "edit": True,
                "remove": True,
            },
        }

        # Create map centered on Stockholm, Sweden
        m = ui.leaflet(center=(59.3293, 18.0686), zoom=13, draw_control=draw_control)
        m.classes("w-full h-screen rounded-lg")

        # Set up event handlers
        _setup_map_handlers(m, messages_column)

    return m


def _setup_map_handlers(m, messages_column):
    """Set up event handlers for map drawing actions."""

    def add_message(text: str):
        """Add a message to the activity log."""
        with messages_column:
            ui.label(text).classes("text-sm text-gray-700 break-words")

    def handle_draw(e: events.GenericEventArguments):
        """Handle drawing creation events."""
        layer_type = e.args["layerType"]
        coords = e.args["layer"].get("_latlng") or e.args["layer"].get("_latlngs")
        message = f"✅ Drawn {layer_type} at {coords}"
        logger.info(f"Drawn {layer_type} at {coords}")
        add_message(message)
        ui.notify(f"Marked a {layer_type}", position="top", type="positive")

        # Update global state with bounding box from drawn shape
        _update_bbox_from_layer(e.args["layer"], layer_type)

    def handle_edit():
        """Handle drawing edit events."""
        message = "✏️ Edit completed"
        logger.info("Edit completed")
        add_message(message)
        ui.notify("Locations updated", position="top", type="info")

    def handle_delete():
        """Handle drawing deletion events."""
        message = "🗑️ Marker deleted"
        logger.info("Marker deleted")
        add_message(message)
        ui.notify("Marker removed", position="top", type="warning")
        # Clear bbox from state
        current_state["bbox"] = None

    m.on("draw:created", handle_draw)
    m.on("draw:edited", handle_edit)
    m.on("draw:deleted", handle_delete)


def _create_results_panel(messages_column):
    """Create the results panel with search controls."""
    with ui.column().classes("w-96"):
        with ui.card().classes("w-full p-3 shadow-sm rounded-lg"):
            ui.label("Search Products").classes("text-lg font-semibold mb-3")

            # Collection selector
            collection_select = ui.select(
                label="Satellite Collection",
                options=["SENTINEL-2", "SENTINEL-1", "SENTINEL-3", "SENTINEL-5P"],
                value="SENTINEL-2",
            ).classes("w-full mb-3")

            # Product level filter (for Sentinel-2)
            product_level_select = ui.select(
                label="Product Level",
                options=["L1C", "L2A", "L1C + L2A"],
                value="L2A",
            ).classes("w-full mb-3")

            # Cloud cover filter (for optical sensors)
            cloud_cover_input = ui.number(label="Max Cloud Cover (%)", value=30, min=0, max=100, step=5).classes("w-full mb-3")

            # Max results
            max_results_input = ui.number(label="Max Results", value=10, min=1, max=100, step=5).classes("w-full mb-3")

            # Search button
            search_button = ui.button("🔍 Search Products")
            search_button.classes("w-full")
            search_button.props("color=primary")

            # Loading indicator label
            loading_label = ui.label("").classes("text-sm text-blue-600 mt-2 font-medium")

            async def perform_search_wrapper():
                await _perform_search(
                    messages_column,
                    results_display,
                    search_button,
                    loading_label,
                    collection_select.value,
                    product_level_select.value,
                    cloud_cover_input.value,
                    max_results_input.value,
                )

            search_button.on_click(perform_search_wrapper)

        # Results display
        with ui.card().classes("w-full flex-1 mt-4 p-3 shadow-sm rounded-lg"):
            ui.label("Results").classes("text-lg font-semibold mb-3")
            with ui.scroll_area().classes("w-full h-96"):
                results_display = ui.column().classes("w-full gap-2")

    return results_display


async def _perform_search(messages_column, results_display, search_button, loading_label, collection: str, product_level: str, max_cloud_cover: float, max_results: int):
    """Perform catalog search with current state.

    try:
        from PIL import Image

        cmap, labels = _scl_palette_and_labels()
        idx = np.clip(scl_arr, 0, len(cmap) - 1)
        rgb = cmap[idx].astype("uint8")
        try:
            rgb = np.flipud(rgb)
        except Exception:
            pass
        tmpf = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
        tmpf.close()
        Image.fromarray(rgb).save(tmpf.name, format="PNG")
    """

    def add_message(text: str):
        """Add a message to the activity log."""
        with messages_column:
            ui.label(text).classes("text-sm text-gray-700 break-words")

    def filter_products_by_level(products: list, level_filter: str) -> list:
        """Filter products by processing level.

        Args:
            products: List of ProductInfo objects
            level_filter: "L1C", "L2A", or "L1C + L2A"

        Returns:
            Filtered list of ProductInfo objects
        """
        if level_filter == "L1C + L2A":
            return products  # Keep all

        filtered = []
        for product in products:
            if level_filter in product.name:  # Check if L1C or L2A is in product name
                filtered.append(product)

        return filtered

    # Validate that we have necessary data
    if current_state["bbox"] is None:
        ui.notify("⚠️ Please drop a pin (or draw) a location on the map first", position="top", type="warning")
        add_message("⚠️ Search failed: No location selected")
        loading_label.text = ""
        return

    if current_state["date_range"] is None:
        ui.notify("⚠️ Please select a date range", position="top", type="warning")
        add_message("⚠️ Search failed: No date range selected")
        loading_label.text = ""
        return

    # Extract date range
    date_range = current_state["date_range"]
    start_date = date_range.get("from", "")
    end_date = date_range.get("to", start_date)

    # Show loading message and disable button
    ui.notify(f"🔍 Searching {collection} products ({product_level})...", position="top", type="info")
    add_message(f"🔍 Searching {collection} products ({product_level}) for {start_date} to {end_date}")

    # Disable search button and show loading state
    search_button.enabled = False
    original_text = search_button.text
    search_button.text = "⏳ Searching..."
    loading_label.text = "⏳ Searching..."

    # Clear previous results
    results_display.clear()
    with results_display:
        ui.spinner(size="lg")
        ui.label("Searching...").classes("text-gray-600")

    # Allow UI to render before starting the blocking search
    import asyncio

    await asyncio.sleep(0.1)

    try:
        # Perform search
        catalog = CatalogSearch()
        bbox = current_state["bbox"]

        products = catalog.search_products(
            bbox=bbox,
            start_date=start_date,
            end_date=end_date,
            collection=collection,
            max_cloud_cover=max_cloud_cover if collection in ["SENTINEL-2", "SENTINEL-3"] else None,
            max_results=int(max_results),
            product_level=product_level if product_level != "L1C + L2A" else None,
        )

        # Filter by product level if needed
        filtered_products = filter_products_by_level(products, product_level)

        # Display results
        results_display.clear()
        current_state["products"] = filtered_products

        if not filtered_products:
            with results_display:
                ui.label("No products found with selected level").classes("text-gray-500 italic")
            ui.notify("No products found with selected level", position="top", type="warning")
            add_message("❌ No products found with selected level")
        else:
            with results_display:
                ui.label(f"Found {len(filtered_products)} products (filtered from {len(products)} total)").classes("text-sm font-semibold text-green-600 mb-2")

                for i, product in enumerate(filtered_products, 1):
                    with ui.card().classes("w-full p-3 bg-gray-50 shadow-sm rounded-md"):
                        ui.label(f"{i}. {getattr(product, 'display_name', product.name)}").classes("text-xs font-mono break-all")
                        ui.label(f"📅 {product.sensing_date}").classes("text-xs text-gray-600")
                        ui.label(f"💾 {product.size_mb:.1f} MB").classes("text-xs text-gray-600")
                        if product.cloud_cover is not None:
                            ui.label(f"☁️ {product.cloud_cover:.1f}%").classes("text-xs text-gray-600")

                        # Buttons for quicklook and metadata
                        with ui.row().classes("w-full gap-2 mt-2"):
                            ui.button(
                                "🖼️ Quicklook",
                                on_click=lambda p=product: _show_product_quicklook(p, messages_column),
                            ).classes("text-xs flex-1")
                            ui.button(
                                "📋 Metadata",
                                on_click=lambda p=product: _show_product_metadata(p, messages_column),
                            ).classes("text-xs flex-1")

            ui.notify(f"✅ Found {len(filtered_products)} products", position="top", type="positive")
            add_message(f"✅ Found {len(filtered_products)} products (from {len(products)} total)")
            logger.info(f"Search completed: {len(filtered_products)} products found (filtered from {len(products)})")

            # Re-enable search button and clear loading label
            search_button.enabled = True
            search_button.text = original_text
            loading_label.text = ""

    except Exception as e:
        logger.error(f"Search failed: {e}")
        results_display.clear()
        with results_display:
            ui.label(f"Error: {str(e)}").classes("text-red-600 text-sm")
        ui.notify(f"❌ Search failed: {str(e)}", position="top", type="negative")
        add_message(f"❌ Search error: {str(e)}")

        # Re-enable search button and clear loading label
        search_button.enabled = True
        search_button.text = original_text
        loading_label.text = ""
    finally:
        # Ensure the search button and loading label are always reset
        try:
            search_button.enabled = True
            search_button.text = original_text
            loading_label.text = ""
        except Exception:
            # Defensive: ignore UI update errors
            pass


async def _show_product_quicklook(product, messages_column):
    """Show quicklook image for a product."""

    def add_message(text: str):
        """Add a message to the activity log."""
        with messages_column:
            ui.label(text).classes("text-sm text-gray-700 break-words")

    try:
        ui.notify("📥 Downloading quicklook...", position="top", type="info")
        add_message(f"📥 Downloading quicklook for {getattr(product, 'display_name', product.name)}")

        # Initialize products manager and download quicklook
        manager = ProductsManager()
        quicklook = manager.get_quicklook(product)

        if quicklook:
            # Show quicklook in a dialog
            with ui.dialog() as dialog:
                with ui.card():
                    ui.label(f"Quicklook: {getattr(product, 'display_name', product.name)}").classes("text-lg font-semibold mb-3")
                    ui.label(f"Sensing Date: {product.sensing_date}").classes("text-sm text-gray-600 mb-3")

                    # Display image
                    base64_image = quicklook.get_base64()
                    ui.image(source=f"data:image/jpeg;base64,{base64_image}").classes("w-full rounded-lg")

                    with ui.row().classes("w-full gap-2 mt-4"):
                        ui.button("Close", on_click=dialog.close).classes("flex-1")

            dialog.open()
            ui.notify("✅ Quicklook loaded", position="top", type="positive")
            add_message(f"✅ Quicklook loaded for {getattr(product, 'display_name', product.name)}")
        else:
            ui.notify("❌ Could not load quicklook", position="top", type="negative")
            add_message(f"❌ Quicklook not available for {getattr(product, 'display_name', product.name)}")

    except Exception as e:
        logger.error(f"Error loading quicklook: {e}")
        ui.notify(f"❌ Error: {str(e)}", position="top", type="negative")
        add_message(f"❌ Quicklook error: {str(e)}")


async def _show_product_metadata(product, messages_column):
    """Show metadata for a product."""

    def add_message(text: str):
        """Add a message to the activity log."""
        with messages_column:
            ui.label(text).classes("text-sm text-gray-700 break-words")

    try:
        ui.notify("📥 Downloading metadata...", position="top", type="info")
        add_message(f"📥 Downloading metadata for {getattr(product, 'display_name', product.name)}")

        # Initialize products manager and download metadata
        manager = ProductsManager()
        metadata = manager.get_metadata(product)

        if metadata:
            # Show metadata in a dialog with scrollable XML
            with ui.dialog() as dialog:
                with ui.card():
                    ui.label(f"Metadata: {getattr(product, 'display_name', product.name)}").classes("text-lg font-semibold mb-3")
                    ui.label("File: MTD_MSIL2A.xml").classes("text-sm text-gray-600 mb-3")

                    # Display metadata in a scrollable area
                    with ui.scroll_area().classes("w-full h-96"):
                        ui.code(metadata.metadata_xml, language="xml").classes("w-full text-xs")

                    with ui.row().classes("w-full gap-2 mt-4"):
                        ui.button("Close", on_click=dialog.close).classes("flex-1")

            dialog.open()
            ui.notify("✅ Metadata loaded", position="top", type="positive")
            add_message(f"✅ Metadata loaded for {getattr(product, 'display_name', product.name)}")
        else:
            ui.notify("❌ Could not load metadata", position="top", type="negative")
            add_message(f"❌ Metadata not available for {getattr(product, 'display_name', product.name)}")

    except Exception as e:
        logger.error(f"Error loading metadata: {e}")
        ui.notify(f"❌ Error: {str(e)}", position="top", type="negative")
        add_message(f"❌ Metadata error: {str(e)}")


def _update_bbox_from_layer(layer: dict, layer_type: str):
    """Extract bounding box from drawn layer and update global state."""
    try:
        if layer_type == "marker":
            # For a single marker, create a small bbox around it
            latlng = layer.get("_latlng", {})
            lat = latlng.get("lat")
            lng = latlng.get("lng")
            if lat is not None and lng is not None:
                # Create a ~1km bbox around the point
                delta = 0.01  # roughly 1km
                current_state["bbox"] = BoundingBox(west=lng - delta, south=lat - delta, east=lng + delta, north=lat + delta)
                logger.info(f"Updated bbox from marker: {current_state['bbox']}")
        # Add support for other shapes (rectangle, polygon) as needed
    except Exception as e:
        logger.error(f"Error extracting bbox from layer: {e}")


def _create_local_products_tab():
    """Create a tab for inspecting already downloaded products locally."""
    import os
    from pathlib import Path

    from vresto.products.downloader import _BAND_RE

    with ui.column().classes("w-full gap-4"):
        with ui.row().classes("w-full gap-6"):
            # Left: folder selector and controls
            with ui.column().classes("w-80"):
                with ui.card().classes("w-full"):
                    ui.label("Downloaded Products").classes("text-lg font-semibold mb-3")

                    folder_input = ui.input(label="Download folder", value=str(Path.home() / "vresto_downloads")).classes("w-full mb-3")
                    scan_btn = ui.button("🔎 Scan folder").classes("w-full")

                    ui.label("Filter (substring)").classes("text-sm text-gray-600 mt-3")
                    filter_input = ui.input(placeholder="partial product name...").classes("w-full mb-2")

            # Middle: product list and bands
            with ui.column().classes("w-96"):
                with ui.card().classes("w-full flex-1"):
                    ui.label("Products").classes("text-lg font-semibold mb-3")
                    # Keep a lightweight dropdown to allow quick selection; list is still shown below
                    products_select = ui.select(options=[], label="Discovered products").classes("w-full mb-2")
                    with ui.scroll_area().classes("w-full h-72"):
                        products_column = ui.column().classes("w-full gap-2")

            # Right: preview and band selection
            with ui.column().classes("flex-1"):
                with ui.card().classes("w-full flex-1"):
                    ui.label("Preview & Bands").classes("text-lg font-semibold mb-3")
                    preview_area = ui.column().classes("w-full")

    # state holders
    scanned_products: dict = {}

    def add_activity(msg: str):
        with products_column:
            ui.label(msg).classes("text-sm text-gray-700 break-words")

    def _scan_folder():
        root = folder_input.value or ""
        root = os.path.expanduser(root)
        products_column.clear()
        scanned_products.clear()
        if not root or not os.path.exists(root):
            ui.notify("⚠️ Folder does not exist", position="top", type="warning")
            add_activity("⚠️ Scan failed: folder does not exist")
            return
        # show loading state
        scan_btn.enabled = False
        original_scan_text = getattr(scan_btn, "text", "🔎 Scan folder")
        scan_btn.text = "⏳ Scanning..."
        add_activity(f"🔎 Scanning folder: {root}")
        # discover .SAFE directories or directories containing IMG_DATA (recursive)
        found_set = set()
        for dirpath, dirnames, filenames in os.walk(root):
            # detect any .SAFE directories directly under current dir
            for d in list(dirnames):
                if d.endswith(".SAFE"):
                    found_set.add(os.path.join(dirpath, d))
            # detect IMG_DATA; product root often two levels up from IMG_DATA
            if "IMG_DATA" in dirnames:
                img_dir = os.path.join(dirpath, "IMG_DATA")
                product_root = os.path.abspath(os.path.join(img_dir, "..", ".."))
                # try to find nearest .SAFE ancestor
                cur = product_root
                found_safe = False
                while cur and cur != os.path.dirname(cur):
                    if cur.endswith(".SAFE"):
                        found_set.add(cur)
                        found_safe = True
                        break
                    cur = os.path.dirname(cur)
                if not found_safe:
                    found_set.add(product_root)

        found = sorted(found_set)

        # apply filter
        flt = (filter_input.value or "").strip().lower()
        if flt:
            found = [p for p in found if flt in os.path.basename(p).lower()]

        if not found:
            add_activity("ℹ️ No products found in folder")
            ui.notify("No products found", position="top", type="info")
            scan_btn.enabled = True
            scan_btn.text = original_scan_text
            return

        add_activity(f"✅ Found {len(found)} products")

        names = []
        for p in sorted(found):
            pname = os.path.basename(p)
            display_name = pname[:-5] if pname.upper().endswith(".SAFE") else pname
            names.append(display_name)
            # keep mapping from display name to full path; if duplicates arise, last wins
            scanned_products[display_name] = p

        # populate select and product cards
        products_select.options = names
        if names:
            products_select.value = names[0]
            # show cards as well (displaying friendly names without .SAFE)
            for name in names:
                p = scanned_products[name]
                with products_column:
                    with ui.card().classes("w-full p-2 bg-gray-50"):
                        ui.label(name).classes("text-xs font-mono break-all")
                        with ui.row().classes("w-full gap-2 mt-2"):
                            ui.button("🔍 Inspect", on_click=lambda pp=p: _inspect_local_product(pp)).classes("text-xs")
            # auto-inspect first
            _inspect_local_product(scanned_products[names[0]])

        # restore scan button
        scan_btn.enabled = True
        scan_btn.text = original_scan_text

    def _inspect_local_product(path: str):
        # clear preview area and show bands
        preview_area.clear()
        try:
            # find IMG_DATA prefix within SAFE structure if present
            img_root = None
            if path.endswith(".SAFE"):
                granule = os.path.join(path, "GRANULE")
                if os.path.isdir(granule):
                    for g in os.scandir(granule):
                        img = os.path.join(g.path, "IMG_DATA")
                        if os.path.isdir(img):
                            img_root = img
                            break
            else:
                # try to find IMG_DATA under product dir
                granule = os.path.join(path, "GRANULE")
                if os.path.isdir(granule):
                    for g in os.scandir(granule):
                        img = os.path.join(g.path, "IMG_DATA")
                        if os.path.isdir(img):
                            img_root = img
                            break

            if not img_root:
                # fallback: search recursively for any jp2 files
                candidates = []
                for root, dirs, files in os.walk(path):
                    for f in files:
                        if f.lower().endswith(".jp2"):
                            candidates.append(os.path.join(root, f))
                if not candidates:
                    preview_area.add(ui.label("No image bands found locally").classes("text-sm text-gray-600"))
                    return
                img_root = os.path.dirname(candidates[0])

            # list files and extract bands
            bands_map = {}
            for root, dirs, files in os.walk(img_root):
                for f in files:
                    m = _BAND_RE.search(f)
                    if not m:
                        continue
                    band = m.group("band").upper()
                    res = int(m.group("res"))
                    bands_map.setdefault(band, set()).add(res)

            # Helper: choose best band file for a given band and preferred resolution
            def _find_band_file(band_name: str, preferred_resolution="native") -> str | None:
                matches = []
                for rootp, dirs, files in os.walk(img_root):
                    for f in files:
                        m = _BAND_RE.search(f)
                        if not m:
                            continue
                        b = m.group("band").upper()
                        if b != band_name.upper():
                            continue
                        try:
                            r = int(m.group("res"))
                        except Exception:
                            r = None
                        matches.append((r, os.path.join(rootp, f)))
                if not matches:
                    return None
                # prefer exact resolution if requested, otherwise smallest (best/native)
                if preferred_resolution != "native":
                    try:
                        pref = int(preferred_resolution)
                        for r, p in matches:
                            if r == pref:
                                return p
                    except Exception:
                        pass
                # fallback: choose smallest non-None resolution, else first
                valid = [m for m in matches if m[0] is not None]
                if valid:
                    best = min(valid, key=lambda x: x[0])[1]
                    return best
                return matches[0][1]

            with preview_area:
                ui.label(f"Product: {os.path.basename(path)}").classes("text-sm font-semibold")
                ui.label(f"IMG_DATA: {img_root}").classes("text-xs text-gray-600 mb-2")

                # static, non-interactive band list (for clarity)
                ui.label("Available bands:").classes("text-sm text-gray-600 mt-1")
                # Make band list scrollable to save vertical space
                with ui.card().classes("w-full p-2 bg-gray-50 mb-2"):
                    with ui.scroll_area().classes("w-full max-h-40"):
                        for band, resset in sorted(bands_map.items()):
                            ui.label(f"- {band}: resolutions {sorted(resset)}").classes("text-xs font-mono")

                # interactive selectors for visualization
                # Make single-band selector narrower so it doesn't span full width
                single_band_select = ui.select(
                    options=sorted(bands_map.keys()),
                    label="Single band to preview",
                    value=sorted(bands_map.keys())[0] if bands_map else None,
                ).classes("w-48 mb-2")
                # Note about RGB composite: choose bands automatically for a natural-color composite
                ui.label("Note: 'RGB composite' composes three bands (e.g. B04,B03,B02) to create an approximate natural-color image.").classes("text-xs text-gray-600 mb-2")

                # Visualization controls: resolution selector and mode
                RES_NATIVE_LABEL = "Native (best available per band)"
                # For in-browser previews we only support 60m (browsers can't handle full 10/20m JP2s reliably).
                with ui.row().classes("w-full gap-2 mt-2 mb-2"):
                    resolution_select = ui.select(options=["60", RES_NATIVE_LABEL], value="60").classes("w-48")
                    mode_select = ui.select(options=["Single band", "RGB composite", "All bands"], value="Single band").classes("w-48")

                ui.label("Important: Browser previews only support 60m resolution (or Native downsampled). 10m and 20m can't be rendered reliably in-browser; a tiler plugin will add full-resolution viewing soon.").classes("text-xs text-red-600 mb-2")

                band_names = sorted(bands_map.keys())

                # helper to pick default RGB bands
                def _default_rgb():
                    for combo in [("B04", "B03", "B02"), ("B04", "B03", "B02")]:
                        if all(b in bands_map for b in combo):
                            return combo
                    # fallback to first three
                    return tuple(band_names[:3])

                ui.row().classes("w-full items-center mt-2")
                preview_btn = ui.button("▶️ Preview").classes("text-sm")
                preview_spinner = ui.spinner(size="sm").classes("ml-2 hidden")
                import asyncio

                # single preview display area (replace contents on each preview)
                preview_display = ui.column().classes("w-full mt-2")

                async def _show_preview():
                    # set loading state so user gets immediate feedback
                    original_text = getattr(preview_btn, "text", "▶️ Preview")
                    try:
                        preview_btn.text = "⏳ Previewing..."
                    except Exception:
                        pass
                    preview_btn.enabled = False
                    try:
                        preview_spinner.remove_class("hidden")
                    except Exception:
                        pass

                    # allow UI to render spinner/button text
                    try:
                        await asyncio.sleep(0.05)
                    except Exception:
                        pass

                    try:
                        # determine desired bands
                        mode = mode_select.value
                        res_raw = resolution_select.value
                        resolution = "native" if res_raw == RES_NATIVE_LABEL else int(res_raw)
                        if mode == "RGB composite":
                            rgb_bands = _default_rgb()
                            _build_and_show_rgb(rgb_bands, img_root, resolution)
                        elif mode == "Single band":
                            band = single_band_select.value
                            if not band:
                                ui.notify("⚠️ No band selected for single-band preview", position="top", type="warning")
                            else:
                                _build_and_show_single(band, img_root, resolution)
                        else:  # All bands
                            all_bands = sorted(bands_map.keys())
                            if not all_bands:
                                ui.notify("⚠️ No bands available to show", position="top", type="warning")
                            else:
                                _build_and_show_all(all_bands, img_root, resolution)
                    finally:
                        # restore button and hide spinner
                        try:
                            preview_btn.text = original_text
                        except Exception:
                            pass
                        preview_btn.enabled = True
                        try:
                            preview_spinner.add_class("hidden")
                        except Exception:
                            pass

                preview_btn.on_click(lambda: asyncio.create_task(_show_preview()))

                def _build_and_show_rgb(bands_tuple, img_root_local, resolution_local):
                    # similar approach to earlier: use rasterio to read bands and compose
                    try:
                        import tempfile

                        import numpy as np

                        try:
                            import rasterio
                            from rasterio.enums import Resampling
                        except Exception:
                            ui.label("Rasterio not installed; cannot build RGB composite").classes("text-sm text-gray-600 mt-2")
                            return

                        # find files for requested bands and resolution (prefer exact resolution or native)
                        band_files = {}
                        for rootp, dirs, files in os.walk(img_root_local):
                            for f in files:
                                m = _BAND_RE.search(f)
                                if not m:
                                    continue
                                band = m.group("band").upper()
                                res = int(m.group("res"))
                                if band in bands_tuple:
                                    if resolution_local == "native" or res == int(resolution_local):
                                        band_files.setdefault(band, os.path.join(rootp, f))
                        # if resolution requested but missing for some band, try native
                        for b in bands_tuple:
                            if b not in band_files:
                                # find any available
                                for rootp, dirs, files in os.walk(img_root_local):
                                    for f in files:
                                        m = _BAND_RE.search(f)
                                        if not m:
                                            continue
                                        band = m.group("band").upper()
                                        if band == b:
                                            band_files[b] = os.path.join(rootp, f)
                                            break
                                    if b in band_files:
                                        break

                        if not all(b in band_files for b in bands_tuple):
                            ui.label("Requested bands not fully available locally").classes("text-sm text-gray-600 mt-2")
                            return

                        srcs = {b: rasterio.open(band_files[b]) for b in bands_tuple}
                        # choose reference by smallest pixel size
                        resolutions_map = {b: abs(s.transform.a) for b, s in srcs.items()}
                        ref_band = min(resolutions_map, key=resolutions_map.get)
                        ref = srcs[ref_band]

                        # Determine reference native shape
                        ref_height = ref.height
                        ref_width = ref.width
                        # Compute preview target shape (preserve aspect ratio)
                        out_h, out_w = _compute_preview_shape(ref_height, ref_width)
                        dtype_bytes = 2  # typical uint16 JP2 -> 2 bytes; conservative estimate
                        estimated_bytes = int(ref_height) * int(ref_width) * 3 * dtype_bytes

                        # check current RSS and avoid huge allocations — set safe threshold to 1 GiB extra
                        current_rss = _get_memory_info()
                        SAFE_EXTRA = 1 * 1024 * 1024 * 1024

                        if current_rss is not None and estimated_bytes > SAFE_EXTRA:
                            # fallback: downsample to a smaller preview-friendly size to avoid OOM
                            out_h, out_w = _compute_preview_shape(ref_height, ref_width, max_dim=1024)
                            msg = f"Full-res preview ({ref_width}x{ref_height}) would need ~{_format_bytes(estimated_bytes)}; downsampling to {out_w}x{out_h} for safety (RSS={_format_bytes(current_rss)})"
                            logger.warning(msg)
                            with preview_display:
                                ui.label(msg).classes("text-xs text-red-600")
                            arrs = []
                            for b in bands_tuple:
                                s = srcs[b]
                                try:
                                    data = s.read(1, out_shape=(out_h, out_w), resampling=Resampling.bilinear)
                                except Exception:
                                    data = s.read(1)
                                    data = _resize_array_to_preview(data, PREVIEW_MAX_DIM)
                                arrs.append(data)
                        else:
                            arrs = []
                            for b in bands_tuple:
                                s = srcs[b]
                                try:
                                    # request out_shape for preview
                                    data = s.read(1, out_shape=(out_h, out_w), resampling=Resampling.bilinear)
                                except Exception:
                                    data = s.read(1)
                                    data = _resize_array_to_preview(data, PREVIEW_MAX_DIM)
                                arrs.append(data)
                        rgb = np.stack(arrs, axis=-1)
                        p1 = np.percentile(rgb, 2)
                        p99 = np.percentile(rgb, 98)
                        rgb = (rgb - p1) / max((p99 - p1), 1e-6)
                        rgb = (np.clip(rgb, 0.0, 1.0) * 255).astype("uint8")
                        # flip vertically so (0,0) array index maps to bottom-left in the saved image
                        try:
                            rgb = np.flipud(rgb)
                        except Exception:
                            pass
                        tmpf = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
                        tmpf.close()
                        # Try several image writers: Pillow, imageio, matplotlib
                        wrote = False
                        try:
                            from PIL import Image

                            Image.fromarray(rgb).save(tmpf.name, quality=85)
                            wrote = True
                        except Exception:
                            try:
                                import imageio

                                imageio.imwrite(tmpf.name, rgb)
                                wrote = True
                            except Exception:
                                try:
                                    import matplotlib.pyplot as plt

                                    plt.imsave(tmpf.name, rgb)
                                    wrote = True
                                except Exception:
                                    wrote = False

                        # update single preview area
                        preview_display.clear()
                        with preview_display:
                            if wrote:
                                ui.image(source=tmpf.name).classes("w-full rounded-lg mt-2")
                            else:
                                ui.label("Cannot write preview image; install Pillow or imageio (e.g. `pip install Pillow imageio`)").classes("text-sm text-gray-600 mt-2")
                        # cleanup opened datasets
                        for s in srcs.values():
                            try:
                                s.close()
                            except Exception:
                                pass
                    except Exception as e:
                        logger.exception("Error building RGB: %s", e)
                        ui.label(f"Error building RGB preview: {e}").classes("text-sm text-red-600 mt-2")

                def _build_and_show_single(band, img_root_local, resolution_local):
                    """Render a single band using a viridis colormap and colorbar if possible."""
                    try:
                        import numpy as np

                        try:
                            import rasterio
                        except Exception:
                            preview_display.clear()
                            with preview_display:
                                ui.label("Rasterio not installed; cannot render band").classes("text-sm text-gray-600 mt-2")
                            return

                        # locate band file using consistent selection helper
                        band_file = _find_band_file(band, preferred_resolution=resolution_local)

                        if not band_file:
                            preview_display.clear()
                            with preview_display:
                                ui.label("Band file not found locally").classes("text-sm text-gray-600 mt-2")
                            return

                        s = rasterio.open(band_file)
                        # compute preview target shape based on native size
                        out_h, out_w = _compute_preview_shape(s.height, s.width)
                        try:
                            data = s.read(1, out_shape=(out_h, out_w), resampling=rasterio.enums.Resampling.bilinear)
                        except Exception:
                            data = s.read(1)
                            data = _resize_array_to_preview(data, PREVIEW_MAX_DIM)

                        # scale to 0..1 using min/max
                        vmin = float(np.nanmin(data))
                        vmax = float(np.nanmax(data))
                        denom = vmax - vmin if (vmax - vmin) != 0 else 1.0
                        normalized = (data - vmin) / denom

                        # try plotly first for interactive heatmap
                        # if this is SCL, render with SCL palette and legend side-by-side using Plotly
                        if band and band.strip().upper() == "SCL":
                            logger.info("Rendering SCL single-band preview for band=%s", band)
                            scl_arr = data.astype("int")
                            # ensure SCL is preview-sized
                            try:
                                scl_arr = _resize_array_to_preview(scl_arr, PREVIEW_MAX_DIM)
                            except Exception:
                                pass

                            # Try Plotly first
                            logger.info("Attempting Plotly SCL rendering...")
                            fig_scl, scl_msg = _scl_plotly_figure_from_array(scl_arr)
                            if fig_scl is not None:
                                logger.info("Plotly SCL rendering succeeded")
                                preview_display.clear()
                                with preview_display:
                                    legend_png = _scl_legend_image(box_width=48, box_height=20, pad=6)
                                    with ui.row().classes("w-full gap-2"):
                                        with ui.column().classes("flex-1"):
                                            ui.plotly(fig_scl).classes("w-full rounded-lg mt-2")
                                            if scl_msg:
                                                ui.label(scl_msg).classes("text-xs text-gray-600 mt-1")
                                        with ui.column().classes("w-72"):
                                            if legend_png is not None:
                                                ui.image(source=legend_png).classes("w-full rounded-lg mt-2")
                                            else:
                                                _scl_legend_html_inline()
                                try:
                                    s.close()
                                except Exception:
                                    pass
                                return
                            # Plotly rendering unavailable — show informative message (no static fallback)
                            preview_display.clear()
                            with preview_display:
                                ui.label("Could not render SCL interactively; install plotly (`pip install plotly`).").classes("text-sm text-gray-600 mt-2")
                            try:
                                s.close()
                            except Exception:
                                pass
                            return

                        # try plotly first for interactive heatmap (non-SCL)
                        try:
                            import plotly.graph_objects as go

                            fig = go.Figure(go.Heatmap(z=normalized, colorscale="Viridis", colorbar=dict(title="scaled")))
                            # Preserve aspect ratio: size figure proportional to data shape and lock y-axis scale to x
                            rows, cols = normalized.shape if len(normalized.shape) == 2 else (normalized.shape[0], normalized.shape[1])
                            base_width = 700
                            min_h = 200
                            max_h = 900
                            try:
                                height = max(min_h, min(max_h, int(base_width * (rows / cols))))
                            except Exception:
                                height = 400
                            fig.update_layout(margin=dict(l=0, r=0, t=0, b=0), width=base_width, height=height)
                            fig.update_yaxes(rangemode="tozero", scaleanchor="x", scaleratio=1)
                            preview_display.clear()
                            with preview_display:
                                ui.plotly(_ensure_plotly_origin_bottom_left(fig)).classes("w-full rounded-lg mt-2")
                                ui.label(f"renderer: plotly (interactive)  •  min={vmin:.3f} max={vmax:.3f}  •  shape={data.shape} dtype={data.dtype}").classes("text-xs text-gray-600 mt-1")
                            try:
                                s.close()
                            except Exception:
                                pass
                            return
                        except Exception:
                            pass

                        # No static fallback — interactive Plotly required for single-band previews
                        preview_display.clear()
                        with preview_display:
                            ui.label("Could not render interactive preview; install plotly (`pip install plotly`).").classes("text-sm text-gray-600 mt-2")
                        try:
                            s.close()
                        except Exception:
                            pass

                    except Exception as e:
                        logger.exception("Error building single-band: %s", e)
                        preview_display.clear()
                        with preview_display:
                            ui.label(f"Error building band preview: {e}").classes("text-sm text-red-600 mt-2")

                def _build_and_show_all(bands_list, img_root_local, resolution_local):
                    """Create NxN grid of thumbnails for all bands and show as one image."""
                    try:
                        import math

                        import numpy as np

                        try:
                            import rasterio
                        except Exception:
                            ui.label("Rasterio not installed; cannot build band grid").classes("text-sm text-gray-600 mt-2")
                            return

                        thumbs = []
                        # limit number of bands to avoid huge images (cap at 64)
                        bands_list = bands_list[:64]
                        # keep mapping band -> original file path for re-opening (useful for SCL full render)
                        band_files_map: dict = {}
                        for band in bands_list:
                            # choose band file consistently using helper (respect requested resolution)
                            band_file = _find_band_file(band, preferred_resolution=resolution_local)
                            if not band_file:
                                # placeholder gray tile
                                thumbs.append(None)
                                continue
                            # remember original file for possible full-res reads later
                            band_files_map[band.upper()] = band_file
                            s = rasterio.open(band_file)
                            # read into preview-sized array (e.g., up to PREVIEW_MAX_DIM) to represent 60m preview
                            try:
                                p_h, p_w = _compute_preview_shape(s.height, s.width)
                                data_preview = s.read(1, out_shape=(p_h, p_w), resampling=rasterio.enums.Resampling.bilinear)
                            except Exception:
                                # fallback to reading full then resizing
                                data_preview = s.read(1)
                                data_preview = _resize_array_to_preview(data_preview, PREVIEW_MAX_DIM)

                            # then build a small thumbnail from the preview array for grid display
                            try:
                                native_res = int(round(abs(s.transform.a)))
                            except Exception:
                                native_res = None
                            try:
                                orig_shape = (s.height, s.width)
                            except Exception:
                                orig_shape = None

                            # create 128x128 tile from preview array
                            try:
                                tile_rgb = None
                                if band.upper() == "SCL":
                                    cmap_scl, _labels = _scl_palette_and_labels()
                                    idx = np.clip(data_preview.astype("int"), 0, len(cmap_scl) - 1)
                                    tile_rgb = cmap_scl[idx].astype("uint8")
                                else:
                                    p1 = np.percentile(data_preview, 2)
                                    p99 = np.percentile(data_preview, 98)
                                    img = (np.clip((data_preview - p1) / max((p99 - p1), 1e-6), 0, 1) * 255).astype("uint8")
                                    tile_rgb = np.stack([img, img, img], axis=-1)
                                # resize the preview array to 128x128 for the grid tile using PIL helper
                                tile_small = _resize_array_to_preview(tile_rgb, max_dim=128)
                                thumbs.append({"img": tile_small, "res_m": native_res, "shape": orig_shape, "preview_shape": (p_h, p_w)})
                            except Exception:
                                thumbs.append(None)
                            try:
                                s.close()
                            except Exception:
                                pass

                        # Helper: function to render a grid given a list of (band_name, tile) pairs
                        def render_grid(pairs, title_prefix=None):
                            import plotly.graph_objects as go
                            from plotly.subplots import make_subplots

                            n = len(pairs)
                            if n == 0:
                                return None

                            cols = int(math.ceil(math.sqrt(n)))
                            rows = int(math.ceil(n / cols))

                            # Titles include band name, pixel dims and native resolution (if available)
                            titles = []
                            for name, t in pairs:
                                if t is None:
                                    titles.append(name)
                                    continue
                                if isinstance(t, dict):
                                    shape = t.get("shape")
                                    resm = t.get("res_m")
                                else:
                                    shape = None
                                    resm = None
                                shape_str = f"{shape[1]}x{shape[0]} px" if shape else "- px"
                                res_str = f"{resm}m" if resm else "-m"
                                titles.append(f"{name}\n{shape_str}\n{res_str}")

                            col_w = [1.0 / cols] * cols
                            row_h = [1.0 / rows] * rows
                            fig = make_subplots(
                                rows=rows,
                                cols=cols,
                                subplot_titles=titles,
                                column_widths=col_w,
                                row_heights=row_h,
                                horizontal_spacing=0.01,
                                vertical_spacing=0.02,
                            )

                            for idx, (_name, t) in enumerate(pairs):
                                r = idx // cols + 1
                                c = idx % cols + 1
                                if t is None:
                                    tile = np.zeros((128, 128, 3), dtype="uint8") + 80
                                    try:
                                        tile = np.flipud(tile)
                                    except Exception:
                                        pass
                                    trace = go.Image(z=tile)
                                else:
                                    # t may be a dict with metadata
                                    if isinstance(t, dict):
                                        t_img = t.get("img")
                                    else:
                                        t_img = t
                                    if getattr(t_img, "dtype", None) != np.uint8:
                                        t_img = (np.clip(t_img, 0, 1) * 255).astype("uint8") if t_img.max() <= 1 else t_img.astype("uint8")
                                    try:
                                        t_img = np.flipud(t_img)
                                    except Exception:
                                        pass
                                    trace = go.Image(z=t_img)
                                fig.add_trace(trace, row=r, col=c)
                                # Ensure this subplot uses bottom-left origin and preserve aspect ratio
                                try:
                                    fig.update_yaxes(rangemode="tozero", autorange="reversed", row=r, col=c)
                                except Exception:
                                    pass
                                try:
                                    fig.update_yaxes(scaleanchor="x", scaleratio=1, row=r, col=c)
                                except Exception:
                                    pass

                            fig.update_xaxes(showticklabels=False, showgrid=False, zeroline=False)
                            fig.update_yaxes(showticklabels=False, showgrid=False, zeroline=False, rangemode="tozero", autorange="reversed")

                            tile_px = 280
                            width = min(3000, cols * tile_px)
                            height = min(3000, rows * tile_px)
                            fig.update_layout(margin=dict(l=6, r=6, t=30, b=6), width=width, height=height, showlegend=False)
                            fig.update_xaxes(matches="x", showticklabels=False, showgrid=False, zeroline=False)
                            fig.update_yaxes(matches="y", showticklabels=False, showgrid=False, zeroline=False, rangemode="tozero", autorange="reversed")
                            # Ensure image origin is bottom-left (0,0 at bottom-left)
                            try:
                                fig.update_yaxes(rangemode="tozero", autorange="reversed")
                            except Exception:
                                pass
                            try:
                                for ann in fig.layout.annotations:
                                    ann.font.size = 12
                            except Exception:
                                pass

                            try:
                                return fig
                            except Exception:
                                return fig

                        # Build mapping of band name -> thumbnail (preserve order)
                        pairs = []
                        for idx, band in enumerate(bands_list):
                            pairs.append((band, thumbs[idx] if idx < len(thumbs) else None))

                        # Prepare three separate groups for display (case-insensitive)
                        # Exclude SCL from the main B* grid — show SCL only in its separate panel
                        b_pairs = [(n, t) for (n, t) in pairs if n.upper().startswith("B")]
                        scl_pairs = [(n, t) for (n, t) in pairs if n.upper() == "SCL"]
                        special_pairs = [(n, t) for (n, t) in pairs if n.upper() in ("AOT", "TCI", "WVP")]

                        # Function to show a plotly figure or fallback image in preview_display
                        def show_fig_or_fallback(fig_obj):
                            preview_display.clear()
                            with preview_display:
                                if fig_obj is None:
                                    ui.label("No bands to display").classes("text-sm text-gray-600 mt-2")
                                else:
                                    try:
                                        ui.plotly(fig_obj).classes("w-full rounded-lg mt-2")
                                    except Exception:
                                        ui.label("Could not render interactive figure").classes("text-sm text-gray-600")

                        # Render and display the three requested figures in sequence (B-band grid, SCL, special group)
                        # Render all three groups into the preview area without clearing between them
                        preview_display.clear()
                        # 1) B* bands grid
                        try:
                            b_fig = render_grid(b_pairs) if b_pairs else None
                            # placeholder label for SCL note (filled later if downsampling occurred)
                            b_scl_note_label = None
                            with preview_display:
                                ui.label("B* Bands Grid").classes("text-sm font-semibold mb-1")
                                if b_fig is not None:
                                    try:
                                        ui.plotly(b_fig).classes("w-full rounded-lg mt-2")
                                    except Exception:
                                        ui.label("Could not render B* bands interactively").classes("text-sm text-gray-600 mt-2")
                                else:
                                    ui.label("No B* bands available").classes("text-sm text-gray-600 mt-2")
                                # create an initially-empty label we can update later with SCL downsample info
                                b_scl_note_label = ui.label("").classes("text-xs text-gray-600 mt-1")
                        except Exception:
                            b_scl_note_label = None

                        # 2) SCL with custom colormap rendered as interactive Plotly image
                        try:
                            with preview_display:
                                ui.label("SCL (Scene Classification)").classes("text-sm font-semibold mt-4 mb-1")
                            if scl_pairs:
                                # Prefer reading the full SCL band from disk if available for accurate classes
                                scl_arr = None
                                scl_file = None
                                try:
                                    scl_file = _find_band_file("SCL", preferred_resolution=resolution_local)
                                except Exception:
                                    scl_file = None

                                if scl_file:
                                    try:
                                        s_full = rasterio.open(scl_file)
                                        scl_arr = s_full.read(1)
                                        try:
                                            s_full.close()
                                        except Exception:
                                            pass
                                    except Exception:
                                        scl_arr = None

                                # fallback: if no full file, attempt to derive class indices from thumbnail dict 'idx'
                                if scl_arr is None:
                                    scl_tile = scl_pairs[0][1]
                                    if scl_tile is None:
                                        raise ValueError("SCL tile missing")
                                    # if thumbnail is a dict with 'idx', use it directly
                                    if isinstance(scl_tile, dict) and "idx" in scl_tile:
                                        scl_arr = scl_tile.get("idx")
                                    else:
                                        # if thumbnail is RGB, we cannot reliably recover indices; try using first channel as proxy
                                        if getattr(scl_tile, "ndim", 0) == 3:
                                            scl_arr = scl_tile[..., 0]
                                        else:
                                            scl_arr = scl_tile

                                fig_scl, scl_msg = _scl_plotly_figure_from_array(scl_arr)
                                legend_png = _scl_legend_image(box_width=48, box_height=20, pad=6)
                                with preview_display:
                                    with ui.row().classes("w-full gap-2"):
                                        with ui.column().classes("flex-1"):
                                            if fig_scl is not None:
                                                try:
                                                    ui.plotly(fig_scl).classes("w-full rounded-lg mt-2")
                                                    if scl_msg:
                                                        ui.label(scl_msg).classes("text-xs text-gray-600 mt-1")
                                                        # if the B* grid placeholder exists, also set the note there for the grid view
                                                        try:
                                                            if b_scl_note_label is not None:
                                                                b_scl_note_label.text = f"SCL note: {scl_msg}"
                                                        except Exception:
                                                            pass
                                                except Exception:
                                                    ui.label("Could not render SCL interactively").classes("text-sm text-gray-600 mt-2")
                                            else:
                                                ui.label("SCL rendering not available").classes("text-sm text-gray-600 mt-2")
                                        with ui.column().classes("w-72"):
                                            if legend_png is not None:
                                                ui.image(source=legend_png).classes("w-full rounded-lg mt-2")
                                            else:
                                                _scl_legend_html_inline()
                            else:
                                with preview_display:
                                    ui.label("SCL band not present in product").classes("text-sm text-gray-600 mt-2")
                        except Exception:
                            pass

                        # 3) AOT, TCI, WVP group
                        try:
                            special_fig = render_grid(special_pairs) if special_pairs else None
                            with preview_display:
                                ui.label("AOT / TCI / WVP").classes("text-sm font-semibold mt-4 mb-1")
                                if special_fig is not None:
                                    try:
                                        ui.plotly(special_fig).classes("w-full rounded-lg mt-2")
                                    except Exception:
                                        ui.label("Could not render special group interactively").classes("text-sm text-gray-600 mt-2")
                                else:
                                    ui.label("No AOT/TCI/WVP bands available").classes("text-sm text-gray-600 mt-2")
                        except Exception:
                            pass

                    except Exception as e:
                        logger.exception("Error building all-bands grid: %s", e)
                        ui.label(f"Error building band grid: {e}").classes("text-sm text-red-600 mt-2")

        except Exception as e:
            logger.error(f"Error inspecting local product: {e}")
            preview_area.clear()
            with preview_area:
                ui.label(f"Error: {e}").classes("text-sm text-red-600")

    # wire buttons
    scan_btn.on_click(lambda: _scan_folder())
    # No browse button: users can edit the folder path directly.

    # Wire dropdown change to auto-inspect the selected product (replaces the bottom button)
    def _on_products_select_change(e: dict):
        sel = e.value
        if not sel:
            return
        if sel not in scanned_products:
            ui.notify("⚠️ Selected product not found", position="top", type="warning")
            return
        _inspect_local_product(scanned_products[sel])

    try:
        products_select.on_change(_on_products_select_change)
    except Exception:
        # fallback for older nicegui versions
        pass


def _scl_colormap():
    """Return a matplotlib ListedColormap and BoundaryNorm for SCL values.

    Colors provided as RGB tuples (0-255) are normalized to 0-1.
    """
    try:
        from matplotlib.colors import BoundaryNorm, ListedColormap

        scl_colors = [
            (0, 0, 0),
            (255, 0, 0),
            (47, 47, 47),
            (100, 50, 0),
            (0, 160, 0),
            (255, 230, 90),
            (0, 0, 255),
            (128, 128, 128),
            (192, 192, 192),
            (255, 255, 255),
            (100, 200, 255),
            (255, 150, 255),
        ]
        scl_colors_norm = [(r / 255.0, g / 255.0, b / 255.0) for (r, g, b) in scl_colors]
        cmap = ListedColormap(scl_colors_norm)
        bounds = list(range(len(scl_colors) + 1))
        norm = BoundaryNorm(bounds, cmap.N)
        return cmap, norm
    except Exception:
        return None, None


def _get_memory_info() -> int | None:
    """Return current process RSS memory in bytes if available, else None.

    Tries `psutil` first, then falls back to `resource.getrusage`.
    """
    try:
        import psutil

        p = psutil.Process()
        return int(p.memory_info().rss)
    except Exception:
        try:
            import resource

            return int(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss)
        except Exception:
            return None


def _format_bytes(n: int | None) -> str:
    if n is None:
        return "?"
    for unit in ["B", "KB", "MB", "GB", "TB"]:
        if n < 1024.0:
            return f"{n:.1f}{unit}"
        n /= 1024.0
    return f"{n:.1f}PB"


# Preview sizing helpers
PREVIEW_MAX_DIM = 1830  # target maximum preview dimension (1830x1830 for ~60m preview)


def _compute_preview_shape(orig_h: int, orig_w: int, max_dim: int = PREVIEW_MAX_DIM) -> tuple[int, int]:
    """Compute preview out_shape preserving aspect ratio so max dimension <= max_dim.

    Returns (out_h, out_w).
    """
    try:
        scale = max(orig_h / max_dim, orig_w / max_dim, 1.0)
        out_h = int(max(1, round(orig_h / scale)))
        out_w = int(max(1, round(orig_w / scale)))
        return out_h, out_w
    except Exception:
        return min(orig_h, max_dim), min(orig_w, max_dim)


def _resize_array_to_preview(arr, max_dim: int = PREVIEW_MAX_DIM):
    """Resize a numpy array (2D or 3D) to have max dimension <= max_dim using PIL.

    Returns a resized numpy array (uint8 for images, or same dtype for single band scaled to 0..1 then uint8 if needed).
    """
    try:
        import numpy as _np
        from PIL import Image

        if getattr(arr, "ndim", 0) == 2:
            mode = "L"
            img = Image.fromarray((_np.clip(arr, 0, 255)).astype("uint8"), mode=mode)
        else:
            # assume HxWxC with uint8 or float
            if arr.dtype != _np.uint8:
                # normalize floats to 0..255
                a = arr.copy()
                if a.max() <= 1.0:
                    a = (a * 255.0).astype("uint8")
                else:
                    a = _np.clip(a, 0, 255).astype("uint8")
                img = Image.fromarray(a)
            else:
                img = Image.fromarray(arr)

        w, h = img.size
        # compute new size preserving aspect
        scale = max(h / max_dim, w / max_dim, 1.0)
        new_w = int(max(1, round(w / scale)))
        new_h = int(max(1, round(h / scale)))
        img_rs = img.resize((new_w, new_h), resample=Image.BILINEAR)
        out = _np.array(img_rs)
        return out
    except Exception:
        return arr


def _scl_palette_and_labels():
    """Return SCL RGB palette (uint8) and labels list in class order 0..11."""
    labels = [
        "No Data (Missing data)",
        "Saturated or defective pixel",
        "Topographic casted shadows",
        "Cloud shadows",
        "Vegetation",
        "Not-vegetated",
        "Water",
        "Unclassified",
        "Cloud medium probability",
        "Cloud high probability",
        "Thin cirrus",
        "Snow or ice",
    ]
    colors = [
        (0, 0, 0),
        (255, 0, 0),
        (47, 47, 47),
        (100, 50, 0),
        (0, 160, 0),
        (255, 230, 90),
        (0, 0, 255),
        (128, 128, 128),
        (192, 192, 192),
        (255, 255, 255),
        (100, 200, 255),
        (255, 150, 255),
    ]
    import numpy as _np

    cmap = _np.array(colors, dtype=_np.uint8)
    return cmap, labels


def _scl_plotly_figure_from_array(scl_arr, max_width=900):
    """Create a Plotly Figure with the SCL array mapped to RGB colors."""
    try:
        import numpy as _np
        import plotly.graph_objects as go
        from PIL import Image as _PILImage

        cmap, _labels = _scl_palette_and_labels()
        idx = _np.clip(scl_arr.astype("int"), 0, len(cmap) - 1)
        rgb = cmap[idx]
        rows, cols = rgb.shape[0], rgb.shape[1]
        # Avoid sending enormous arrays to Plotly/browser — downsample if too large
        MAX_DIM = PREVIEW_MAX_DIM
        orig_shape = (rows, cols)
        info_msg = None
        if max(rows, cols) > MAX_DIM:
            scale = MAX_DIM / max(rows, cols)
            new_rows = max(1, int(rows * scale))
            new_cols = max(1, int(cols * scale))
            try:
                pil = _PILImage.fromarray(rgb)
                pil_rs = pil.resize((new_cols, new_rows), resample=_PILImage.NEAREST)
                rgb = _np.array(pil_rs)
                rows, cols = new_rows, new_cols
                logger.info("Downsampled SCL for Plotly from %sx%s to %sx%s", orig_shape[1], orig_shape[0], cols, rows)
                info_msg = f"SCL downsampled for preview: {orig_shape[1]}x{orig_shape[0]} → {cols}x{rows} (target {PREVIEW_MAX_DIM}px max)"
            except Exception:
                logger.exception("Failed to downsample SCL array for Plotly; proceeding with original size")

        # Flip vertically so (0,0) is at bottom-left (Image traces ignore autorange)
        import numpy as _np

        try:
            rgb = _np.flipud(rgb)
        except Exception:
            pass

        fig = go.Figure(go.Image(z=rgb))
        width = min(max_width, cols)
        # cap height similarly to avoid oversized layout
        height = min(900, rows)
        fig.update_layout(margin=dict(l=6, r=6, t=6, b=6), width=width, height=height)
        # Note: autorange doesn't affect Image traces; we flipped the data instead
        return fig, info_msg
    except Exception:
        return None, None


def _scl_legend_image(box_width: int = 40, box_height: int = 24, pad: int = 8, font_size: int = 12):
    """Create a vertical legend image (PNG) with color boxes next to labels and return temp file path.

    Falls back to None if Pillow is not available.
    """
    try:
        import tempfile

        from PIL import Image, ImageDraw, ImageFont

        cmap, labels = _scl_palette_and_labels()
        n = len(labels)

        # load default font
        try:
            font = ImageFont.load_default()
        except Exception:
            font = None

        # measure max text width
        dummy = Image.new("RGB", (10, 10))
        draw = ImageDraw.Draw(dummy)
        max_text_w = 0
        for lab in labels:
            w, h = draw.textsize(lab, font=font)
            if w > max_text_w:
                max_text_w = w

        img_w = box_width + pad + max_text_w + pad * 2
        img_h = n * (box_height + pad) + pad
        img = Image.new("RGB", (img_w, img_h), (255, 255, 255))
        draw = ImageDraw.Draw(img)

        y = pad
        for i, lab in enumerate(labels):
            c = tuple(int(x) for x in cmap[i])
            # draw rectangle color box
            draw.rectangle([pad, y, pad + box_width, y + box_height], fill=c)
            # draw text to the right of box
            tx = pad + box_width + pad
            ty = y + max(0, (box_height - font.getsize(lab)[1]) // 2 if font else 0)
            draw.text((tx, ty), f"{i}: {lab}", fill=(0, 0, 0), font=font)
            y += box_height + pad

        tmpf = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
        tmpf.close()
        img.save(tmpf.name, format="PNG")
        return tmpf.name
    except Exception:
        return None


def _scl_legend_html(container):
    """Render an HTML/CSS legend inside given NiceGUI container for SCL classes.

    `container` should be a NiceGUI element context (e.g., a `with ui.column():` block).
    """
    cmap, labels = _scl_palette_and_labels()
    # vertical list: color swatch (fixed size) + label
    for i, lab in enumerate(labels):
        r, g, b = int(cmap[i][0]), int(cmap[i][1]), int(cmap[i][2])
        with container:
            with ui.row().classes("items-center gap-3"):
                ui.html(f"<div style='width:28px;height:20px;background:rgb({r},{g},{b});border:1px solid #666;'></div>", sanitize=False)
                ui.label(f"{i}: {lab}").classes("text-sm")


def _scl_legend_html_inline():
    """Render an HTML/CSS legend inline (within current UI context) for SCL classes."""
    cmap, labels = _scl_palette_and_labels()
    # vertical list: color swatch (fixed size) + label
    for i, lab in enumerate(labels):
        r, g, b = int(cmap[i][0]), int(cmap[i][1]), int(cmap[i][2])
        with ui.row().classes("items-center gap-3"):
            ui.html(f"<div style='width:28px;height:20px;background:rgb({r},{g},{b});border:1px solid #666;'></div>", sanitize=False)
            ui.label(f"{i}: {lab}").classes("text-sm")


def _ensure_plotly_origin_bottom_left(fig):
    """Ensure a Plotly Figure uses bottom-left origin by reversing y-axis if possible."""
    try:
        # Try to reverse autorange for image-like plots so (0,0) appears at bottom-left.
        # Some Plotly versions respect `autorange='reversed'`; keep rangemode as well for safety.
        try:
            fig.update_yaxes(rangemode="tozero", autorange="reversed")
        except Exception:
            pass
        # For subplot figures, also attempt to set each yaxis in layout explicitly
        try:
            for k in list(fig.layout.keys()):
                if str(k).startswith("yaxis"):
                    try:
                        try:
                            setattr(fig.layout[k], "rangemode", "tozero")
                            setattr(fig.layout[k], "autorange", "reversed")
                        except Exception:
                            pass
                    except Exception:
                        pass
        except Exception:
            pass
    except Exception:
        pass
    return fig


def _create_name_search_sidebar():
    """Create the left sidebar for name-based search with filters."""
    with ui.column().classes("w-80"):
        # Product name search card
        with ui.card().classes("w-full"):
            ui.label("Search by Product Name").classes("text-lg font-semibold mb-3")

            # Product name input
            name_input = ui.input(
                label="Product Name",
                placeholder="e.g., S2A_MSIL2A_20201212T235129_...",
            ).classes("w-full mb-3")
            name_input.tooltip("Enter the full product name — everything needed is in the name")

            # Search button placed with the filters (single input only)
            search_button = ui.button("🔍 Search by Name").classes("w-full")
            search_button.props("color=primary")

            # Loading indicator label
            loading_label = ui.label("").classes("text-sm text-blue-600 mt-2 font-medium")

            # Max results for name search (user-configurable)
            max_results_input = ui.number(label="Max Results", value=100, min=1, max=500, step=10).classes("w-full mt-2 mb-1")
            max_results_input.tooltip("Maximum number of results returned by the server for name searches")

        # Activity log card
        with ui.card().classes("w-full flex-1 mt-4"):
            ui.label("Activity Log").classes("text-lg font-semibold mb-3")
            with ui.scroll_area().classes("w-full h-96"):
                messages_column_name = ui.column().classes("w-full gap-2")

    return {
        "name_input": name_input,
        "search_button": search_button,
        "loading_label": loading_label,
        "max_results_input": max_results_input,
        "messages_column": messages_column_name,
    }


def _create_name_search_results_panel(filters):
    """Create the results panel for name-based search."""
    with ui.column().classes("flex-1"):
        # Note: the search button is provided by the filters sidebar; here we only show results
        search_button = filters.get("search_button")
        loading_label = filters.get("loading_label")

        # Results display
        with ui.card().classes("w-full flex-1 mt-4"):
            ui.label("Results").classes("text-lg font-semibold mb-3")
            with ui.scroll_area().classes("w-full h-96"):
                results_display = ui.column().classes("w-full gap-2")

        # Set up button click handler after results_display is defined
        async def perform_name_search_wrapper():
            await _perform_name_search(
                filters["messages_column"],
                results_display,
                search_button,
                loading_label,
                filters["name_input"],
                filters.get("max_results_input"),
            )

        # Wire up the search button provided by the sidebar filters
        if search_button is not None:
            search_button.on_click(perform_name_search_wrapper)

    return results_display


def _create_download_tab():
    """Create the download tab UI which accepts a product name, fetches available bands,
    allows selecting bands and resolution, and triggers downloads via ProductsManager.
    """
    from pathlib import Path

    from vresto.products import ProductsManager

    with ui.column().classes("w-full gap-4"):
        with ui.row().classes("w-full gap-6"):
            # Left column: product input and controls
            with ui.column().classes("w-80"):
                with ui.card().classes("w-full"):
                    ui.label("Download Product").classes("text-lg font-semibold mb-3")

                    product_input = ui.input(label="Product name or S3 path", placeholder="S2A_MSIL2A_... or s3://.../PRODUCT.SAFE").classes("w-full mb-3")

                    fetch_button = ui.button("📥 Fetch bands").classes("w-full mb-2")
                    fetch_button.props("color=primary")

                    # Resolution selector is above the bands so user chooses resolution first
                    ui.label("Band resolution to download:").classes("text-sm text-gray-600 mb-2")
                    # Resolution selector: show friendly labels, map to internal values at download time
                    RES_NATIVE_LABEL = "Native (best available per band)"
                    # Keep all download options (users may want to download 10/20/60), but warn that in-browser preview will only show 60m/native-downsampled images.
                    resolution_select = ui.select(options=["60", "20", "10", RES_NATIVE_LABEL], value="60").classes("w-full mb-3")

                    ui.label("Native selects each band's best available (smallest) native resolution. Note: in-browser previews only support 60m or native-downsampled images; 10m and 20m can't be rendered reliably in the browser.").classes(
                        "text-xs text-gray-600 mb-2"
                    )

                    ui.label("Available bands").classes("text-sm text-gray-600 mb-2")
                    # Selection helpers
                    with ui.row().classes("w-full gap-2 mb-2"):
                        select_all_btn = ui.button("Select All").classes("text-sm")
                        deselect_all_btn = ui.button("Deselect All").classes("text-sm")
                        select_10m_btn = ui.button("Select all 10m bands").classes("text-sm")
                        select_20m_btn = ui.button("Select all 20m bands").classes("text-sm")
                        select_60m_btn = ui.button("Select all 60m bands").classes("text-sm")

                    bands_container = ui.column().classes("w-full gap-1")
                    created_checkboxes: list = []

                    def _select_all(val: bool):
                        for c in created_checkboxes:
                            try:
                                c.set_value(val)
                            except Exception:
                                try:
                                    c.value = val
                                except Exception:
                                    pass

                    def _select_by_res(res_target: int, val: bool = True):
                        for c in created_checkboxes:
                            try:
                                res_list = getattr(c, "resolutions", [])
                                if res_target in res_list:
                                    c.set_value(val)
                            except Exception:
                                try:
                                    if res_target in getattr(c, "resolutions", []):
                                        c.value = val
                                except Exception:
                                    pass

                    select_all_btn.on_click(lambda: _select_all(True))
                    deselect_all_btn.on_click(lambda: _select_all(False))
                    select_10m_btn.on_click(lambda: _select_by_res(10, True))
                    select_20m_btn.on_click(lambda: _select_by_res(20, True))
                    select_60m_btn.on_click(lambda: _select_by_res(60, True))

                    dest_input = ui.input(label="Destination directory", value=str(Path.home() / "vresto_downloads")).classes("w-full mt-3 mb-3")

                    download_button = ui.button("⬇️ Download selected").classes("w-full")
                    download_button.props("color=primary")
                    # Progress UI for downloads: circular progress with a small textual counter
                    # hide the built-in numeric value; we'll show a formatted percentage below
                    progress = ui.circular_progress(value=0.0, max=1.0, size="lg", show_value=False).classes("m-auto mt-2")
                    progress_label = ui.label("").classes("text-sm text-gray-600 mt-1")

            # Right column: activity log and results
            with ui.column().classes("flex-1"):
                with ui.card().classes("w-full flex-1"):
                    ui.label("Activity Log").classes("text-lg font-semibold mb-3")
                    with ui.scroll_area().classes("w-full h-96"):
                        activity_column = ui.column().classes("w-full gap-2")

    # Handlers
    def add_activity(msg: str):
        with activity_column:
            ui.label(msg).classes("text-sm text-gray-700 break-words")

    async def handle_fetch():
        product = product_input.value.strip() if product_input.value else ""
        if not product:
            ui.notify("⚠️ Enter a product name or S3 path first", position="top", type="warning")
            add_activity("⚠️ Fetch failed: no product provided")
            return

        add_activity(f"🔎 Resolving bands for: {product}")
        try:
            mgr = ProductsManager()
            # ProductsManager uses ProductDownloader internally; get the mapper via ProductDownloader
            # We'll ask ProductDownloader.list_available_bands via ProductsManager by constructing s3 path
            s3_path = mgr._construct_s3_path_from_name(product)

            # Use ProductDownloader directly to list bands
            from vresto.products.downloader import ProductDownloader

            pd = ProductDownloader(s3_client=mgr.s3_client)
            bands_map = pd.list_available_bands(s3_path)

            bands_container.clear()
            created_checkboxes.clear()
            if not bands_map:
                add_activity("ℹ️ No band files found for this product (or product not found)")
                ui.notify("No bands found", position="top", type="warning")
                return

            for band, res_set in sorted(bands_map.items()):
                # show band with its available resolutions inside the bands_container
                with bands_container:
                    cb = ui.checkbox(f"{band} (available: {sorted(res_set)})")
                cb.band_name = band
                cb.resolutions = sorted(res_set)
                created_checkboxes.append(cb)

            add_activity(f"✅ Found bands: {', '.join(sorted(bands_map.keys()))}")
            ui.notify("Bands fetched", position="top", type="positive")

        except Exception as e:
            add_activity(f"❌ Error fetching bands: {e}")
            ui.notify(f"Error: {e}", position="top", type="negative")

    async def handle_download():
        product = product_input.value.strip() if product_input.value else ""
        if not product:
            ui.notify("⚠️ Enter a product name or S3 path first", position="top", type="warning")
            add_activity("⚠️ Download failed: no product provided")
            return

        # Read selected checkboxes from the created_checkboxes list
        selected_bands = [getattr(c, "band_name", None) for c in created_checkboxes if getattr(c, "value", False)]
        if not selected_bands:
            ui.notify("⚠️ Select at least one band to download", position="top", type="warning")
            add_activity("⚠️ Download failed: no bands selected")
            return

        # Map display from select to internal resolution: 'native' or int
        raw_res = resolution_select.value
        if raw_res == "Native (best available per band)":
            resolution = "native"
        else:
            try:
                resolution = int(raw_res)
            except Exception:
                resolution = "native"
        # resample option removed; downloads are fetched at requested resolution if available
        dest_dir = dest_input.value or str(Path.home() / "vresto_downloads")

        add_activity(f"⬇️ Starting download for {product}: bands={selected_bands}, resolution={resolution}")

        try:
            mgr = ProductsManager()
            from vresto.products.downloader import ProductDownloader, _parse_s3_uri

            pd = ProductDownloader(s3_client=mgr.s3_client)

            # Resolve product S3 prefix and build keys for requested bands at chosen resolution
            s3_path = mgr._construct_s3_path_from_name(product)
            try:
                keys = pd.build_keys_for_bands(s3_path, selected_bands, resolution)
            except Exception as e:
                add_activity(f"❌ Could not build keys for bands/resolution: {e}")
                ui.notify(f"Failed: {e}", position="top", type="negative")
                return

            total = len(keys)
            # initialize progress
            try:
                progress.set_value(0.0)
            except Exception:
                progress.value = 0.0
            progress_label.text = f"0.0% (0 / {total})"
            add_activity(f"⬇️ Downloading {total} files to {dest_dir}")

            import asyncio

            downloaded = []
            for i, s3uri in enumerate(keys, start=1):
                try:
                    bucket, key = _parse_s3_uri(s3uri)
                    # preserve s3 structure locally
                    dest = Path(dest_dir) / key
                    path = await asyncio.to_thread(pd._download_one, s3uri, dest, False)
                    downloaded.append(path)
                    # update circular progress (value between 0 and 1)
                    frac = float(i) / float(total) if total else 1.0
                    try:
                        progress.set_value(frac)
                    except Exception:
                        progress.value = frac
                    progress_label.text = f"{frac * 100:.1f}% ({i} / {total})"
                    add_activity(f"✅ Downloaded {Path(path).name}")
                except Exception as ex:
                    add_activity(f"❌ Failed to download {s3uri}: {ex}")
                    # continue downloading remaining files

            add_activity(f"✅ Download completed: {len(downloaded)} of {total} files")
            ui.notify(f"Download finished: {len(downloaded)} files", position="top", type="positive")
        except Exception as e:
            add_activity(f"❌ Download error: {e}")
            ui.notify(f"Download failed: {e}", position="top", type="negative")

    # NiceGUI accepts async handlers directly
    fetch_button.on_click(handle_fetch)
    download_button.on_click(handle_download)


async def _perform_name_search(
    messages_column,
    results_display,
    search_button,
    loading_label,
    name_input,
    max_results_input=None,
):
    """Perform product name search.

    Args:
        messages_column: UI column for activity log messages
        results_display: UI column for displaying results
        search_button: Search button element for disabling during search
        loading_label: Loading label element for showing search status
        name_input: Input field with product name pattern
        collection_select: Selected satellite collection
        product_level_select: Selected product level
        date_picker: Date range picker
        cloud_cover_input: Max cloud cover value
        max_results_input: Max results value
    """

    def add_message(text: str):
        """Add a message to the activity log."""
        with messages_column:
            ui.label(text).classes("text-sm text-gray-700 break-words")

    def filter_products_by_level(products: list, level_filter: str) -> list:
        """Filter products by processing level."""
        if level_filter == "L1C + L2A":
            return products

        filtered = []
        for product in products:
            if level_filter in product.name:
                filtered.append(product)

        return filtered

    # Validate that we have a product name
    if not name_input.value or not name_input.value.strip():
        ui.notify("⚠️ Please enter a product name or pattern", position="top", type="warning")
        add_message("⚠️ Search failed: No product name entered")
        return

    # Respect max_results input if provided
    try:
        if max_results_input is not None:
            max_results = int(max_results_input.value)
        else:
            max_results = 100
    except Exception:
        max_results = 100

    # Heuristic: detect overly-generic patterns and warn / cap results
    name_trim = name_input.value.strip()
    generic = False
    try:
        # too short, common prefix like 'S2B_' or ends with just 'S2B_MSIL2A_' etc
        if len(name_trim) < 6:
            generic = True
        # starts with a short collection prefix only
        if name_trim.upper().endswith("_") and name_trim.upper().startswith(("S2A_", "S2B_", "S1A_", "S1B_")):
            generic = True
        # pattern is a short token (e.g., "S2B")
        if name_trim.upper() in ("S2A", "S2B", "S2", "S1", "S1A", "S1B"):
            generic = True
    except Exception:
        generic = False

    if generic:
        # warn the user and reduce max_results to a sensible default if they didn't set a custom higher value
        ui.notify("⚠️ This looks like a very generic product pattern — results may be large.", position="top", type="warning")
        add_message("⚠️ Detected generic name search; limiting results to avoid UI timeout")
        # enforce a safe upper limit of 200 for generic queries
        max_results = min(max_results, 200)

    # Parsed acquisition date (filled from product name when possible)
    parsed_acq_date = None

    # Only the product name is provided by the UI. Parse it for helpful filters.
    name_pattern = name_input.value.strip()
    # Default heuristics
    collection = None
    product_level = None
    max_results = 100

    # Try to parse product name fields using ProductName helper
    try:
        from vresto.products.product_name import ProductName

        pn = ProductName(name_pattern)
        product_level = pn.product_level
        # Guess collection from product type
        if pn.product_type == "S2":
            collection = "SENTINEL-2"
        elif pn.product_type == "S1":
            collection = "SENTINEL-1"
        elif pn.product_type == "S5P":
            collection = "SENTINEL-5P"

        if pn.acquisition_datetime and len(pn.acquisition_datetime) >= 8:
            parsed_acq_date = pn.acquisition_datetime[:8]
    except Exception:
        pn = None

    # Show loading message and disable button
    ui.notify(f"🔍 Searching products for '{name_pattern}'...", position="top", type="info")
    add_message(f"🔍 Searching products for name: '{name_pattern}' (parsed collection={collection}, level={product_level})")

    # Disable search button and show loading state
    search_button.enabled = False
    original_text = search_button.text
    search_button.text = "⏳ Searching..."
    loading_label.text = "⏳ Searching..."

    # Clear previous results
    results_display.clear()
    with results_display:
        ui.spinner(size="lg")
        ui.label("Searching...").classes("text-gray-600")

    # Allow UI to render before starting the blocking search
    import asyncio

    await asyncio.sleep(0.1)

    try:
        # Perform name-based search using catalog API (server-side name filters)
        catalog = CatalogSearch()

        # Normalize pattern: remove wildcard characters, server side handles contains/eq
        raw_pattern = name_pattern.strip()
        pattern = raw_pattern.replace("*", "")

        # Heuristic: use exact match when the provided string looks like a full product name
        looks_exact = False
        try:
            if len(pattern) > 30 and ("MSIL" in pattern or "_MSI" in pattern) and "T" in pattern:
                looks_exact = True
        except Exception:
            looks_exact = False

        match_type = "eq" if looks_exact else "contains"

        # If the input looks like an exact product name, parsing above already attempted to extract date

        products = []
        try:
            if match_type == "eq":
                products = catalog.search_products_by_name(pattern, match_type="eq", max_results=max_results)
                if not products:
                    logger.info("Exact name search returned 0 results; trying exact with '.SAFE' suffix")
                    try:
                        products = catalog.search_products_by_name(f"{pattern}.SAFE", match_type="eq", max_results=max_results)
                    except Exception:
                        logger.exception("Exact '.SAFE' name search failed")

                if not products:
                    logger.info("Exact and '.SAFE' search returned 0 results; falling back to contains")
                    try:
                        products = catalog.search_products_by_name(pattern, match_type="contains", max_results=max(max_results, 100))
                    except Exception:
                        logger.exception("Fallback contains name search failed")
            else:
                products = catalog.search_products_by_name(pattern, match_type=match_type, max_results=max_results)
        except Exception:
            logger.exception("Name-based search failed; falling back to empty result list")

        # If we parsed an acquisition date, use it as a single-day start/end filter
        start_date = ""
        end_date = ""
        if parsed_acq_date:
            try:
                sd = f"{parsed_acq_date[0:4]}-{parsed_acq_date[4:6]}-{parsed_acq_date[6:8]}"
                start_date = sd
                end_date = sd
                add_message(f"ℹ️ Using date from product name: {sd}")
            except Exception:
                start_date = ""
                end_date = ""

        logger.info(f"Name search (server) returned {len(products)} products for pattern '{pattern}' (match_type tried={match_type})")

        # If server returned a lot of results, warn the user and truncate displayed list
        SERVER_TOO_MANY = 500
        if len(products) > SERVER_TOO_MANY:
            ui.notify(f"⚠️ Server returned {len(products)} products — this may be slow. Showing first {max_results}.", position="top", type="warning")
            add_message(f"⚠️ Server returned {len(products)} products; truncated to first {max_results} for UI responsiveness")
            products = products[:max_results]

        # Apply client-side filters not supported by name API: date range and product level
        filtered_products: list = []
        filtered_out_examples: list[tuple[str, str]] = []  # (product_name, reason)
        for p in products:
            try:
                reason = None

                # Date filter
                if start_date:
                    try:
                        sensed = p.sensing_date
                        if sensed:
                            # p.sensing_date is formatted like 'YYYY-MM-DD HH:MM:SS'
                            dt = datetime.strptime(sensed, "%Y-%m-%d %H:%M:%S")
                            dt_date = dt.date()
                            sd = datetime.fromisoformat(start_date).date()
                            ed = datetime.fromisoformat(end_date).date() if end_date else sd
                            if not (sd <= dt_date <= ed):
                                reason = f"date {dt_date} outside {sd}–{ed}"
                    except Exception:
                        # If parsing fails, do not filter by date
                        pass

                # Product level filter (if parsed or apparent)
                if reason is None and product_level and product_level != "L1C + L2A":
                    try:
                        if product_level not in p.name:
                            reason = f"level not {product_level}"
                    except Exception:
                        pass

                if reason is None:
                    filtered_products.append(p)
                else:
                    if len(filtered_out_examples) < 5:
                        filtered_out_examples.append((p.name, reason))
            except Exception:
                logger.exception("Error while applying client-side filters; skipping product")

        # Display results
        results_display.clear()
        current_state["products"] = filtered_products

        # Inform user about server-return and client-side filtering
        with results_display:
            ui.label(f"Server returned {len(products)} products; {len(filtered_products)} match after client-side filters").classes("text-sm text-gray-600 mb-2")
            if filtered_out_examples:
                ui.label("Examples of filtered-out products:").classes("text-xs text-gray-500 mt-1")
                for name, reason in filtered_out_examples:
                    # name may be raw product name; strip .SAFE for display
                    disp = name[:-5] if isinstance(name, str) and name.upper().endswith(".SAFE") else name
                    ui.label(f"- {disp} ({reason})").classes("text-xs font-mono text-gray-500 break-all")

        if not filtered_products:
            with results_display:
                ui.label("No products found matching the criteria").classes("text-gray-500 italic mt-2")
            ui.notify("No products found", position="top", type="warning")
            add_message("❌ No products found matching the search criteria")
        else:
            with results_display:
                ui.label(f"Found {len(filtered_products)} products").classes("text-sm font-semibold text-green-600 mb-2")

                import asyncio

                for i, product in enumerate(filtered_products, 1):
                    with ui.card().classes("w-full p-2 bg-gray-50"):
                        ui.label(f"{i}. {getattr(product, 'display_name', product.name)}").classes("text-xs font-mono break-all")
                        ui.label(f"📅 {product.sensing_date}").classes("text-xs text-gray-600")
                        ui.label(f"💾 {product.size_mb:.1f} MB").classes("text-xs text-gray-600")
                        if product.cloud_cover is not None:
                            ui.label(f"☁️ {product.cloud_cover:.1f}%").classes("text-xs text-gray-600")

                        # Buttons for quicklook and metadata
                        with ui.row().classes("w-full gap-2 mt-2"):
                            ui.button(
                                "🖼️ Quicklook",
                                on_click=lambda p=product: _show_product_quicklook(p, messages_column),
                            ).classes("text-xs flex-1")
                            ui.button(
                                "📋 Metadata",
                                on_click=lambda p=product: _show_product_metadata(p, messages_column),
                            ).classes("text-xs flex-1")

                    # Yield to the event loop periodically to avoid blocking the NiceGUI websocket
                    if i % 20 == 0:
                        await asyncio.sleep(0)

            ui.notify(f"✅ Found {len(filtered_products)} products", position="top", type="positive")
            add_message(f"✅ Found {len(filtered_products)} products matching '{name_pattern}'")
            logger.info(f"Name search completed: {len(filtered_products)} products found")

            # Re-enable search button and clear loading label
            search_button.enabled = True
            search_button.text = original_text
            loading_label.text = ""

    except Exception as e:
        logger.error(f"Name search failed: {e}")
        results_display.clear()
        with results_display:
            ui.label(f"Error: {str(e)}").classes("text-red-600 text-sm")
        ui.notify(f"❌ Search failed: {str(e)}", position="top", type="negative")
        add_message(f"❌ Search error: {str(e)}")

        # Re-enable search button and clear loading label
        search_button.enabled = True
        search_button.text = original_text
        loading_label.text = ""
    finally:
        # Ensure the search button and loading label are always reset
        try:
            search_button.enabled = True
            search_button.text = original_text
            loading_label.text = ""
        except Exception:
            pass


if __name__ in {"__main__", "__mp_main__"}:
    with ui.column().classes("w-full h-screen p-6"):
        create_map_interface()

    ui.run()
