# Copyright 2026, Jiri Eischmann <jiri@eischmann.cz> and the meshy contributors
# SPDX-License-Identifier: GPL-3.0-or-later

"""Shared view utilities."""

import gettext
import json
import logging
import threading
import types
import weakref
from datetime import datetime, timedelta
from urllib.request import Request, urlopen


def _(msg):
    return gettext.dgettext("meshy", msg)


import gi

gi.require_version("Adw", "1")
gi.require_version("Gtk", "4.0")
gi.require_version("Shumate", "1.0")
from gi.repository import Adw, Gio, GLib, GObject, Gtk, Shumate

_OFM_BASE = "https://tiles.openfreemap.org"
_OFM_STYLE_URLS = {
    "bright": f"{_OFM_BASE}/styles/bright",
    "dark": f"{_OFM_BASE}/styles/dark",
}

log = logging.getLogger(__name__)

_source_cache = {}
_source_failed = set()
_map_widgets = []
_theme_listener_connected = False
_custom_tile_url = None
_custom_tile_type = None

_UA = "Meshy/1.0 (https://codeberg.org/sesivany/meshy)"
_MAX_ZOOM = 14
_RASTER_MAX_ZOOM = 19


def _format_date_text(dt):
    """Format a datetime as a human-friendly date string for dividers."""
    today = datetime.now().date()
    d = dt.date() if isinstance(dt, datetime) else dt
    if d == today:
        return _("Today")
    if d == today - timedelta(days=1):
        return _("Yesterday")
    gdt = GLib.DateTime.new_local(d.year, d.month, d.day, 0, 0, 0)
    if d.year == today.year:
        # Translators: date format for current year, strftime-style.
        # %A = weekday, %B = month name, %-d = day number.
        return gdt.format(_("%A, %B %-d"))
    # Translators: date format with year, strftime-style.
    # %A = weekday, %B = month name, %-d = day number, %Y = year.
    return gdt.format(_("%A, %B %-d, %Y"))


_osm_source = None


def _get_osm_source():
    global _osm_source
    if _osm_source is None:
        registry = Shumate.MapSourceRegistry.new_with_defaults()
        _osm_source = registry.get_by_id(Shumate.MAP_SOURCE_OSM_MAPNIK)
    return _osm_source


def _make_raster_source(url_template):
    return Shumate.RasterRenderer.new_full_from_url(
        "custom-tiles",
        "Custom Tiles",
        "",
        "",
        0,
        19,
        256,
        Shumate.MapProjection.MERCATOR,
        url_template,
    )


def _fix_interpolations(obj):
    """Fix ["linear", N] -> ["linear"] for Shumate compatibility."""
    if isinstance(obj, list):
        if (
            len(obj) >= 2
            and obj[0] == "interpolate"
            and isinstance(obj[1], list)
            and obj[1][0] == "linear"
            and len(obj[1]) > 1
        ):
            obj[1] = ["linear"]
        for item in obj:
            _fix_interpolations(item)
    elif isinstance(obj, dict):
        for v in obj.values():
            _fix_interpolations(v)


def _patch_style_json(style_text):
    """Patch a vector style JSON for Shumate compatibility.

    Shumate supports only one data source. When the style has multiple vector
    sources, we keep the one referenced by the most layers (the "main" source)
    and drop layers that reference other sources.
    """
    from collections import Counter

    style = json.loads(style_text)
    sources = style.get("sources", {})
    vector_sources = {k: s for k, s in sources.items() if s.get("type") == "vector"}
    if not vector_sources:
        return None
    layer_counts = Counter(
        ly.get("source") for ly in style.get("layers", []) if ly.get("source") in vector_sources
    )
    vector_key = layer_counts.most_common(1)[0][0] if layer_counts else next(iter(vector_sources))
    vector_src = vector_sources[vector_key]
    if "url" in vector_src and "tiles" not in vector_src:
        tilejson_text = (
            urlopen(Request(vector_src["url"], headers={"User-Agent": _UA}), timeout=10)
            .read()
            .decode("utf-8")
        )
        vector_src["tiles"] = json.loads(tilejson_text).get("tiles", [])
    vector_src.pop("url", None)
    style["sources"] = {vector_key: vector_src}
    _UNSUPPORTED_LAYER_TYPES = {"fill-extrusion", "heatmap", "hillshade", "raster"}
    style["layers"] = [
        ly
        for ly in style.get("layers", [])
        if (ly.get("source") in (vector_key, None) or "source" not in ly)
        and ly.get("type") not in _UNSUPPORTED_LAYER_TYPES
    ]
    style.pop("sprite", None)
    style.pop("glyphs", None)
    for layer in style["layers"]:
        _fix_interpolations(layer)
        _fix_offset_expressions(layer)
    return json.dumps(style)


