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

"""QR code scanner using XDG Camera Portal + PipeWire + GStreamer."""

import logging
import os

try:
    from pyzbar import pyzbar as _pyzbar
except ImportError:
    _pyzbar = None

import gi

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

log = logging.getLogger(__name__)

Gst.init(None)

PORTAL_BUS_NAME = "org.freedesktop.portal.Desktop"
PORTAL_OBJECT_PATH = "/org/freedesktop/portal/desktop"
PORTAL_CAMERA_IFACE = "org.freedesktop.portal.Camera"
PORTAL_REQUEST_IFACE = "org.freedesktop.portal.Request"


class QRScannerDialog:
    """Dialog that opens the camera via XDG portal and scans for QR codes."""

    def __init__(self, window, on_result):
        self._window = window
        self._on_result = on_result
        self._pipeline = None
        self._dialog = None
        self._signal_id = None

    def start(self):
        """Request camera access via the XDG portal."""
        try:
            self._bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
        except GLib.Error as e:
            log.error(f"Cannot connect to session bus: {e.message}")
            self._show_error(_("Cannot connect to session bus: {}").format(e.message))
            return

        # Check if camera portal is available
        try:
            result = self._bus.call_sync(
                PORTAL_BUS_NAME,
                PORTAL_OBJECT_PATH,
                "org.freedesktop.DBus.Properties",
                "Get",
                GLib.Variant("(ss)", (PORTAL_CAMERA_IFACE, "IsCameraPresent")),
                GLib.VariantType.new("(v)"),
                Gio.DBusCallFlags.NONE,
                5000,
                None,
            )
            is_present = result.get_child_value(0).get_variant().get_boolean()
            if not is_present:
                self._show_error(_("No camera detected on this system."))
                return
        except GLib.Error as e:
            log.warning(f"Could not check IsCameraPresent: {e.message}")
            # Continue anyway, the portal might still work

        self._access_camera()

    def _access_camera(self):
        """Call AccessCamera on the portal."""
        token = f"meshy_{os.getpid()}"
        sender = self._bus.get_unique_name().replace(".", "_").lstrip(":")
        request_path = f"/org/freedesktop/portal/desktop/request/{sender}/{token}"

        log.info(f"AccessCamera: subscribing to {request_path}")

        # Subscribe to the Response signal before making the call
        self._signal_id = self._bus.signal_subscribe(
            PORTAL_BUS_NAME,
            PORTAL_REQUEST_IFACE,
            "Response",
            request_path,
            None,
            Gio.DBusSignalFlags.NO_MATCH_RULE,
            self._on_access_response,
        )

        params = GLib.Variant(
            "(a{sv})",
            (
                {
                    "handle_token": GLib.Variant("s", token),
                },
            ),
        )

        self._bus.call(
            PORTAL_BUS_NAME,
            PORTAL_OBJECT_PATH,
            PORTAL_CAMERA_IFACE,
            "AccessCamera",
            params,
            None,  # accept any reply type
            Gio.DBusCallFlags.NONE,
            30000,
            None,
            self._on_access_call_done,
        )

    def _on_access_call_done(self, bus, result):
        try:
            res = bus.call_finish(result)
            log.info(f"AccessCamera returned: {res}")
        except GLib.Error as e:
            log.error(f"AccessCamera call failed: {e.message}")
            self._cleanup_signal()
            self._show_error(_("Could not access camera: {}").format(e.message))

    def _on_access_response(self, bus, sender, path, iface, signal, params):
        log.info(f"AccessCamera response received: {params}")
        self._cleanup_signal()

        response = params.get_child_value(0).get_uint32()
        if response != 0:
            log.warning(f"Camera access denied, response={response}")
            GLib.idle_add(lambda: self._show_error(_("Camera access was denied.")) or False)
            return

        # Permission granted, open PipeWire remote
        GLib.idle_add(lambda: self._open_pipewire_remote() or False)

    def _cleanup_signal(self):
        if self._signal_id:
            self._bus.signal_unsubscribe(self._signal_id)
            self._signal_id = None

    def _open_pipewire_remote(self):
        """Get a PipeWire fd from the portal."""
        try:
            msg = Gio.DBusMessage.new_method_call(
                PORTAL_BUS_NAME,
                PORTAL_OBJECT_PATH,
                PORTAL_CAMERA_IFACE,
                "OpenPipeWireRemote",
            )
            msg.set_body(GLib.Variant("(a{sv})", ({},)))

            result = self._bus.send_message_with_reply_sync(
                msg,
                Gio.DBusSendMessageFlags.NONE,
                5000,
                None,
            )
            # send_message_with_reply_sync returns (reply_msg, out_serial)
            reply_msg = result[0] if isinstance(result, tuple) else result

            if reply_msg.get_message_type() == Gio.DBusMessageType.ERROR:
                error_name = reply_msg.get_error_name()
                log.error(f"OpenPipeWireRemote error: {error_name}")
                self._show_error(_("Could not open camera stream."))
                return

            fd_list = reply_msg.get_unix_fd_list()
            if not fd_list or fd_list.get_length() == 0:
                log.error("OpenPipeWireRemote: no fd in reply")
                self._show_error(_("No camera file descriptor received."))
                return

            pw_fd = fd_list.get(0)
            log.info(f"Got PipeWire fd: {pw_fd}")
            self._start_scanning(pw_fd)

        except GLib.Error as e:
            log.error(f"OpenPipeWireRemote failed: {e.message}")
            self._show_error(_("Could not open camera: {}").format(e.message))
        except Exception as e:
            log.error(f"OpenPipeWireRemote unexpected error: {e}")
            self._show_error(_("Could not open camera: {}").format(e))

    def _start_scanning(self, pw_fd):
        """Build GStreamer pipeline and show the camera dialog."""
        builder = Gtk.Builder.new_from_resource(
            "/page/codeberg/sesivany/Meshy/ui/qr-scanner-dialog.ui"
        )
        self._dialog = builder.get_object("dialog")
        self._picture = builder.get_object("picture")
        self._status_label = builder.get_object("status_label")

        self._dialog.connect("closed", self._on_dialog_closed)

        # Build GStreamer pipeline
        pipeline_str = (
            f"pipewiresrc fd={pw_fd} ! "
            "videoconvert ! "
            "video/x-raw,format=RGB ! "
            "tee name=t "
            "t. ! queue ! videoconvert ! gtk4paintablesink name=sink "
            "t. ! queue max-size-buffers=1 leaky=downstream ! "
            "videoscale ! video/x-raw,width=640,height=480 ! "
            "videoconvert ! video/x-raw,format=GRAY8 ! "
            "appsink name=qrsink emit-signals=true max-buffers=1 drop=true"
        )
        try:
            self._pipeline = Gst.parse_launch(pipeline_str)
        except GLib.Error as e:
            log.error(f"Failed to create GStreamer pipeline: {e.message}")
            self._show_error(_("Camera pipeline error: {}").format(e.message))
            return

        # Connect the paintable sink to the Picture widget
        sink = self._pipeline.get_by_name("sink")
        paintable = sink.get_property("paintable")
        self._picture.set_paintable(paintable)

        # Connect to appsink for QR decoding
        appsink = self._pipeline.get_by_name("qrsink")
        appsink.connect("new-sample", self._on_new_sample)

        # Watch for pipeline errors
        bus = self._pipeline.get_bus()
        bus.add_signal_watch()
        bus.connect("message::error", self._on_pipeline_error)

        self._pipeline.set_state(Gst.State.PLAYING)
        self._dialog.present(self._window)
        self._scan_active = True
        self._scan_start_time = GLib.get_monotonic_time()
        self._hint_shown = False
        self._timeout_shown = False
        self._hint_timer_id = GLib.timeout_add(8000, self._on_scan_hint)
        self._timeout_timer_id = GLib.timeout_add(30000, self._on_scan_timeout)

    def _on_new_sample(self, appsink):
        """Pull a frame from appsink and try to decode QR codes."""
        if not self._scan_active:
            return Gst.FlowReturn.OK

        sample = appsink.emit("pull-sample")
        if not sample:
            return Gst.FlowReturn.OK

        buf = sample.get_buffer()
        caps = sample.get_caps()
        struct = caps.get_structure(0)
        width = struct.get_value("width")
        height = struct.get_value("height")
        struct.get_string("format")

        success, mapinfo = buf.map(Gst.MapFlags.READ)
        if not success:
            return Gst.FlowReturn.OK

        if _pyzbar is None:
            buf.unmap(mapinfo)
            return Gst.FlowReturn.OK

        try:
            results = _pyzbar.decode(
                (bytes(mapinfo.data), width, height),
                symbols=[_pyzbar.ZBarSymbol.QRCODE],
            )
        except Exception as e:
            log.debug(f"QR decode error: {e}")
            buf.unmap(mapinfo)
            return Gst.FlowReturn.OK

        buf.unmap(mapinfo)

        for qr in results:
            data = qr.data.decode("utf-8", errors="ignore").strip()
            log.info(f"QR code detected: {data[:80]}...")
            self._scan_active = False
            GLib.idle_add(self._on_qr_found, data)
            return Gst.FlowReturn.OK

        return Gst.FlowReturn.OK

    def _on_scan_hint(self):
        """Show a hint after 8 seconds of no detection."""
        self._hint_timer_id = None
        if self._scan_active:
            self._status_label.set_label(
                _(
                    "No QR code detected yet.\n"
                    "Make sure the code is well-lit, sharp, and fills most of the frame."
                )
            )
        return False

    def _on_scan_timeout(self):
        """Show a give-up message after 30 seconds."""
        self._timeout_timer_id = None
        if self._scan_active:
            self._status_label.set_label(
                _("Could not detect QR code.\n" "Try importing the contact via clipboard instead.")
            )
        return False

    def _on_qr_found(self, data):
        """Handle a detected meshcore:// QR code."""
        self._status_label.set_label(_("QR code found!"))
        self._stop_pipeline()
        if self._dialog:
            self._dialog.close()
        self._on_result(data)
        return False

    def _on_pipeline_error(self, bus, msg):
        err, debug = msg.parse_error()
        log.error(f"GStreamer error: {err.message} ({debug})")
        GLib.idle_add(lambda: self._show_error(_("Camera error: {}").format(err.message)) or False)

    def _on_dialog_closed(self, dialog):
        self._stop_pipeline()

    def _stop_pipeline(self):
        self._scan_active = False
        if getattr(self, "_hint_timer_id", None):
            GLib.source_remove(self._hint_timer_id)
            self._hint_timer_id = None
        if getattr(self, "_timeout_timer_id", None):
            GLib.source_remove(self._timeout_timer_id)
            self._timeout_timer_id = None
        if self._pipeline:
            self._pipeline.set_state(Gst.State.NULL)
            self._pipeline = None

    def _show_error(self, message):
        dialog = Adw.AlertDialog(heading=_("Camera Error"), body=message)
        dialog.add_response("ok", _("OK"))
        dialog.present(self._window)
