"""Subprocess helpers built on Gio so nothing blocks the GTK main loop.

The whole backend contract (see backend/*.sh, backend/geolocate.py) is: a command
prints JSON (or plain text) to stdout, diagnostics to stderr, non-zero exit on
failure. These helpers run such commands asynchronously and hand the parsed result
back on the main thread.
"""

from __future__ import annotations

import json
import shlex
from typing import Callable, Sequence

import gi

gi.require_version("Gio", "2.0")
from gi.repository import Gio, GLib  # noqa: E402


def _as_argv(cmd: Sequence[str] | str) -> list[str]:
    return shlex.split(cmd) if isinstance(cmd, str) else list(cmd)


def run_text(cmd: Sequence[str] | str, cb: Callable[[bool, str, str], None]) -> None:
    """Run cmd, call cb(ok, stdout, stderr) on the main thread when done."""
    try:
        proc = Gio.Subprocess.new(
            _as_argv(cmd),
            Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE,
        )
    except GLib.Error as err:
        GLib.idle_add(cb, False, "", str(err))
        return

    def _done(p: Gio.Subprocess, res: Gio.AsyncResult) -> None:
        try:
            _, out, errout = p.communicate_utf8_finish(res)
        except GLib.Error as err:
            cb(False, "", str(err))
            return
        cb(p.get_successful(), out or "", errout or "")

    proc.communicate_utf8_async(None, None, _done)


def run_json(cmd: Sequence[str] | str, cb: Callable[[bool, object], None]) -> None:
    """Run cmd expecting JSON on stdout; call cb(ok, data)."""

    def _text(ok: bool, out: str, err: str) -> None:
        if not ok or not out.strip():
            cb(False, err.strip() or "no output")
            return
        try:
            cb(True, json.loads(out))
        except json.JSONDecodeError as exc:
            cb(False, f"bad json: {exc}")

    run_text(cmd, _text)


class Poller:
    """Repeatedly run a command (or callable) on an interval, main-thread callback.

    Used for lightweight state that has no change signal (open ports, public IP,
    weather refresh). Modules that back onto an Astal GObject service should prefer
    connecting to that service's `notify::` signals instead of polling.
    """

    def __init__(self, interval_s: float, cmd: Sequence[str] | str, cb, json_mode: bool = False):
        self.interval_s = interval_s
        self.cmd = cmd
        self.cb = cb
        self.json_mode = json_mode
        self._source_id: int | None = None
        self._stopped = False

    def start(self) -> "Poller":
        self._stopped = False
        self._tick()
        self._source_id = GLib.timeout_add_seconds(int(self.interval_s), self._tick)
        return self

    def _tick(self) -> bool:
        if self._stopped:
            return GLib.SOURCE_REMOVE
        if self.json_mode:
            run_json(self.cmd, lambda ok, data: None if self._stopped else self.cb(ok, data))
        else:
            run_text(self.cmd, lambda ok, out, err: None if self._stopped else self.cb(ok, out, err))
        return GLib.SOURCE_CONTINUE

    def refresh_now(self) -> None:
        self._tick()

    def stop(self) -> None:
        self._stopped = True
        if self._source_id is not None:
            GLib.source_remove(self._source_id)
            self._source_id = None