def _is_simple_offset(val):
    return isinstance(val, list) and len(val) == 2 and all(isinstance(v, int | float) for v in val)


def _fix_offset_expressions(layer):
    """Replace expression-based offsets with [0, 0] — Shumate expects plain arrays."""
    for section_key in ("layout", "paint"):
        section = layer.get(section_key)
        if not section:
            continue
        for prop in ("text-offset", "icon-offset"):
            if prop in section and not _is_simple_offset(section[prop]):
                section[prop] = [0, 0]


def _fetch_vector_source(
    style_url, renderer_id, callback, cache_key=None, license_text=None, license_uri=None
):
    """Fetch a vector style JSON and create a VectorRenderer asynchronously."""
    log.warning(
        "fetch_vector_source: url=%s renderer_id=%s cache_key=%s", style_url, renderer_id, cache_key
    )
    if cache_key and cache_key in _source_cache:
        log.warning("fetch_vector_source: cache hit for %s", cache_key)
        callback(_source_cache[cache_key])
        return
    if cache_key and cache_key in _source_failed:
        log.warning("fetch_vector_source: previously failed for %s", cache_key)
        callback(None)
        return

    def _worker():
        try:
            log.warning("fetch_vector_source: fetching style from %s", style_url)
            style_text = (
                urlopen(Request(style_url, headers={"User-Agent": _UA}), timeout=15)
                .read()
                .decode("utf-8")
            )
            log.warning("fetch_vector_source: fetched %d bytes, patching...", len(style_text))
            patched = _patch_style_json(style_text)
            if not patched:
                log.warning("No vector source in style from %s", style_url)
                if cache_key:
                    _source_failed.add(cache_key)
                GLib.idle_add(lambda: callback(None) or False)
                return

            log.warning(
                "fetch_vector_source: patched to %d bytes, creating renderer...", len(patched)
            )

            def _finish():
                try:
                    renderer = Shumate.VectorRenderer.new(renderer_id, patched)
                    log.warning("fetch_vector_source: VectorRenderer created: %s", renderer)
                    if license_text:
                        renderer.set_license(license_text)
                    if license_uri:
                        renderer.set_license_uri(license_uri)
                    if cache_key:
                        _source_cache[cache_key] = renderer
                    callback(renderer)
                except Exception as e:
                    log.warning("Failed to create VectorRenderer %s: %s", renderer_id, e)
                    if cache_key:
                        _source_failed.add(cache_key)
                    callback(None)
                return False

            GLib.idle_add(_finish)
        except Exception as e:
            log.warning("Failed to fetch vector style from %s: %s", style_url, e)
            if cache_key:
                _source_failed.add(cache_key)
            GLib.idle_add(lambda: callback(None) or False)

    threading.Thread(target=_worker, daemon=True).start()


def _fetch_ofm_source(style, callback):
    """Fetch an OFM vector source (bright/dark) with caching."""
    _fetch_vector_source(
        _OFM_STYLE_URLS[style],
        f"ofm-{style}",
        callback,
        cache_key=style,
        license_text="© OpenFreeMap © OpenStreetMap (ODbL)",
        license_uri="https://www.openstreetmap.org/copyright",
    )


def _collect_live_widgets():
    """Prune dead weak refs and return live map widgets."""
    live = [(ref, ref()) for ref in _map_widgets]
    _map_widgets[:] = [ref for ref, w in live if w is not None]
    return [w for _, w in live if w is not None]


