// autopilot-menubar.swift  -  what is running, in the menu bar, while you are elsewhere.
//
// macOS has no Live Activity for a Mac app: ActivityKit ships in the macOS SDK
// but every entry point is `@available(macOS, unavailable)`, and the activities
// that appear in the menu bar on macOS 26 are mirrored from an iPhone. The Mac
// equivalent of "a thing that keeps showing progress in the corner" is an
// NSStatusItem, which is what this is.
//
// It is deliberately the dumbest program in the tree. It reads one JSON file and
// draws it. No polling of git, no shelling out, no network, no writes. Every
// decision - what is running, how long, what is queued - is made by the runner
// and lands in status.json; this only renders. That keeps the one compiled
// artifact we ship small enough to read in a sitting, and it means a bug here
// can misdraw but cannot affect a run.
//
// Unbundled on purpose. A .app means signing, notarization and a release
// artifact; a bare executable with `.accessory` activation owns a status item
// just as well, stays out of the Dock and the app switcher, and is built on
// demand from this source so nothing binary ever enters the package.
//
// Build:  swiftc -O autopilot-menubar.swift -o ~/.claude/autopilot/bin/menubar
// Run:    menubar [--status-file <path>] [--prefs-file <path>] [--interval <seconds>]

import AppKit

// MARK: - What the runner writes

struct Item: Decodable {
    let id: String            // PROJ-1234, or my-app#316
    let title: String?        // the ticket's own summary, trimmed by the writer
    let phase: Loose?         // "3" or 3 - jq passes the queue's own shape through
    let phaseName: String?    // "Dev"
    let phaseTotal: Loose?    // the last phase id in phases.json: 5
    let repo: String?
    let stack: String?
    let startedAt: Double?
    let state: String?        // running | queued | awaiting-answer | blocked
    let url: String?          // the ticket, so a row is clickable
}

/// Something that finished and produced a link worth opening: a PR, a Jira
/// comment, a Confluence page. Unattended work is only useful if what it
/// produced is one click away - otherwise the queue runs and you still have to
/// go looking for the results.
struct Report: Decodable {
    let id: String
    let kind: String?         // pr | jira | confluence | wiki
    let url: String?
    let finishedAt: Double?
    let outcome: String?      // merged | open | failed
}

/// A JSON value that may honestly arrive as a string or as a number.
///
/// This is not defensive programming for its own sake. `phase` is written by
/// `autopilot-status.sh`, which passes the queue's own value through `jq`
/// untouched, so it is a number when the runner wrote a number and a string when
/// it wrote a string - and Swift's synthesized `Decodable` throws on the
/// mismatch. A throw here does not lose one field: `Status` fails to decode
/// entirely and the indicator draws the off state while items are in flight,
/// which is the blank this file calls worse than no list, arriving through the
/// type system instead of through the logic.
struct Loose: Decodable {
    let text: String?
    init(from decoder: Decoder) throws {
        let c = try decoder.singleValueContainer()
        if c.decodeNil() { text = nil }
        else if let s = try? c.decode(String.self) { text = s }
        else if let i = try? c.decode(Int.self) { text = String(i) }
        else if let d = try? c.decode(Double.self) { text = String(Int(d)) }
        else { text = nil }
    }
}

struct Status: Decodable {
    let on: Bool
    let slots: Int?
    /// Items in flight RIGHT NOW. The writer removes an item the moment it
    /// finishes; it reappears under `reports` with whatever it produced. Nothing
    /// finished is ever left here - a list that keeps showing a done item is
    /// worse than no list, because you stop trusting the ones that are real.
    let running: [Item]?
    let queued: [Item]?
    let awaiting: [Item]?
    /// Recently finished, newest first, bounded by the writer to a short window
    /// (a day). This is not a history log - it is "what happened while you were
    /// away", and an unbounded list stops answering that.
    let reports: [Report]?
    let todayCount: Int?
    let todayUsd: Double?
    let lastTickAt: Double?
    let power: String? // ac | battery
    let note: String?
    /// How this machine's CLI spells the command that stops the mode. Written by
    /// `autopilot-status.sh`, which knows which host tree it was installed into;
    /// the three CLIs spell the same command three ways.
    let stopCommand: String?
}

// MARK: - Language

/// Every line a person reads renders in `prefs.global.outputLanguage`, the same
/// preference the rest of the pipeline reads. English is the default, and the
/// fallback for a code this table does not carry, so an unknown language draws a
/// readable menu rather than an empty one.
///
/// The codes are the preferences schema's own: lowercase, matched exactly, so
/// this menu and the terminal answer one written value the same way.
enum Language: String {
    case en
    case tr
}

