//! D-Bus client for the Go agent's `com.printplatformpro.BrowserAgent`
//! interface. Synchronous (blocking) façade — every call is a
//! single round-trip to the local agent over the session bus,
//! latency is <1 ms, so we don't pay for an async runtime here.
//!
//! The client owns a `zbus::blocking::Connection` for the lifetime
//! of the app. Connection failure is recoverable: the UI surfaces
//! a "Disconnected" banner and degrades gracefully instead of
//! crashing.

use anyhow::{anyhow, Context, Result};
use zbus::blocking::{Connection, Proxy};

const BUS_NAME: &str = "com.printplatformpro.BrowserAgent";
const OBJECT_PATH: &str = "/com/printplatformpro/BrowserAgent";
const INTERFACE: &str = "com.printplatformpro.BrowserAgent1";
const SYSTEMD_UNIT: &str = "pppro-browser-linux.service";

/// `systemctl --user start <unit>` — invoked by `ensure_running()` at
/// UI startup and by `request_restart()` when the agent's D-Bus
/// listener is gone. Uses --no-block so the UI doesn't stall on a
/// slow unit start; the caller polls D-Bus separately if it cares.
fn start_service() -> Result<()> {
    let status = std::process::Command::new("systemctl")
        .args(["--user", "start", "--no-block", SYSTEMD_UNIT])
        .status()
        .context("invoke systemctl")?;
    if !status.success() {
        return Err(anyhow!("systemctl exited with {status}"));
    }
    Ok(())
}

/// Client wraps the long-lived session-bus connection and a proxy
/// pointed at the agent. `zbus::blocking::Connection` is itself
/// `Clone + Send + Sync` (its internals are reference-counted), so
/// `AgentClient` is Clone-able and a worker thread can take an owned
/// copy without going through `Rc`/`Arc`. The pages still share via
/// `Rc<AgentClient>` on the main thread; only paths that need to
/// move into a `std::thread::spawn` need to `.as_ref().clone()` out
/// of the Rc first.
#[derive(Debug, Clone)]
pub struct AgentClient {
    conn: Connection,
}

/// Snapshot of one row from the agent's allowlist. Matches the
/// `a(sst)` D-Bus signature exactly.
#[derive(Debug, Clone)]
pub struct OriginEntry {
    pub origin: String,
    pub source: String,
    pub approved_at_unix: u64,
}

/// Snapshot of one connected printer. Matches `a(ssss)` from
/// `ListPrinters`.
#[derive(Debug, Clone)]
pub struct PrinterEntry {
    pub uid: String,
    pub name: String,
    pub manufacturer: String,
    pub serial: String,
}

/// One result row from `ScanNetworkPrinters`. Carries enough context
/// for the Add-Network dialog to render the source pill (probe vs
/// mdns), populate the manual entry fields on click, and show the
/// firmware version as a row subtitle.
#[derive(Debug, Clone)]
pub struct DiscoveredPrinter {
    pub name: String,
    pub host: String,
    pub port: u16,
    pub model: String,
    pub firmware: String,
    pub source: String, // "probe" or "mdns"
}

impl PrinterEntry {
    /// Network printers carry a stable UID of the form
    /// `net:<host>:<port>` (mirrors `usb.NetworkPrinter.UID()`
    /// agent-side). USB printers use the device's USB serial
    /// number. Used by the Printers page to gate per-row Remove
    /// buttons — only network printers can be removed from the UI.
    /// True for any networked printer — raw-socket Zebras (UID
    /// prefix `net:`) AND IPP-only ZSB-series printers (UID
    /// prefix `ipp:`). The Printers page routes both into the
    /// Network sub-tab; the distinct UID prefix lets the row
    /// builder pick the right transport icon + the agent route
    /// writes via the matching backend.
    pub fn is_network(&self) -> bool {
        self.uid.starts_with("net:") || self.uid.starts_with("ipp:")
    }

    /// True specifically for IPP-only printers (Zebra ZSB series).
    /// Used by the row builder to surface a different transport
    /// pill / tooltip; printing itself works the same way (the
    /// agent's writeIPP path translates ZPL into an IPP Print-Job
    /// with `application/vnd.zebra-zpl`).
    pub fn is_ipp(&self) -> bool {
        self.uid.starts_with("ipp:")
    }

