pragma Singleton

// ─────────────────────────────────────────────────────────────────────────────
// Clipboard history — read out of Vicinae's store.
//
// Vicinae (the launcher) already runs a clipboard watcher, so this service is
// deliberately a *reader* and nothing else. A second `wl-paste --watch` would
// record every copy twice and give the desktop two disagreeing opinions about
// which pastes are sensitive enough to conceal.
//
// Two files back every entry:
//   ~/.local/share/vicinae/clipboard.db            metadata + a 50-char preview
//   ~/.local/share/vicinae/clipboard-data/<offer>  the full payload, text or binary
//
// The stored preview is truncated far too short to fill a row, so the query
// pulls the first few hundred bytes out of the payload file instead (sqlite's
// readfile()) and falls back to the stored preview if that file has been reaped.
//
// Read-only and on demand: the query runs when refresh() is called, never on a
// timer. Nothing in here wakes up while the popover is closed.
// ─────────────────────────────────────────────────────────────────────────────

import Quickshell
import Quickshell.Io
import QtQuick

Singleton {
    id: root

    // ── Entry kinds ─────────────────────────────────────────────────────────
    // Vicinae's `selection.kind` column, confirmed empirically by copying one
    // of each: plain text, a URL, a PNG and a file drag.
    readonly property int kindText: 1
    readonly property int kindLink: 2
    readonly property int kindImage: 3
    readonly property int kindFile: 4

    readonly property string dbPath: Quickshell.env("HOME") + "/.local/share/vicinae/clipboard.db"
    readonly property string dataDir: Quickshell.env("HOME") + "/.local/share/vicinae/clipboard-data"

    // How many rows to load. The popover scrolls, so this is a memory budget
    // rather than a display limit; anything older is a job for the launcher.
    readonly property int limit: 50

    // Newest first, pinned entries hoisted above them. Each element:
    //   { id, offerId, mime, kind, source, host, pinned, ts, size,
    //     preview, encrypted, textual, typeLabel, dataPath }
    property var entries: []

    // True between refresh() and the query finishing. The popover uses it to
    // tell "nothing copied yet" apart from "not loaded yet".
    property bool loading: false

    // False if the query failed outright — a missing database, or Vicinae not
    // installed. Distinct from an empty history.
    property bool available: true

    // Wall-clock seconds at the moment `entries` was filled. Relative times are
    // rendered against this rather than against a live clock, so a row's label
    // cannot change while the user is reading it and nothing has to tick.
    property real queriedAt: 0

    signal refreshed

    // Reload the history. Cheap enough to call on every popover open; a call
    // made while a query is already in flight is dropped rather than queued,
    // because the in-flight result is already at most milliseconds stale.
    function refresh(): void {
        if (query.running)
            return;
        root.loading = true;
        query.running = true;
    }

    // Put an entry back on the clipboard. The payload file is the source of
    // truth — the preview in `entries` is truncated, so copying that back would
    // silently hand the user a fragment of what they asked for.
    function copyEntry(id: string): void {
        const entry = root.entries.find(e => e.id === id);
        if (!entry || entry.encrypted)
            return;

        // Arguments after the -c script become $0..$n, so neither the path nor
        // the mime type is ever spliced into shell source. wl-copy has no
        // read-from-file flag, hence the redirect.
        copy.command = ["sh", "-c", 'exec wl-copy --type "$2" < "$1"', "qs-clipboard", entry.dataPath, entry.mime];
        copy.running = true;
    }

    // "just now" / "5m" / "2h" / "Mon" / "Mar 4", against the time the list was
    // loaded. Deliberately terse: this sits in the corner of a dense row.
    function relativeTime(ts: real): string {
        const delta = Math.max(0, root.queriedAt - ts);

        if (delta < 60)
            return "just now";
        if (delta < 3600)
            return Math.floor(delta / 60) + "m";
        if (delta < 86400)
            return Math.floor(delta / 3600) + "h";

        const date = new Date(ts * 1000);
        if (delta < 7 * 86400)
            return Qt.formatDateTime(date, "ddd");
        return Qt.formatDateTime(date, "MMM d");
    }

    // ── The query ───────────────────────────────────────────────────────────
    // -readonly so a click in the shell can never take a write lock on a
    // database another process owns; -json because clipboard text is full of
    // newlines, pipes and quotes and any separator we picked would appear in it.
    //
    // The payload file is only read for unencrypted text: casting an encrypted
    // blob or a PNG to text yields noise, and those rows carry a usable label in
    // `text_preview` already ("Image (60x60)").
    readonly property string sql: `
        select s.id                       as id,
               o.id                       as offerId,
               s.preferred_mime_type      as mime,
               s.kind                     as kind,
               o.encryption_type          as enc,
               coalesce(s.source, '')     as source,
               coalesce(o.url_host, '')   as host,
               s.pinned_at is not null    as pinned,
               s.updated_at               as ts,
               o.size                     as size,
               case when s.preferred_mime_type like 'text/%' and o.encryption_type = 0
                    then coalesce(cast(substr(readfile('${root._sqlLiteral(root.dataDir)}/' || o.id), 1, 400) as text),
                                  o.text_preview)
                    else o.text_preview
               end                        as preview
        from selection s
        join data_offer o
          on o.selection_id = s.id and o.mime_type = s.preferred_mime_type
        where o.id = (
            select min(o2.id)
            from data_offer o2
            where o2.selection_id = s.id
              and o2.mime_type = s.preferred_mime_type
        )
        order by s.pinned_at is null, s.pinned_at desc, s.updated_at desc
        limit ${root.limit}`

    Process {
        id: query
        command: ["sqlite3", "-readonly", "-json", root.dbPath, root.sql]

        stdout: StdioCollector {
            onStreamFinished: root._parse(this.text)
        }

        onExited: exitCode => {
            root.loading = false;
            if (exitCode !== 0) {
                root.available = false;
                root.entries = [];
                root.refreshed();
            }
        }
    }

    Process {
        id: copy
        onExited: exitCode => {
            // Silent success, loud failure: a copy that quietly did nothing
            // leaves the user pasting whatever was there before.
            if (exitCode !== 0)
                console.warn("Clipboard: wl-copy exited", exitCode);
        }
    }

    function _parse(text: string): void {
        root.available = true;
        root.queriedAt = Date.now() / 1000;

        // sqlite3 -json prints nothing at all for an empty result set.
        if (!text || !text.trim()) {
            root.entries = [];
            root.refreshed();
            return;
        }

        try {
            root.entries = JSON.parse(text).map(row => root._normalise(row));
        } catch (e) {
            console.warn("Clipboard: could not parse history —", e);
            root.entries = [];
            root.available = false;
        }
        root.refreshed();
    }

    // Turn a raw row into what the UI actually asks questions of, so no
    // delegate has to know about column names or Vicinae's integer kinds.
    function _normalise(row: var): var {
        const encrypted = row.enc !== 0;
        const textual = row.kind === root.kindText || row.kind === root.kindLink;
        const raw = row.preview || "";

        return {
            id: row.id,
            offerId: row.offerId,
            mime: row.mime,
            kind: row.kind,
            source: row.source,
            host: row.host,
            pinned: row.pinned === 1,
            ts: row.ts,
            size: row.size,
            encrypted: encrypted,
            textual: textual,
            typeLabel: root._typeLabel(row.kind, row.mime),
            // Newlines and tabs become spaces: a row is one line of a list, and
            // a pasted shell script must not turn it into a paragraph.
            preview: encrypted ? "" : raw.replace(/\s+/g, " ").trim(),
            dataPath: root.dataDir + "/" + row.offerId
        };
    }

    // Short chip text for entries with nothing readable to show.
    function _typeLabel(kind: int, mime: string): string {
        switch (kind) {
        case root.kindImage:
            // "image/png" -> "PNG". Falls back to the whole type if it is odd.
            return (mime.split("/")[1] || mime).split(";")[0].toUpperCase();
        case root.kindFile:
            return "Files";
        case root.kindLink:
            return "Link";
        default:
            return "Text";
        }
    }

    // Escape a path for embedding in a SQL string literal. HOME is not
    // attacker-controlled, but a stray apostrophe would break the query.
    function _sqlLiteral(value: string): string {
        return value.replace(/'/g, "''");
    }
}