func resolveLanguage(prefsPath: String) -> Language {
    guard let data = FileManager.default.contents(atPath: prefsPath),
          let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
          let global = root["global"] as? [String: Any],
          let code = global["outputLanguage"] as? String,
          let language = Language(rawValue: code)
    else { return .en }
    return language
}

/// The indicator's own chrome, and nothing else: whatever the runner puts in
/// `note`, `emptyReason` or `blockedReason` arrives already rendered and is drawn
/// as it stands.
struct Strings {
    let minutes: String
    let hoursMinutes: String
    let daysHours: String
    let tooltipRunning: String
    let tooltipOnBattery: String
    let tooltipOff: String
    let off: String
    let idle: String
    let queuedHeading: String
    let queuedMore: String
    let awaitingHeading: String
    let reportsHeading: String
    let todayCount: String
    let lastScan: String
    let ago: String
    let stopHint: String
    let controlIsElsewhere: String
    let quit: String
}

func strings(for language: Language) -> Strings {
    switch language {
    case .en:
        return Strings(
            minutes: "%dm",
            hoursMinutes: "%dh %dm",
            daysHours: "%dd %dh",
            tooltipRunning: "multi-agent autopilot: %d running, %d queued%@",
            tooltipOnBattery: " · on battery, sleep is free",
            tooltipOff: "multi-agent autopilot is off",
            off: "autopilot is off",
            idle: "on, nothing queued",
            queuedHeading: "Queued (%d)",
            queuedMore: "  ... %d more",
            awaitingHeading: "Waiting for an answer (%d)",
            reportsHeading: "Reports (%d)",
            todayCount: "today: %d",
            lastScan: "last scan %@ ago",
            ago: "%@ ago",
            stopHint: "To stop: %@",
            controlIsElsewhere: "Control stays in the terminal",
            quit: "Close the indicator")
    case .tr:
        return Strings(
            minutes: "%ddk",
            hoursMinutes: "%dsa %ddk",
            daysHours: "%dg %dsa",
            tooltipRunning: "multi-agent autopilot: %d koşuyor, %d sırada%@",
            tooltipOnBattery: " · bataryada, uyku serbest",
            tooltipOff: "multi-agent autopilot kapalı",
            off: "autopilot kapalı",
            idle: "açık, sırada iş yok",
            queuedHeading: "Sırada (%d)",
            queuedMore: "  ... %d tane daha",
            awaitingHeading: "Cevap bekliyor (%d)",
            reportsHeading: "Raporlar (%d)",
            todayCount: "bugün %d madde",
            lastScan: "son tarama %@ önce",
            ago: "%@ önce",
            stopHint: "Durdurmak için: %@",
            controlIsElsewhere: "Denetim terminalde kalır",
            quit: "Göstergeyi kapat")
    }
}

// MARK: - Rendering

/// Elapsed time as the user reads it, not as a duration library formats it.
/// Minutes up to an hour, then hours and minutes, then days; a run that has been
/// going for three days is not readable in minutes. The units come from the same
/// table as the rest of the chrome.
func elapsed(since epoch: Double?, _ ui: Strings) -> String {
    guard let epoch, epoch > 0 else { return "-" }
    let total = max(0, Date().timeIntervalSince1970 - epoch)
    let m = Int(total / 60)
    if m < 60 { return String(format: ui.minutes, m) }
    let h = m / 60
    if h < 24 { return String(format: ui.hoursMinutes, h, m % 60) }
    return String(format: ui.daysHours, h / 24, h % 24)
}

/// The one line in the corner. Short, because the menu bar is shared real estate
/// and a long title pushes other people's items off a laptop display.
func title(_ st: Status?) -> String {
    guard let st, st.on else { return "○" }
    let r = (st.running ?? []).filter { ($0.state ?? "running") == "running" }.count
    let q = st.queued?.count ?? 0
    let a = st.awaiting?.count ?? 0
    if r == 0 && q == 0 && a == 0 { return "◌" }
    var parts: [String] = []
    if r > 0 { parts.append("◐ \(r)") }
    if q > 0 { parts.append("· \(q)") }
    if a > 0 { parts.append("? \(a)") }
    return parts.joined(separator: " ")
}

final class Controller: NSObject {
    private lazy var item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
    private let path: String
    private let interval: TimeInterval
    private let ui: Strings
    private var timer: Timer?