    /// Bluetooth printers carry a stable UID of the form `bt:<bdaddr>`
    /// (mirrors `usb.Printer.Serial = "bt:" + addr` agent-side). Used
    /// by the Printers page's ViewStack to route rows into the
    /// Bluetooth sub-tab.
    pub fn is_bluetooth(&self) -> bool {
        self.uid.starts_with("bt:")
    }
}

/// Reflects what `GetAutostartState` returned. Stays a `String`
/// because new states might be added agent-side (e.g. `masked`)
/// and we want the UI to render those gracefully even on an old
/// build.
#[derive(Debug, Clone)]
pub struct AutostartState(pub String);

/// Snapshot of the agent's two HTTP listeners. `ok` is true iff the
/// listener is bound and serving; on failure `err` carries the bind
/// error string (e.g. "address already in use"). Surfaced in the
/// Settings page's Agent section.
#[derive(Debug, Clone, Default)]
pub struct AgentHealth {
    pub http_ok: bool,
    pub http_addr: String,
    pub http_err: String,
    pub https_ok: bool,
    pub https_addr: String,
    pub https_err: String,
}

impl AutostartState {
    pub fn is_enabled(&self) -> bool {
        self.0 == "enabled"
    }
    pub fn is_supported(&self) -> bool {
        self.0 != "unsupported"
    }
}

impl AgentClient {
    pub fn connect() -> Result<Self> {
        let conn = Connection::session().context("connect to session D-Bus")?;
        Ok(Self { conn })
    }

    /// Ping the agent and, if it isn't answering, ask systemd to
    /// (re)start the user unit. Called at UI startup so a user who
    /// quit the agent via the tray and then re-opens the Settings app
    /// gets a working window instead of a row of "Agent not running"
    /// banners. Best-effort — failure here is non-fatal; the existing
    /// status banner + manual Restart button cover the worst case.
    pub fn ensure_running(&self) {
        if self.get_version().is_ok() {
            return;
        }
        tracing::info!("agent not answering D-Bus — invoking systemctl --user start");
        if let Err(e) = start_service() {
            tracing::warn!("auto-start failed (ignoring): {e:?}");
            return;
        }
        // systemd ack returns once the unit's ExecStart fires; the
        // agent itself takes another ~300-800 ms to claim the bus
        // name. Poll for up to 4 s so the first window paint already
        // shows live data.
        for _ in 0..20 {
            std::thread::sleep(std::time::Duration::from_millis(200));
            if self.get_version().is_ok() {
                tracing::info!("agent came up after systemctl start");
                return;
            }
        }
        tracing::warn!("agent still not responding 4 s after systemctl start");
    }