def _apply_map_source(map_widget, source):
    if source:
        map_widget.set_map_source(source)
        max_zoom = _RASTER_MAX_ZOOM if _custom_tile_type == "raster" else _MAX_ZOOM
        map_widget.get_viewport().set_max_zoom_level(max_zoom)


def _is_dark():
    return Adw.StyleManager.get_default().get_dark()


def _apply_default_source(widgets):
    """Apply the theme-appropriate OFM source (or OSM fallback) to widgets."""
    style = "dark" if _is_dark() else "bright"

    def _apply(source):
        s = source if source else _get_osm_source()
        for w in widgets:
            _apply_map_source(w, s)

    _fetch_ofm_source(style, _apply)


def _on_theme_changed(*_args):
    if _custom_tile_url:
        return
    widgets = _collect_live_widgets()
    if widgets:
        _apply_default_source(widgets)


def create_shumate_map(zoom_level=14, lat=0.0, lon=0.0):
    """Create a Shumate SimpleMap with theme-aware tiles.

    Raises:
        Exception: If map creation fails (e.g., on macOS with GdkMacos backend issues)
    """
    global _theme_listener_connected, _custom_tile_url, _custom_tile_type

    import logging

    log = logging.getLogger(__name__)

    try:
        log.debug(f"Creating Shumate map: zoom={zoom_level}, lat={lat}, lon={lon}")

        from gi.repository import Gio

        settings = Gio.Settings.new("page.codeberg.sesivany.Meshy")
        _custom_tile_url = settings.get_string("custom-tile-url") or None
        _custom_tile_type = settings.get_string("custom-tile-type") or None

        map_widget = Shumate.SimpleMap()
        map_widget.set_vexpand(True)

        viewport = map_widget.get_viewport()
        viewport.set_zoom_level(zoom_level)
        viewport.set_location(lat, lon)

        _collect_live_widgets()
        _map_widgets.append(weakref.ref(map_widget))

        # Apply tile source BEFORE adding marker layer so markers render on top
        if _custom_tile_url and _custom_tile_type == "raster":
            _apply_map_source(map_widget, _make_raster_source(_custom_tile_url))
        else:
            osm_source = _get_osm_source()
            if osm_source:
                _apply_map_source(map_widget, osm_source)

        marker_layer = Shumate.MarkerLayer.new(viewport)
        map_widget.get_map().add_layer(marker_layer)

        def _apply_async(source):
            if source:
                _apply_map_source(map_widget, source)

        if _custom_tile_url and _custom_tile_type == "vector":
            _fetch_vector_source(_custom_tile_url, "custom-vector", _apply_async)
        elif not _custom_tile_url:
            _apply_default_source([map_widget])

        if not _theme_listener_connected:
            Adw.StyleManager.get_default().connect("notify::dark", _on_theme_changed)
            _theme_listener_connected = True

        log.debug("Shumate map created successfully")
        return map_widget, marker_layer, viewport

    except Exception as e:
        log.error(f"Failed to create Shumate map widget: {e}", exc_info=True)
        # Re-raise so callers can handle gracefully
        raise


def apply_custom_source(url, tile_type, on_result=None):
    """Switch all tracked map widgets to a custom source, or back to default.

    on_result(ok: bool) is called on the main thread when done (vector only).
    """
    global _custom_tile_url, _custom_tile_type
    _custom_tile_url = url or None
    _custom_tile_type = tile_type or None

    widgets = _collect_live_widgets()
    log.warning("apply_custom_source: url=%s type=%s widgets=%d", url, tile_type, len(widgets))
    if not widgets:
        log.warning("apply_custom_source: no live widgets — source saved for next map open")
        if on_result:
            on_result(True)
        return

    if _custom_tile_url and _custom_tile_type == "raster":
        for w in widgets:
            _apply_map_source(w, _make_raster_source(_custom_tile_url))
        if on_result:
            on_result(True)
    elif _custom_tile_url and _custom_tile_type == "vector":

        def _apply(source):
            log.warning("apply_custom_source callback: source=%s widgets=%d", source, len(widgets))
            if source:
                for w in widgets:
                    _apply_map_source(w, source)
            if on_result:
                on_result(source is not None)

        _fetch_vector_source(_custom_tile_url, "custom-vector", _apply)
    else:
        _apply_default_source(widgets)
        if on_result:
            on_result(True)