    init(path: String, interval: TimeInterval, strings ui: Strings, headless: Bool = false) {
        self.path = path
        self.interval = interval
        self.ui = ui
        super.init()
        if headless { return }
        item.button?.title = "○"
        let menu = NSMenu()
        menu.delegate = self
        item.menu = menu
        refresh()
        timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
            self?.refresh()
        }
    }

    private func read() -> Status? {
        guard let data = FileManager.default.contents(atPath: path) else { return nil }
        return try? JSONDecoder().decode(Status.self, from: data)
    }

    private func refresh() {
        let st = read()
        item.button?.title = title(st)
        // A tooltip carries what does not fit in the title, for a glance that
        // does not need a click.
        if let st, st.on {
            let power = st.power == "battery" ? ui.tooltipOnBattery : ""
            item.button?.toolTip = String(
                format: ui.tooltipRunning, st.running?.count ?? 0, st.queued?.count ?? 0, power)
        } else {
            item.button?.toolTip = ui.tooltipOff
        }
    }

    /// One item, as much as fits on one line, in the order you would ask:
    /// which item, how far in, how long, what stack.
    ///
    ///   ◐ PROJ-1234 · 2/5 Dev · 22dk · ios
    ///
    /// The phase is a fraction rather than a name alone, because "Dev" says
    /// nothing about how much is left and "2/5" says it without a second line.
    private func row(_ i: Item, prefix: String) -> NSMenuItem {
        var line = "\(prefix)\(i.id)"
        if let p = i.phase?.text, !p.isEmpty {
            let total = i.phaseTotal?.text.map { "/\($0)" } ?? ""
            line += " · \(p)\(total)"
            if let n = i.phaseName, !n.isEmpty { line += " \(n)" }
        } else if let n = i.phaseName {
            line += " · \(n)"
        }
        if i.startedAt != nil { line += " · \(elapsed(since: i.startedAt, ui))" }
        if let s = i.stack, !s.isEmpty { line += " · \(s)" }

        let m = NSMenuItem(title: line, action: nil, keyEquivalent: "")
        // The ticket title is long and would push every row's shape around, so
        // it lives in the tooltip where it costs nothing.
        if let t = i.title, !t.isEmpty { m.toolTip = t }
        if let u = i.url, let link = URL(string: u) {
            m.action = #selector(open(_:))
            m.target = self
            m.representedObject = link
            m.isEnabled = true
        } else {
            m.isEnabled = false
        }
        return m
    }

    /// Opening a URL is the only thing this program does besides draw. It cannot
    /// start, stop or change a run - control stays in the terminal, where it is
    /// confirmed.
    @objc private func open(_ sender: NSMenuItem) {
        guard let u = sender.representedObject as? URL else { return }
        NSWorkspace.shared.open(u)
    }

    private func reportRow(_ r: Report) -> NSMenuItem {
        var line = "  \(r.id)"
        if let k = r.kind { line += " · \(k.uppercased())" }
        if let o = r.outcome { line += " · \(o)" }
        if r.finishedAt != nil {
            line += " · " + String(format: ui.ago, elapsed(since: r.finishedAt, ui))
        }
        let m = NSMenuItem(title: line, action: nil, keyEquivalent: "")
        if let u = r.url, let link = URL(string: u) {
            m.action = #selector(open(_:))
            m.target = self
            m.representedObject = link
            m.isEnabled = true
        } else {
            m.isEnabled = false
        }
        return m
    }

    func rebuild(_ menu: NSMenu) {
        menu.removeAllItems()
        guard let st = read() else {
            menu.addItem(withTitle: ui.off, action: nil, keyEquivalent: "").isEnabled = false
            menu.addItem(.separator())
            let q = NSMenuItem(title: ui.quit, action: #selector(quit), keyEquivalent: "q")
            q.target = self
            menu.addItem(q)
            return
        }
        if !st.on {
            menu.addItem(withTitle: ui.off, action: nil, keyEquivalent: "").isEnabled = false
        } else {
            // Fixed order, running first: the thing you would interrupt for is
            // always in the same place.
            //
            // Filtered on `state` as well as on which array it is in. A finished
            // item belongs in `reports`, not in `running`, and the writer moves
            // it - but a renderer that trusts the array alone will pin a finished
            // item at the top of the menu until the next write, which is exactly
            // the "it says it is still working when it is not" failure this whole
            // release was about. Two conditions, one of them local.
            for i in (st.running ?? []).filter({ ($0.state ?? "running") == "running" }) {
                menu.addItem(row(i, prefix: "◐ "))
            }
            if let q = st.queued, !q.isEmpty {
                menu.addItem(.separator())
                menu.addItem(withTitle: String(format: ui.queuedHeading, q.count), action: nil, keyEquivalent: "").isEnabled = false
                for i in q.prefix(8) { menu.addItem(row(i, prefix: "· ")) }
                if q.count > 8 {
                    menu.addItem(withTitle: String(format: ui.queuedMore, q.count - 8), action: nil, keyEquivalent: "").isEnabled = false
                }
            }
            if let a = st.awaiting, !a.isEmpty {
                menu.addItem(.separator())
                menu.addItem(withTitle: String(format: ui.awaitingHeading, a.count), action: nil, keyEquivalent: "").isEnabled = false
                for i in a.prefix(6) { menu.addItem(row(i, prefix: "? ")) }
            }
            if (st.running?.isEmpty ?? true) && (st.queued?.isEmpty ?? true) && (st.awaiting?.isEmpty ?? true) {
                menu.addItem(withTitle: ui.idle, action: nil, keyEquivalent: "").isEnabled = false
            }
        }
        // What it produced. Without this the menu answers "is it working" and
        // never "what did it do", which is the question you actually have after
        // being away.
        if let rs = st.reports, !rs.isEmpty {
            menu.addItem(.separator())
            menu.addItem(withTitle: String(format: ui.reportsHeading, rs.count), action: nil, keyEquivalent: "").isEnabled = false
            for r in rs.prefix(8) { menu.addItem(reportRow(r)) }
        }

        menu.addItem(.separator())
        if let n = st.todayCount {
            var line = String(format: ui.todayCount, n)
            if let u = st.todayUsd { line += String(format: " · $%.2f", u) }
            menu.addItem(withTitle: line, action: nil, keyEquivalent: "").isEnabled = false
        }
        if let t = st.lastTickAt {
            menu.addItem(
                withTitle: String(format: ui.lastScan, elapsed(since: t, ui)),
                action: nil, keyEquivalent: "").isEnabled = false
        }
        if let n = st.note, !n.isEmpty {
            menu.addItem(withTitle: n, action: nil, keyEquivalent: "").isEnabled = false
        }
        menu.addItem(.separator())
        // Control lives in the terminal. The quit item closes the INDICATOR only,
        // and says so - a menu item reading "Close" next to a queue would
        // reasonably be taken for "stop the autopilot", and quietly stopping
        // unattended work from a menu nobody confirmed is the wrong default. The
        // command is named the way THIS machine's CLI spells it, or the line
        // falls back to where control lives when the writer did not say.
        let stop = st.stopCommand.flatMap { $0.isEmpty ? nil : $0 }
        let stopLine = stop.map { String(format: ui.stopHint, $0) } ?? ui.controlIsElsewhere
        menu.addItem(withTitle: stopLine, action: nil, keyEquivalent: "").isEnabled = false
        let q = NSMenuItem(title: ui.quit, action: #selector(quit), keyEquivalent: "q")
        q.target = self
        menu.addItem(q)
    }

    @objc private func quit() { NSApplication.shared.terminate(nil) }

    /// The title and every menu line, as text. Built by the same rebuild() the
    /// live menu uses, so what a gate asserts is what a user sees.
    func dump() -> String {
        let st = read()
        let menu = NSMenu()
        rebuild(menu)
        var out = ["title: \(title(st))"]
        for i in 0..<menu.numberOfItems {
            let mi = menu.item(at: i)!
            out.append(mi.isSeparatorItem ? "---" : mi.title)
        }
        return out.joined(separator: "\n")
    }
}

extension Controller: NSMenuDelegate {
    func menuWillOpen(_ menu: NSMenu) { rebuild(menu) }
}

// MARK: - Entry

var statusPath = (NSHomeDirectory() as NSString).appendingPathComponent(".claude/autopilot/status.json")
var prefsPath = (NSHomeDirectory() as NSString)
    .appendingPathComponent(".claude/multi-agent-preferences.json")
var interval: TimeInterval = 5

var dumpOnly = false
var args = Array(CommandLine.arguments.dropFirst())
while let a = args.first {
    args.removeFirst()
    switch a {
    case "--status-file":
        if let v = args.first { statusPath = v; args.removeFirst() }
    case "--prefs-file":
        if let v = args.first { prefsPath = v; args.removeFirst() }
    case "--interval":
        if let v = args.first, let d = Double(v) { interval = max(1, d); args.removeFirst() }
    case "--dump":
        dumpOnly = true
    case "--help", "-h":
        print("usage: menubar [--status-file <path>] [--prefs-file <path>] [--interval <seconds>] [--dump]")
        exit(0)
    default:
        FileHandle.standardError.write("menubar: unknown option \(a)\n".data(using: .utf8)!)
        exit(2)
    }
}

// `--dump` prints the title and every menu line, then exits. It exists so the
// content can be asserted by a gate: a smoke test cannot click a status item, and
// a gate that only checks "the binary compiled" proves nothing about what the
// user reads. Same code path as the live menu, so the dump cannot drift from it.
let table = strings(for: resolveLanguage(prefsPath: prefsPath))

if dumpOnly {
    let c = Controller(path: statusPath, interval: interval, strings: table, headless: true)
    print(c.dump())
    exit(0)
}

let app = NSApplication.shared
app.setActivationPolicy(.accessory) // no Dock tile, no app switcher entry
let controller = Controller(path: statusPath, interval: interval, strings: table)
_ = controller
app.run()
