# 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

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, Gtk, GLib, Gst

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 = 'meshy_%d' % (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."""
        self._dialog = Adw.Dialog()
        self._dialog.set_title(_('Scan QR Code'))
        self._dialog.set_content_width(400)
        self._dialog.set_content_height(400)
        self._dialog.connect('closed', self._on_dialog_closed)

        toolbar_view = Adw.ToolbarView()
        header = Adw.HeaderBar()
        toolbar_view.add_top_bar(header)

        content = Gtk.Box(
            orientation=Gtk.Orientation.VERTICAL, spacing=8,
            margin_start=8, margin_end=8, margin_bottom=8,
        )

        # Video preview
        self._picture = Gtk.Picture()
        self._picture.set_size_request(360, 270)
        self._picture.set_vexpand(True)
        self._picture.set_content_fit(Gtk.ContentFit.CONTAIN)
        content.append(self._picture)

        # Status label
        self._status_label = Gtk.Label(label=_('Point the camera at a QR code...'))
        self._status_label.add_css_class('dim-label')
        content.append(self._status_label)

        toolbar_view.set_content(content)
        self._dialog.set_child(toolbar_view)

        # 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')
        fmt = struct.get_string('format')

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

        try:
            from pyzbar import pyzbar
            # pyzbar can decode from raw (data, width, height) tuple for 'L' (grayscale)
            # or from PIL Image. For RGB data, use the tuple form with 3 channels.
            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)