def deregister_map_widget(map_widget):
    """Remove a map widget from the theme-tracking list."""
    _map_widgets[:] = [ref for ref in _map_widgets if ref() is not None and ref() is not map_widget]


def open_map_picker(window, title, button_label, init_lat, init_lon, on_pick):
    """Open a map dialog with crosshair, call on_pick(lat, lon) on confirm."""
    builder = Gtk.Builder.new_from_resource("/page/codeberg/sesivany/Meshy/ui/map-picker-dialog.ui")
    dialog = builder.get_object("dialog")
    header = builder.get_object("header")
    overlay = builder.get_object("overlay")
    crosshair = builder.get_object("crosshair")

    win_w = window.get_width() or 800
    win_h = window.get_height() or 600
    dialog.set_title(title)
    dialog.set_content_width(max(500, win_w - 60))
    dialog.set_content_height(max(400, win_h - 60))

    confirm_btn = Gtk.Button(label=button_label)
    confirm_btn.add_css_class("suggested-action")
    header.pack_end(confirm_btn)

    init_zoom = 14 if (abs(init_lat) > 1e-6 or abs(init_lon) > 1e-6) else 2
    map_widget, _ml, viewport = create_shumate_map(zoom_level=init_zoom, lat=init_lat, lon=init_lon)
    map_widget.set_hexpand(True)

    crosshair.set_markup('<span size="24000" color="#e05050">✚</span>')

    overlay.set_child(map_widget)

    def _on_confirm(*_args):
        on_pick(viewport.get_latitude(), viewport.get_longitude())
        dialog.close()

    confirm_btn.connect("clicked", _on_confirm)
    dialog.connect("closed", lambda _d: deregister_map_widget(map_widget))
    dialog.present(window)


def _add_hover_swap(row, button, badge, get_count, on_long_press):
    """Add hover show/hide behavior: show button and hide badge on hover,
    reverse on leave. Also add long-press handler for touchscreens."""
    motion = Gtk.EventControllerMotion()
    motion.connect("enter", lambda *_: (button.set_visible(True), badge.set_visible(False)))
    motion.connect(
        "leave", lambda *_: (button.set_visible(False), badge.set_visible(get_count() > 0))
    )
    row.add_controller(motion)
    long_press = Gtk.GestureLongPress()
    long_press.connect("pressed", lambda g, x, y: on_long_press())
    row.add_controller(long_press)


_CONTACT_TYPE_ICONS = {
    1: "avatar-default-symbolic",
    2: "repeater-symbolic",
    3: "user-home-symbolic",
    4: "weather-clear-symbolic",
}


class _ContactPickItem(GObject.Object):
    def __init__(self, contact):
        super().__init__()
        self.contact = contact


def _contact_pick_factory_setup(factory, list_item, button_label):
    row = Adw.ActionRow(focusable=False)
    icon = Gtk.Image()
    row.add_prefix(icon)
    btn = Gtk.Button(label=button_label, valign=Gtk.Align.CENTER)
    btn.add_css_class("suggested-action")
    btn.set_visible(False)
    row.add_suffix(btn)
    row._icon = icon
    row._action_btn = btn
    row._action_handler_id = None

    hover = Gtk.EventControllerMotion()
    hover.connect("enter", lambda *_a: btn.set_visible(True))
    hover.connect("leave", lambda *_a: btn.set_visible(False))
    row.add_controller(hover)

    list_item.set_child(row)


def _contact_pick_factory_bind(factory, list_item, on_click, subtitle_func=None):
    row = list_item.get_child()
    contact = list_item.get_item().contact

    row.set_title(GLib.markup_escape_text(contact.name) if contact.name else _("Unknown"))
    subtitle = subtitle_func(contact) if subtitle_func else contact.type_label
    row.set_subtitle(subtitle)
    row._icon.set_from_icon_name(_CONTACT_TYPE_ICONS.get(contact.type, "avatar-default-symbolic"))

    row._action_handler_id = row._action_btn.connect("clicked", lambda _b: on_click(contact))