    fn proxy(&self) -> Result<Proxy<'_>> {
        Proxy::new(&self.conn, BUS_NAME, OBJECT_PATH, INTERFACE).context("agent proxy")
    }

    pub fn get_version(&self) -> Result<String> {
        let p = self.proxy()?;
        let v: String = p.call("GetVersion", &()).context("GetVersion")?;
        Ok(v)
    }

    /// Snapshot of both HTTP/HTTPS listener bind states. Empty `error`
    /// means OK; populated error means the listener never bound or
    /// died — the UI shows a red banner + Restart button.
    pub fn get_agent_health(&self) -> Result<AgentHealth> {
        let p = self.proxy()?;
        let (http_ok, http_addr, http_err, https_ok, https_addr, https_err): (
            bool,
            String,
            String,
            bool,
            String,
            String,
        ) = p.call("GetAgentHealth", &()).context("GetAgentHealth")?;
        Ok(AgentHealth {
            http_ok,
            http_addr,
            http_err,
            https_ok,
            https_addr,
            https_err,
        })
    }

    /// Asks the agent to exit so systemd respawns the user unit. The
    /// d-bus reply lands before the actual exit (~200ms delay), so
    /// callers should re-connect on a short timer.
    ///
    /// When the agent isn't running at all (user clicked Quit in the
    /// tray, or `systemctl --user stop`), the D-Bus call has no
    /// listener and fails. We fall back to `systemctl --user start`
    /// so the same Restart button works in both "running" and "down"
    /// states — otherwise the user is stuck with a toast that
    /// requires dropping to a terminal to recover.
    pub fn request_restart(&self) -> Result<()> {
        let p = self.proxy()?;
        match p.call::<_, _, ()>("RequestRestart", &()) {
            Ok(()) => Ok(()),
            Err(_) => start_service().context("RequestRestart fell through to systemctl start"),
        }
    }

    pub fn list_origins(&self) -> Result<Vec<OriginEntry>> {
        let p = self.proxy()?;
        let raw: Vec<(String, String, u64)> = p.call("ListOrigins", &()).context("ListOrigins")?;
        Ok(raw
            .into_iter()
            .map(|(origin, source, approved_at_unix)| OriginEntry {
                origin,
                source,
                approved_at_unix,
            })
            .collect())
    }

    pub fn approve(&self, origin: &str) -> Result<()> {
        let p = self.proxy()?;
        p.call::<_, _, ()>("Approve", &(origin,))
            .with_context(|| format!("Approve({origin})"))?;
        Ok(())
    }

    pub fn revoke(&self, origin: &str) -> Result<()> {
        let p = self.proxy()?;
        p.call::<_, _, ()>("Revoke", &(origin,))
            .with_context(|| format!("Revoke({origin})"))?;
        Ok(())
    }

    pub fn list_printers(&self) -> Result<Vec<PrinterEntry>> {
        let p = self.proxy()?;
        let raw: Vec<(String, String, String, String)> =
            p.call("ListPrinters", &()).context("ListPrinters")?;
        Ok(raw
            .into_iter()
            .map(|(uid, name, manufacturer, serial)| PrinterEntry {
                uid,
                name,
                manufacturer,
                serial,
            })
            .collect())
    }

    pub fn get_autostart_state(&self) -> Result<AutostartState> {
        let p = self.proxy()?;
        let s: String = p.call("GetAutostartState", &()).context("GetAutostartState")?;
        Ok(AutostartState(s))
    }

    pub fn set_autostart(&self, enabled: bool) -> Result<()> {
        let p = self.proxy()?;
        p.call::<_, _, ()>("SetAutostart", &(enabled,))
            .context("SetAutostart")?;
        Ok(())
    }

    /// Register a network-attached Zebra printer. `port == 0`
    /// resolves to the standard raw-socket port (9100) agent-side.
    /// Returns the stable UID (`net:<host>:<port>`) the agent
    /// assigned so the caller can refresh ListPrinters and
    /// highlight the new row.
    pub fn add_network_printer(&self, name: &str, host: &str, port: u16) -> Result<String> {
        let p = self.proxy()?;
        let uid: String = p
            .call("AddNetworkPrinter", &(name, host, port))
            .with_context(|| format!("AddNetworkPrinter({name}, {host}, {port})"))?;
        Ok(uid)
    }

    /// Drop a network printer by UID. Agent rejects unknown UIDs
    /// with NotFound — the caller surfaces that to the user via
    /// toast so a double-click doesn't silently swallow.
    pub fn remove_network_printer(&self, uid: &str) -> Result<()> {
        let p = self.proxy()?;
        p.call::<_, _, ()>("RemoveNetworkPrinter", &(uid,))
            .with_context(|| format!("RemoveNetworkPrinter({uid})"))?;
        Ok(())
    }

    /// Scan the local network (plus any caller-supplied subnets) for
    /// Zebra printers. Agent-side this runs an mDNS probe + a parallel
    /// subnet sweep with `~HI` host-identification — see
    /// `internal/discovery/scan.go`. Blocks up to ~8 seconds.
    /// `extra_subnets` accepts CIDR (`10.20.30.0/24`), bare network
    /// base (`10.20.30.0`), or any IP in the subnet (`10.20.30.98`,
    /// deduced to `/24`).
    pub fn scan_network_printers(
        &self,
        extra_subnets: &[String],
    ) -> Result<Vec<DiscoveredPrinter>> {
        let p = self.proxy()?;
        // Wire shape: a(ssqsss) — name, host, port, model, firmware, source.
        let raw: Vec<(String, String, u16, String, String, String)> = p
            .call("ScanNetworkPrinters", &(extra_subnets,))
            .with_context(|| format!("ScanNetworkPrinters(extra={extra_subnets:?})"))?;
        Ok(raw
            .into_iter()
            .map(
                |(name, host, port, model, firmware, source)| DiscoveredPrinter {
                    name,
                    host,
                    port,
                    model,
                    firmware,
                    source,
                },
            )
            .collect())
    }

    /// Send the printer's media-calibration command (`~JC` ZPL) to
    /// the device identified by uid. Agent-side this looks up the
    /// printer in the unified USB+network registry and writes
    /// directly — no CORS / approval / HTTP round-trip.
    pub fn calibrate(&self, uid: &str) -> Result<()> {
        let p = self.proxy()?;
        p.call::<_, _, ()>("Calibrate", &(uid,))
            .with_context(|| format!("Calibrate({uid})"))?;
        Ok(())
    }

    /// Generic raw-ZPL relay used by the Test Prints page. Bytes are
    /// passed straight through to `Printer.Write` agent-side; the UI
    /// owns the demo template catalogue.
    pub fn print_zpl(&self, uid: &str, data: &[u8]) -> Result<()> {
        let p = self.proxy()?;
        p.call::<_, _, ()>("PrintZpl", &(uid, data))
            .with_context(|| format!("PrintZpl({uid}, {} bytes)", data.len()))?;
        Ok(())
    }

    /// Query the printer's head resolution via Link-OS SGD. Returns
    /// 203, 300, or whatever the printer reports. Errors when the
    /// printer doesn't speak SGD or the response can't be parsed.
    pub fn get_printer_dpi(&self, uid: &str) -> Result<u16> {
        let p = self.proxy()?;
        let dpi: u16 = p
            .call("GetPrinterDpi", &(uid,))
            .with_context(|| format!("GetPrinterDpi({uid})"))?;
        Ok(dpi)
    }

    /// Query the printer's printhead width and most recently
    /// calibrated label length, both in dots. The UI uses these to
    /// grey out demo labels that won't fit. Errors when SGD isn't
    /// supported — callers treat that as "permit everything".
    pub fn get_media_size(&self, uid: &str) -> Result<(u32, u32)> {
        let p = self.proxy()?;
        let (head_width_dots, label_length_dots): (u32, u32) = p
            .call("GetMediaSize", &(uid,))
            .with_context(|| format!("GetMediaSize({uid})"))?;
        Ok((head_width_dots, label_length_dots))
    }

    /// Newer per-printer dimension probe: physical printhead width,
    /// stored ^PW value, and calibrated label length — three values
    /// from one `^HH` round-trip. Splitting physical-vs-stored lets
    /// the UI surface a "PW capped" warning when someone has
    /// `^PW<n>^JUS`-ed the printer down (the failure mode that hid
    /// as "Printhead: 2.00″" on a 4-inch ZD220 in PR #303).
    ///
    /// Errors when the method doesn't exist (older agent) — caller
    /// falls back to `get_media_size`.
    pub fn get_printer_dimensions(&self, uid: &str) -> Result<(u32, u32, u32)> {
        let p = self.proxy()?;
        let (phys, stored_pw, label_len): (u32, u32, u32) = p
            .call("GetPrinterDimensions", &(uid,))
            .with_context(|| format!("GetPrinterDimensions({uid})"))?;
        Ok((phys, stored_pw, label_len))
    }

    /// Query `~HQES` and parse out the printer's current
    /// errors + warnings. Returns `(ok, errors, warnings)`. An
    /// `ok=false` means the printer is in a fault state (red LED on
    /// most ZD-series). Warnings can be set independently of ok.
    pub fn get_printer_status(&self, uid: &str) -> Result<(bool, Vec<String>, Vec<String>)> {
        let p = self.proxy()?;
        let (ok, errors, warnings): (bool, Vec<String>, Vec<String>) = p
            .call("GetPrinterStatus", &(uid,))
            .with_context(|| format!("GetPrinterStatus({uid})"))?;
        Ok((ok, errors, warnings))
    }

    /// Fetch the persisted per-printer print offset (x, y in dots).
    pub fn get_print_offset(&self, uid: &str) -> Result<(i32, i32)> {
        let p = self.proxy()?;
        let (x, y): (i32, i32) = p
            .call("GetPrintOffset", &(uid,))
            .with_context(|| format!("GetPrintOffset({uid})"))?;
        Ok((x, y))
    }

    /// Return the agent's ZPL history log file path (JSONL,
    /// append-only). Empty string when the agent doesn't have a
    /// log enabled.
    pub fn get_zpl_log_path(&self) -> Result<String> {
        let p = self.proxy()?;
        let path: String = p
            .call("GetZplLogPath", &())
            .context("GetZplLogPath")?;
        Ok(path)
    }

    /// Persist a per-printer print offset (x, y in dots). Pass 0,0
    /// to clear.
    pub fn set_print_offset(&self, uid: &str, x_dots: i32, y_dots: i32) -> Result<()> {
        let p = self.proxy()?;
        p.call::<_, _, ()>("SetPrintOffset", &(uid, x_dots, y_dots))
            .with_context(|| format!("SetPrintOffset({uid}, {x_dots}, {y_dots})"))?;
        Ok(())
    }

    /// Run the full ZPL diagnostic battery (~HI, ~HS, ~HQES, ^HH,
    /// SGD getvars, etc.) against the printer and return the
    /// concatenated human-readable report.
    pub fn get_diagnostics(&self, uid: &str) -> Result<String> {
        let p = self.proxy()?;
        let report: String = p
            .call("GetDiagnostics", &(uid,))
            .with_context(|| format!("GetDiagnostics({uid})"))?;
        Ok(report)
    }

    /// Read the printer's current speed (IPS) and darkness (0-30).
    /// Returns zeros for fields the firmware doesn't expose.
    pub fn get_print_quality(&self, uid: &str) -> Result<(u8, u8)> {
        let p = self.proxy()?;
        let (speed, darkness): (u8, u8) = p
            .call("GetPrintQuality", &(uid,))
            .with_context(|| format!("GetPrintQuality({uid})"))?;
        Ok((speed, darkness))
    }

    /// Persist new speed + darkness on the printer (saved to NV
    /// memory via ^JUS so the values survive power cycles).
    pub fn set_print_quality(&self, uid: &str, speed_ips: u8, darkness: u8) -> Result<()> {
        let p = self.proxy()?;
        p.call::<_, _, ()>("SetPrintQuality", &(uid, speed_ips, darkness))
            .with_context(|| format!("SetPrintQuality({uid}, speed={speed_ips}, dark={darkness})"))?;
        Ok(())
    }

    /// Read the agent's cached license state. Returns everything the
    /// About-tab Licensing group needs to render: key (last 5 chars
    /// shown to the user), product id, coarse status, trial flag,
    /// days remaining, cancel flag, allow-print flag (drives whether
    /// the Test Prints page is enabled), and the last validation
    /// error message (empty when validation last succeeded).
    pub fn get_license_state(&self) -> Result<LicenseState> {
        let p = self.proxy()?;
        let (key, product, status, is_trial, days_remaining, current_period_end, cancel_at_period_end, allow_print, last_validate_err): (
            String, String, String, bool, i32, String, bool, bool, String,
        ) = p.call("GetLicenseState", &()).context("GetLicenseState")?;
        Ok(LicenseState {
            key,
            product,
            status,
            is_trial,
            days_remaining,
            current_period_end,
            cancel_at_period_end,
            allow_print,
            last_validate_err,
        })
    }

    /// Validate + activate a license key the user pasted into the UI.
    /// Returns the new status string ("active" | "trial" | "expired" | …)
    /// or the D-Bus error string from the agent.
    pub fn apply_license_key(&self, key: &str) -> Result<String> {
        let p = self.proxy()?;
        let status: String = p
            .call("ApplyLicenseKey", &(key,))
            .with_context(|| format!("ApplyLicenseKey({key})"))?;
        Ok(status)
    }

    /// Force a re-validate against the license server. Same effect as
    /// the agent's periodic 24h refresh, but on demand.
    pub fn refresh_license(&self) -> Result<String> {
        let p = self.proxy()?;
        let status: String = p.call("RefreshLicense", &()).context("RefreshLicense")?;
        Ok(status)
    }

    /// Canonical purchase URL for this product. The Subscribe button
    /// in the About tab opens this with `gtk::UriLauncher` / xdg-open.
    pub fn get_buy_url(&self) -> Result<String> {
        let p = self.proxy()?;
        let url: String = p.call("GetBuyURL", &()).context("GetBuyURL")?;
        Ok(url)
    }
}

/// Strongly-typed snapshot returned by [`AgentClient::get_license_state`].
#[derive(Debug, Clone)]
pub struct LicenseState {
    pub key: String,
    pub product: String,
    /// Coarse status: `active` | `trial` | `grace` | `expired` | `revoked`.
    pub status: String,
    pub is_trial: bool,
    /// `-1` means no expiry (shouldn't happen for subscription products).
    pub days_remaining: i32,
    /// RFC 3339. Empty when the agent has no cached value.
    pub current_period_end: String,
    pub cancel_at_period_end: bool,
    /// Whether `/write` + `PrintZpl` are currently being allowed.
    pub allow_print: bool,
    pub last_validate_err: String,
}