def _contact_pick_factory_unbind(factory, list_item):
    row = list_item.get_child()
    if row._action_handler_id is not None:
        row._action_btn.disconnect(row._action_handler_id)
        row._action_handler_id = None


def show_contact_picker(
    window,
    *,
    title,
    contacts,
    on_add,
    button_label=None,
    subtitle_func=None,
    extra_top_widget=None,
    status_format_all=None,
    status_format_filtered=None,
):
    if button_label is None:
        button_label = _("Add")
    if status_format_all is None:
        status_format_all = _("{total} contacts")
    if status_format_filtered is None:
        status_format_filtered = _("{visible}/{total} contacts")

    builder = Gtk.Builder.new_from_resource("/page/codeberg/sesivany/Meshy/ui/discover-dialog.ui")
    dialog = builder.get_object("discover_dialog")
    dialog.set_title(title)
    search_btn = builder.get_object("discover_search_btn")
    search_entry = builder.get_object("discover_search_entry")
    list_view = builder.get_object("discover_list_view")
    status_label = builder.get_object("discover_status_label")
    filter_btn = builder.get_object("discover_filter_btn")
    sort_btn = builder.get_object("discover_sort_btn")

    filter_btn.set_visible(False)
    sort_btn.set_visible(False)

    search_btn.bind_property("active", search_entry, "visible", GObject.BindingFlags.SYNC_CREATE)
    search_entry.connect("stop-search", lambda _e: search_btn.set_active(False))

    store = Gio.ListStore(item_type=_ContactPickItem)
    for c in sorted(contacts, key=lambda c: c.name.lower()):
        store.append(_ContactPickItem(c))

    def _filter_func(item, _data):
        search = search_entry.get_text().lower()
        return not search or search in item.contact.name.lower()

    custom_filter = Gtk.CustomFilter.new(_filter_func, None)
    filter_model = Gtk.FilterListModel(model=store, filter=custom_filter)

    selection = Gtk.NoSelection(model=filter_model)
    list_view.set_model(selection)

    content_box = search_entry.get_parent()
    if extra_top_widget:
        content_box.prepend(extra_top_widget)

    def update_status():
        total = store.get_n_items()
        visible = filter_model.get_n_items()
        if total == 0:
            status_label.set_label(_("No contacts"))
        elif visible != total:
            status_label.set_label(status_format_filtered.format(visible=visible, total=total))
        else:
            status_label.set_label(status_format_all.format(total=total))

    def _handle_add(contact):
        on_add(contact)
        for i in range(store.get_n_items()):
            if store.get_item(i).contact.public_key_hex == contact.public_key_hex:
                store.remove(i)
                break
        update_status()
        if store.get_n_items() == 0:
            dialog.close()

    def _on_search_changed(*_args):
        custom_filter.changed(Gtk.FilterChange.DIFFERENT)
        update_status()

    search_entry.connect("search-changed", _on_search_changed)

    factory = Gtk.SignalListItemFactory()
    factory.connect("setup", lambda f, li: _contact_pick_factory_setup(f, li, button_label))
    factory.connect(
        "bind",
        lambda f, li: _contact_pick_factory_bind(f, li, _handle_add, subtitle_func=subtitle_func),
    )
    factory.connect("unbind", _contact_pick_factory_unbind)
    list_view.set_factory(factory)

    def _on_activate(lv, pos):
        item = lv.get_model().get_item(pos)
        if item:
            _handle_add(item.contact)

    list_view.connect("activate", _on_activate)

    dialog._refs = [store, custom_filter, filter_model, selection, factory]

    update_status()

    return types.SimpleNamespace(
        dialog=dialog,
        store=store,
        filter_model=filter_model,
        custom_filter=custom_filter,
        selection=selection,
        search_entry=search_entry,
        list_view=list_view,
        status_label=status_label,
        content_box=content_box,
        filter_btn=filter_btn,
        sort_btn=sort_btn,
        update_status=update_status,
        present=lambda: dialog.present(window),
    )
