// Lid Creak — a macOS app for the MacBook lid-angle sensor.
//
// Reads IOHID usage page 0x20 / usage 0x8A (feature report 1 -> uint16 LE degrees) and plays
// FreeSlapMac's own creaking.mp3 while the lid moves. Supersedes the launchd + Python helper:
// a real .app with a window (live angle, measured speed), a menu-bar item, a Creak sound
// switch, a three-level sensitivity control, start-at-login, and a real Quit.
//
// The decision logic lives in Detector, a pure object fed (time, angle) samples, so the sensor
// is only one caller. That is what makes the sensitivity testable without moving the machine:
//
//   speed is measured across a >= 0.15 s span, not between two 50 ms samples, because the
//   sensor reports WHOLE DEGREES and 1 deg in 50 ms reads as 20 deg/s of "speed" — pure
//   quantisation. A 1 deg blip must not creak at the least sensitive setting.
//
//   LidCreak                 the app
//   LidCreak --selftest      sensor + audio diagnostics, plays one creak, exit 0/2
//   LidCreak --trigger-test  10 detector boundary assertions, exit 0/1
//   LidCreak --simulate      8 scripted lid timelines through the same Detector, exit 0/1
//   LidCreak --record N      log N seconds of real sensor samples
//   LidCreak --once N        print N angle samples and exit (debugging)
//   LidCreak --snapshot FILE render the window's content into a PNG (no screen recording)
//   LidCreak --login-item    add/remove the LaunchAgent
//
// No root, no kext, no private APIs: feature-report reads are unprivileged.

import AppKit
import AVFoundation
import CoreFoundation
import IOKit.hid
import QuartzCore

// MARK: - constants

let kPage: Int32 = 0x20
let kUsage: Int32 = 0x8A
let kVersion = "2.2.0"
let kBundleID = "com.isaac.lidcreak"
let kRetrigger: TimeInterval = 1.4
let kSampleHz: Double = 20
let kSleepJumpDeg = 40.0
let kMaxDT: TimeInterval = 0.5
let kClipSeconds = 1.8

// Speed is measured across a sliding window, not between two consecutive
// samples. One 50 ms sample of 1° is 20 °/s of "speed" — pure quantisation,
// not a creak — and that is why every sensitivity setting used to fire.
let kWindow: TimeInterval = 0.35      // look back this far to measure how far it moved
let kMinWindow: TimeInterval = 0.18   // don't trust a span shorter than this
let kSpeedWindow: TimeInterval = 0.15 // shorter span for speed: follows a quick push
let kSpeedMin: TimeInterval = 0.10
let kMinTravel: Double = 3.0          // a lid must actually move 3° to creak
let kConfirmTicks = 2                 // and keep it up for ~100 ms

enum Threshold: Double, CaseIterable {
    case low = 35, normal = 22, high = 14
    var label: String {
        switch self {
        case .low: return "Low — 35 °/s"
        case .normal: return "Normal — 22 °/s"
        case .high: return "High — 14 °/s"
        }
    }
}

// MARK: - log

let logURL = FileManager.default.homeDirectoryForCurrentUser
    .appendingPathComponent("Library/Logs/LidCreak.log")

func logLine(_ s: String) {
    let f = DateFormatter()
    f.dateFormat = "yyyy-MM-dd HH:mm:ss"
    let line = "\(f.string(from: Date()))  \(s)\n"
    if let h = try? FileHandle(forWritingTo: logURL) {
        h.seekToEndOfFile()
        h.write(line.data(using: .utf8)!)
        try? h.close()
    } else {
        try? line.write(to: logURL, atomically: true, encoding: .utf8)
    }
    FileHandle.standardError.write(line.data(using: .utf8)!)
}

// MARK: - sound

/// Finds FreeSlapMac's creaking.mp3, renders a trimmed + peak-normalised copy once, and plays it.
final class CreakSound {
    private(set) var sourceURL: URL?
    private(set) var preparedURL: URL?
    private(set) var peakBefore: Float = 0          // linear peak of the source
    private(set) var gainApplied: Float = 1
    private var players: [AVAudioPlayer] = []
    private var next = 0

    private static let candidates = [
        "/Applications/FreeSlapMac.app/Contents/Resources/Sounds/creaking.mp3",
        "/Applications/FreeSlapMac Silent.app/Contents/Resources/Sounds/creaking.mp3",
        "/Applications/SlapMac.app/Contents/Resources/Sounds/creaking.mp3",
        "/Applications/slapmac.app/Contents/Resources/Sounds/creaking.mp3",
    ]

    var supportDir: URL {
        let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
        return base.appendingPathComponent("LidCreak", isDirectory: true)
    }

    func locate() {
        for path in Self.candidates where FileManager.default.fileExists(atPath: path) {
            sourceURL = URL(fileURLWithPath: path)
            return
        }
        sourceURL = nil
    }

    /// Render <support>/creak.caf: first 1.8 s, normalised to -1 dBFS. Idempotent.
    @discardableResult
    func prepare() throws -> URL? {
        if sourceURL == nil { locate() }
        guard let src = sourceURL else { return nil }
        try FileManager.default.createDirectory(at: supportDir, withIntermediateDirectories: true)
        let out = supportDir.appendingPathComponent("creak.wav")
        let stale = supportDir.appendingPathComponent("creak.caf")
        try? FileManager.default.removeItem(at: stale)
        let outAttrs = (try? FileManager.default.attributesOfItem(atPath: out.path))?[.size] as? Int ?? 0
        if outAttrs > 0 {
            // cache hit: recover the measured numbers so --selftest reports facts, not blanks
            let side = supportDir.appendingPathComponent("prepared.json")
            if let d = try? Data(contentsOf: side),
               let j = try? JSONSerialization.jsonObject(with: d) as? [String: Any] {
                peakBefore = Float(j["peak"] as? Double ?? 0)
                gainApplied = Float(j["gain"] as? Double ?? 1)
            }
            preparedURL = out
            return out
        }

        let file = try AVAudioFile(forReading: src)
        let fmt = file.processingFormat
        let maxFrames = AVAudioFrameCount(min(Double(file.length), fmt.sampleRate * Double(kClipSeconds)))
        guard maxFrames > 0, let buf = AVAudioPCMBuffer(pcmFormat: fmt, frameCapacity: maxFrames) else {
            return nil
        }
        try file.read(into: buf)
        guard let src_data = buf.floatChannelData else { return nil }
        let ch = Int(fmt.channelCount)
        let n = Int(buf.frameLength)
        guard n > 0, ch > 0 else { return nil }

        // find the peak so the gain is measured, not guessed
        var peak: Float = 0
        for c in 0..<ch {
            let p = src_data[c]
            for i in 0..<n { peak = max(peak, abs(p[i])) }
        }
        peakBefore = peak
        guard peak > 0.0001 else { return nil }
        let target: Float = 0.891                       // -1 dBFS
        let gain = min(20, target / peak)
        gainApplied = gain

        // interleave to 16-bit PCM and write the WAV container by hand: no format negotiation,
        // nothing for CoreAudio to reject, and the gain maths is right here instead of implied.
        var pcm = [Int16](repeating: 0, count: n * ch)
        for i in 0..<n {
            for c in 0..<ch {
                let v = max(-1, min(1, src_data[c][i] * gain))
                pcm[i * ch + c] = Int16(v * 32767.0)
            }
        }
        var blob = Data()
        let byteRate = UInt32(fmt.sampleRate) * UInt32(ch * 2)
        let dataBytes = UInt32(pcm.count * 2)
        func le32(_ v: UInt32) -> Data { withUnsafeBytes(of: v.littleEndian) { Data($0) } }
        func le16(_ v: UInt16) -> Data { withUnsafeBytes(of: v.littleEndian) { Data($0) } }
        blob.append("RIFF".data(using: .ascii)!)
        blob.append(le32(36 + dataBytes))
        blob.append("WAVEfmt ".data(using: .ascii)!)
        blob.append(le32(16))                            // PCM chunk size
        blob.append(le16(1))                             // format = PCM
        blob.append(le16(UInt16(ch)))
        blob.append(le32(UInt32(fmt.sampleRate)))
        blob.append(le32(byteRate))
        blob.append(le16(UInt16(ch * 2)))                // block align
        blob.append(le16(16))                            // bits per sample
        blob.append("data".data(using: .ascii)!)
        blob.append(le32(dataBytes))
        pcm.withUnsafeBufferPointer { blob.append(Data(buffer: $0)) }
        try blob.write(to: out)
        let side = supportDir.appendingPathComponent("prepared.json")
        let j: [String: Any] = ["peak": Double(peak), "gain": Double(gain),
                                "source": src.path, "seconds": Double(n) / fmt.sampleRate]
        if let d = try? JSONSerialization.data(withJSONObject: j, options: [.prettyPrinted]) {
            try? d.write(to: side)
        }
        preparedURL = out
        return out
    }

    func loadPlayers() {
        players.removeAll()
        guard let url = preparedURL ?? (try? prepare()) else { return }
        for _ in 0..<3 {
            if let p = try? AVAudioPlayer(contentsOf: url) {
                p.prepareToPlay()
                players.append(p)
            }
        }
        next = 0
    }

    var isReady: Bool { !players.isEmpty }

    /// Play once with a per-move volume (0.55 quiet push .. 1.0 fast flop).
    func play(volume: Float) {
        guard !players.isEmpty else { return }
        let p = players[next % players.count]
        next += 1
        p.stop()
        p.currentTime = 0
        p.volume = max(0.05, min(1, volume))
        p.play()
    }
}

// MARK: - sensor

/// The decision core, separated from I/O so a scripted angle timeline
/// (--simulate) can drive the exact code the lid drives at runtime.
final class Detector {
    var threshold: Double = Threshold.normal.rawValue
    var enabled = true
    private(set) var lastSpeed: Double = 0
    private var hist: [(t: CFTimeInterval, a: Double)] = []
    private var last: Double?
    private var lastPlay: CFTimeInterval = -99
    private var confirm = 0

    func reset() {
        hist.removeAll(); last = nil; confirm = 0; lastSpeed = 0
    }

    /// One sample in; measured speed out when a creak should fire.
    func feed(t now: CFTimeInterval, angle a: Double) -> Double? {
        if let prev = last, abs(a - prev) > Double(kSleepJumpDeg) { reset() }
        hist.append((now, a))
        while let f = hist.first, now - f.t > kWindow * 2 { hist.removeFirst() }

        // travel (did it really move?) uses the long span; speed (how fast?)
        // uses a short span so a quick push is not averaged away — but the short
        // span is still 0.15 s, so one 50 ms 1° sample reads 6.7 °/s, not 20.
        var speed = 0.0
        var travel = 0.0
        if let ref = hist.first(where: { now - $0.t <= kWindow }) ?? hist.first {
            let dt = now - ref.t
            if dt >= kMinWindow { travel = abs(a - ref.a) }
        }
        if let ref = hist.first(where: { now - $0.t <= kSpeedWindow }) ?? hist.first {
            let dt = now - ref.t
            if dt >= kSpeedMin { speed = abs(a - ref.a) / dt }
        }
        lastSpeed = speed
        last = a

        let ok = LidSensor.shouldPlay(speed: speed, travel: travel, threshold: threshold,
                                     enabled: enabled, sinceLast: now - lastPlay)
        confirm = ok ? confirm + 1 : 0
        guard confirm >= kConfirmTicks else { return nil }
        confirm = 0
        lastPlay = now
        return speed
    }
}

final class LidSensor {
    private var manager: IOHIDManager?
    private var device: IOHIDDevice?
    private var timer: DispatchSourceTimer?
    private var lastTime: CFTimeInterval = 0
    let detector = Detector()

    var onCreak: ((Double) -> Void)?               // fired on the main queue
    var onUpdate: ((Double?, Double) -> Void)?     // (angle, speed °/s) on the main queue
    var threshold: Double = Threshold.normal.rawValue
    var enabled = true
    private(set) var lastSpeed: Double = 0
    private(set) var lastAngleValue: Double?

    func start() {
        open()
        let t = DispatchSource.makeTimerSource(queue: DispatchQueue.global(qos: .utility))
        t.schedule(deadline: .now(), repeating: 1.0 / kSampleHz, leeway: .milliseconds(20))
        t.setEventHandler { [weak self] in self?.tick() }
        t.resume()
        timer = t
    }

    func stop() {
        timer?.cancel(); timer = nil
        close()
    }

    private func open() {
        let mgr = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone))
        let matching: [String: Any] = [
            kIOHIDDeviceUsagePageKey: kPage,
            kIOHIDDeviceUsageKey: kUsage,
        ]
        IOHIDManagerSetDeviceMatching(mgr, matching as CFDictionary)
        IOHIDManagerOpen(mgr, IOOptionBits(kIOHIDOptionsTypeNone))
        manager = mgr
        attach()
    }

    private func attach() {
        guard device == nil, let mgr = manager else { return }
        guard let set = IOHIDManagerCopyDevices(mgr) as? Set<IOHIDDevice>, !set.isEmpty else { return }
        for dev in set {
            if IOHIDDeviceOpen(dev, IOOptionBits(kIOHIDOptionsTypeNone)) == kIOReturnSuccess {
                device = dev
                logLine("sensor attached")
                return
            }
        }
    }

    private func close() {
        if let d = device {
            IOHIDDeviceClose(d, IOOptionBits(kIOHIDOptionsTypeNone))
            device = nil
        }
        if let m = manager {
            IOHIDManagerClose(m, IOOptionBits(kIOHIDOptionsTypeNone))
            manager = nil
        }
    }

    private func readAngle() -> Double? {
        if device == nil { attach() }
        guard let d = device else { return nil }
        var buf = [UInt8](repeating: 0, count: 8)
        var len = 8
        let r = buf.withUnsafeMutableBufferPointer { ptr in
            IOHIDDeviceGetReport(d, kIOHIDReportTypeFeature, 1, ptr.baseAddress!, &len)
        }
        guard r == kIOReturnSuccess, len >= 3 else {
            if r != kIOReturnSuccess { close() }          // lid closed: re-enumerate later
            return nil
        }
        return Double(buf[1]) + Double(buf[2]) * 256
    }

    /// Pure decision function — also exercised by --trigger-test.
    /// Three gates: the lid has to travel far enough (kills 1–2° jitter), fast
    /// enough for the chosen sensitivity, and not have creaked in the last 1.4 s.
    static func shouldPlay(speed: Double, travel: Double, threshold: Double,
                           enabled: Bool, sinceLast: TimeInterval) -> Bool {
        guard enabled, sinceLast >= kRetrigger else { return false }
        guard travel >= kMinTravel else { return false }
        return speed >= threshold
    }

    private func tick() {
        let now = CACurrentMediaTime()
        let angle = readAngle()

        if let a = angle {
            lastAngleValue = a
            detector.threshold = threshold
            detector.enabled = enabled
            if let speed = detector.feed(t: now, angle: a) {
                let cb = onCreak
                DispatchQueue.main.async { cb?(speed) }
            }
            lastSpeed = detector.lastSpeed
        } else {
            detector.reset()
            lastSpeed = 0
        }
        lastTime = now
        let cb = onUpdate
        let a2 = lastAngleValue
        let s2 = lastSpeed
        DispatchQueue.main.async { cb?(a2, s2) }
    }
}

// MARK: - login item

enum LoginItem {
    static var plistURL: URL {
        FileManager.default.homeDirectoryForCurrentUser
            .appendingPathComponent("Library/LaunchAgents/\(kBundleID).menubar.plist")
    }
    static var isEnabled: Bool { FileManager.default.fileExists(atPath: plistURL.path) }

    static func setEnabled(_ on: Bool) {
        if on {
            guard let exe = Bundle.main.executablePath else { return }
            let plist: [String: Any] = [
                "Label": "\(kBundleID).menubar",
                "ProgramArguments": [exe],
                "RunAtLoad": true,
                "ProcessType": "Interactive",
            ]
            try? FileManager.default.createDirectory(
                at: plistURL.deletingLastPathComponent(), withIntermediateDirectories: true)
            if let data = try? PropertyListSerialization.data(fromPropertyList: plist,
                                                              format: .xml, options: 0) {
                try? data.write(to: plistURL)
            }
        } else {
            try? FileManager.default.removeItem(at: plistURL)
        }
    }
}

// MARK: - window

// A real window. The first version was a menu-bar-only accessory app, which
// double-clicked into nothing visible — from the outside it looked broken.
final class MainWindowController: NSObject, NSWindowDelegate {
    private(set) var window: NSWindow

    private let angleField = NSTextField(labelWithString: "—")
    private let speedField = NSTextField(labelWithString: "reading lid sensor…")
    private let creaksField = NSTextField(labelWithString: "no creaks yet")
    private let switchControl = NSSwitch()
    private let switchLabel = NSTextField(labelWithString: "Creak sound")
    private let testButton = NSButton(title: "Test creak", target: nil, action: nil)
    private let sensControl: NSSegmentedControl
    private let loginCheck = NSButton(checkboxWithTitle: "Start at login", target: nil, action: nil)
    private let revealButton = NSButton(title: "Sound file", target: nil, action: nil)
    private let footerField = NSTextField(labelWithString: "")

    var onToggle: ((Bool) -> Void)?
    var onTest: (() -> Void)?
    var onSensitivity: ((Double) -> Void)?
    var onLogin: ((Bool) -> Void)?
    var onReveal: (() -> Void)?

    override init() {
        sensControl = NSSegmentedControl(labels: Threshold.allCases.map { $0.label },
                                         trackingMode: .selectOne, target: nil, action: nil)
        window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 480, height: 372),
                          styleMask: [.titled, .closable, .miniaturizable],
                          backing: .buffered, defer: false)
        super.init()
        window.title = "Lid Creak"
        window.isReleasedWhenClosed = false
        window.delegate = self
        build()
        window.center()
    }

    // MARK: build

    private func spacer() -> NSView {
        let v = NSView()
        v.translatesAutoresizingMaskIntoConstraints = false
        v.setContentHuggingPriority(NSLayoutConstraint.Priority(1), for: .horizontal)
        v.widthAnchor.constraint(greaterThanOrEqualToConstant: 8).isActive = true
        return v
    }

    private func row(_ views: [NSView], spacing: CGFloat = 10) -> NSStackView {
        let s = NSStackView(views: views)
        s.orientation = .horizontal
        s.alignment = .centerY
        s.spacing = spacing
        return s
    }

    private func build() {
        guard let content = window.contentView else { return }
        content.wantsLayer = true

        angleField.font = .monospacedDigitSystemFont(ofSize: 52, weight: .semibold)
        angleField.textColor = .labelColor
        angleField.setContentHuggingPriority(.init(999), for: .horizontal)

        speedField.font = .systemFont(ofSize: 13, weight: .regular)
        speedField.textColor = .secondaryLabelColor
        creaksField.font = .systemFont(ofSize: 13, weight: .regular)
        creaksField.textColor = .secondaryLabelColor

        let metrics = NSStackView(views: [speedField, creaksField])
        metrics.orientation = .vertical
        metrics.alignment = .leading
        metrics.spacing = 3

        let header = row([angleField, metrics], spacing: 20)

        switchLabel.font = .systemFont(ofSize: 14, weight: .medium)
        switchControl.target = self
        switchControl.action = #selector(toggled(_:))
        switchControl.controlSize = .large

        testButton.target = self
        testButton.action = #selector(testPressed)
        testButton.bezelStyle = .rounded
        testButton.controlSize = .large
        testButton.keyEquivalent = "t"
        testButton.keyEquivalentModifierMask = []
        testButton.bezelColor = .controlAccentColor
        testButton.contentTintColor = .white

        let toggleRow = row([switchControl, switchLabel, spacer(), testButton])

        let sensLabel = NSTextField(labelWithString: "Sensitivity")
        sensLabel.font = .systemFont(ofSize: 14, weight: .medium)
        sensControl.target = self
        sensControl.action = #selector(sensChanged(_:))
        sensControl.segmentStyle = .rounded
        let sensRow = row([sensLabel, spacer(), sensControl])

        loginCheck.font = .systemFont(ofSize: 13)
        loginCheck.target = self
        loginCheck.action = #selector(loginToggled(_:))
        revealButton.target = self
        revealButton.action = #selector(revealPressed)
        revealButton.bezelStyle = .rounded
        revealButton.controlSize = .small
        revealButton.font = .systemFont(ofSize: 11)
        let bottomRow = row([loginCheck, spacer(), revealButton])

        footerField.font = .systemFont(ofSize: 11)
        footerField.textColor = .tertiaryLabelColor
        footerField.lineBreakMode = .byTruncatingMiddle

        let root = NSStackView(views: [header, toggleRow, sensRow, bottomRow, footerField])
        root.orientation = .vertical
        root.alignment = .leading
        root.spacing = 18
        root.edgeInsets = NSEdgeInsets(top: 24, left: 24, bottom: 20, right: 24)
        root.translatesAutoresizingMaskIntoConstraints = false
        content.addSubview(root)

        NSLayoutConstraint.activate([
            root.leadingAnchor.constraint(equalTo: content.leadingAnchor),
            root.trailingAnchor.constraint(equalTo: content.trailingAnchor),
            root.topAnchor.constraint(equalTo: content.topAnchor),
            root.bottomAnchor.constraint(lessThanOrEqualTo: content.bottomAnchor),
            header.widthAnchor.constraint(equalTo: root.widthAnchor, constant: -48),
            toggleRow.widthAnchor.constraint(equalTo: root.widthAnchor, constant: -48),
            sensRow.widthAnchor.constraint(equalTo: root.widthAnchor, constant: -48),
            bottomRow.widthAnchor.constraint(equalTo: root.widthAnchor, constant: -48),
            footerField.widthAnchor.constraint(equalTo: root.widthAnchor, constant: -48),
        ])

        update(angle: nil, speed: 0, creaks: 0, enabled: true,
               threshold: Threshold.normal.rawValue, soundReady: false, soundPath: nil)

        content.layoutSubtreeIfNeeded()
        let fit = root.fittingSize
        window.setContentSize(NSSize(width: 480, height: max(fit.height, 240)))
    }

    var debugSwitchOn: Bool { switchControl.state == .on }
    var debugFit: String { String(format: "%.0fx%.0f", window.contentView?.fittingSize.width ?? 0, window.contentView?.fittingSize.height ?? 0) }

    // MARK: state

    func update(angle: Double?, speed: Double, creaks: Int, enabled: Bool,
                threshold: Double, soundReady: Bool, soundPath: String?) {
        if let a = angle {
            angleField.stringValue = String(format: "%.0f°", a)
            let moving = speed >= threshold
            speedField.stringValue = moving
                ? String(format: "lid moving %.0f °/s", speed)
                : String(format: "lid %.0f ° · still", a)
        } else {
            angleField.stringValue = "—"
            speedField.stringValue = "lid closed or not readable"
        }
        creaksField.stringValue = creaks == 1 ? "1 creak" : "\(creaks) creaks"
        switchControl.state = enabled ? .on : .off
        switchControl.needsDisplay = true
        switchLabel.textColor = enabled ? .labelColor : .secondaryLabelColor
        testButton.isEnabled = soundReady
        testButton.title = soundReady ? "Test creak" : "No sound file"
        for (i, t) in Threshold.allCases.enumerated() {
            sensControl.setSelected(abs(threshold - t.rawValue) < 0.01, forSegment: i)
        }
        loginCheck.state = LoginItem.isEnabled ? .on : .off
        revealButton.isEnabled = soundPath != nil
        footerField.stringValue = soundReady
            ? "creak: \(soundPath ?? "")\nv\(kVersion)"
            : "creak sound not found — install FreeSlapMac, then reopen\nv\(kVersion)"
        footerField.maximumNumberOfLines = 2
    }

    // MARK: actions

    @objc private func toggled(_ sender: NSSwitch) {
        onToggle?(sender.state == .on)
    }

    @objc private func testPressed() { onTest?() }
    @objc private func revealPressed() { onReveal?() }

    @objc private func loginToggled(_ sender: NSButton) {
        onLogin?(sender.state == .on)
    }

    @objc private func sensChanged(_ sender: NSSegmentedControl) {
        let i = sender.selectedSegment
        guard i >= 0 && i < Threshold.allCases.count else { return }
        onSensitivity?(Threshold.allCases[i].rawValue)
    }

    // MARK: show

    func show() {
        window.collectionBehavior.insert(.moveToActiveSpace)
        NSApp.activate(ignoringOtherApps: true)
        // a background launch can be denied focus; ask the running app directly too
        NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
        window.makeKeyAndOrderFront(nil)
        window.orderFrontRegardless()
        logLine("window shown: visible=\(window.isVisible) "
                + "frame=\(Int(window.frame.width))x\(Int(window.frame.height)) "
                + "policy=\(NSApp.activationPolicy().rawValue)")
        // occlusionState is the honest answer to "can the user actually see it?"
        DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in
            guard let self else { return }
            let w = self.window
            let occ = w.occlusionState.contains(.visible) ? "visible" : "NOT visible"
            let scr = w.screen.map { "\(Int($0.frame.width))x\(Int($0.frame.height))" } ?? "no screen"
            if !w.isOnActiveSpace || !w.occlusionState.contains(.visible) {
                w.collectionBehavior.insert(.moveToActiveSpace)
                w.orderFrontRegardless()
                NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
            }
            logLine("window states: occlusion=\(occ) onScreen=\(w.isOnActiveSpace) "
                    + "key=\(w.isKeyWindow) screen=\(scr) origin=\(Int(w.frame.origin.x)),\(Int(w.frame.origin.y))")
        }
    }

    func windowShouldClose(_ sender: NSWindow) -> Bool {
        // closing hides the window; the app keeps creaking from the menu bar
        sender.orderOut(nil)
        logLine("window closed (still running in menu bar)")
        return false
    }
}

// MARK: - app

final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
    private var statusItem: NSStatusItem!
    private var windowController: MainWindowController!
    private let sensor = LidSensor()
    private let sound = CreakSound()
    private var creakCount = 0

    private var enabled: Bool {
        get { UserDefaults.standard.object(forKey: "enabled") as? Bool ?? true }
        set { UserDefaults.standard.set(newValue, forKey: "enabled") }
    }
    private var threshold: Double {
        get { UserDefaults.standard.object(forKey: "threshold") as? Double ?? Threshold.normal.rawValue }
        set { UserDefaults.standard.set(newValue, forKey: "threshold") }
    }

    func applicationDidFinishLaunching(_ n: Notification) {
        NSApp.setActivationPolicy(.regular)
        buildMainMenu()

        windowController = MainWindowController()
        windowController.onToggle = { [weak self] on in self?.setEnabled(on) }
        windowController.onTest = { [weak self] in self?.fireCreak(speed: 40) }
        windowController.onSensitivity = { [weak self] v in self?.setThresholdValue(v) }
        windowController.onLogin = { [weak self] on in
            LoginItem.setEnabled(on)
            logLine("start at login=\(LoginItem.isEnabled)")
            self?.refreshWindow()
        }
        windowController.onReveal = { [weak self] in self?.revealSound() }

        statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
        if let button = statusItem.button {
            if let img = menuIcon(active: true) {
                button.image = img
                button.image?.isTemplate = true
            } else {
                button.title = "Lid"          // never leave an invisible item in the bar
            }
            button.toolTip = "Lid Creak"
        }
        let menu = NSMenu()
        menu.delegate = self
        statusItem.menu = menu
        rebuild(menu)

        do {
            try sound.prepare()
        } catch {
            logLine("sound prepare failed: \(error)")
        }
        sound.loadPlayers()
        if !sound.isReady { logLine("no creak sound available — is FreeSlapMac installed?") }

        sensor.threshold = threshold
        sensor.enabled = enabled
        sensor.onCreak = { [weak self] speed in self?.fireCreak(speed: speed) }
        sensor.onUpdate = { [weak self] _, _ in
            self?.refreshLiveItem()
            self?.refreshWindow()
        }
        sensor.start()
        logLine("started v\(kVersion) (enabled=\(enabled) threshold=\(Int(threshold)) "
                + "sound=\(sound.sourceURL?.path ?? "none"))")
        refreshWindow()
        windowController.show()
        DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
            guard let self else { return }
            let f = self.statusItem.button?.window?.frame ?? .zero
            logLine("menu bar item: icon=\(self.statusItem.button?.image != nil ? "yes" : "text") "
                    + "frame=\(Int(f.width))x\(Int(f.height)) at x=\(Int(f.origin.x)) "
                    + "visible=\(self.statusItem.isVisible)")
        }
        let out = systemAudio()
        if out.volume == 0 || out.muted {
            logLine("WARNING: system output volume=\(out.volume) muted=\(out.muted) — creaks will be inaudible")
        }
        NotificationCenter.default.addObserver(
            forName: NSWorkspace.willSleepNotification, object: nil, queue: .main
        ) { [weak self] _ in
            self?.sensor.stop()
            logLine("sleep: sensor closed")
        }
        NotificationCenter.default.addObserver(
            forName: NSWorkspace.didWakeNotification, object: nil, queue: .main
        ) { [weak self] _ in
            self?.sensor.start()
            logLine("wake: sensor reopened")
        }
    }

    func applicationWillTerminate(_ n: Notification) {
        sensor.stop()
        logLine("quit")
    }

    // double-clicking the app while it is already running must re-open the window
    func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
        windowController?.show()
        logLine("reopen: window brought back (hadVisible=\(flag))")
        return true
    }

    func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
        false
    }

    private func buildMainMenu() {
        let main = NSMenu()
        let appItem = NSMenuItem()
        main.addItem(appItem)
        let appMenu = NSMenu()
        let about = NSMenuItem(title: "About Lid Creak", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "")
        appMenu.addItem(about)
        appMenu.addItem(.separator())
        let hide = NSMenuItem(title: "Hide Lid Creak", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h")
        appMenu.addItem(hide)
        appMenu.addItem(.separator())
        let quitItem = NSMenuItem(title: "Quit Lid Creak", action: #selector(quit), keyEquivalent: "q")
        quitItem.target = self
        appMenu.addItem(quitItem)
        appItem.submenu = appMenu

        let windowItem = NSMenuItem()
        main.addItem(windowItem)
        let windowMenu = NSMenu(title: "Window")
        let min = NSMenuItem(title: "Minimize", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m")
        windowMenu.addItem(min)
        windowItem.submenu = windowMenu
        NSApp.mainMenu = main
        NSApp.windowsMenu = windowMenu
    }

    private func refreshWindow() {
        guard windowController != nil else { return }
        windowController.update(angle: sensor.lastAngleValue,
                                speed: sensor.lastSpeed,
                                creaks: creakCount,
                                enabled: enabled,
                                threshold: threshold,
                                soundReady: sound.isReady,
                                soundPath: sound.sourceURL?.path)
    }

    private func setEnabled(_ on: Bool) {
        enabled = on
        sensor.enabled = on
        statusItem.button?.image = menuIcon(active: on)
        logLine("enabled=\(on)")
        refreshWindow()
    }

    private func setThresholdValue(_ v: Double) {
        threshold = v
        sensor.threshold = v
        logLine("threshold=\(Int(v)) °/s")
        refreshWindow()
    }

    // MARK: menu

    private func menuIcon(active: Bool) -> NSImage? {
        let name = active ? "speaker.wave.2" : "speaker.slash"
        let img = NSImage(systemSymbolName: name, accessibilityDescription: "Lid Creak")
        img?.isTemplate = true
        return img
    }

    private func rebuild(_ menu: NSMenu) {
        menu.removeAllItems()

        let open = NSMenuItem(title: "Open Lid Creak", action: #selector(openWindow), keyEquivalent: "o")
        open.target = self
        menu.addItem(open)
        menu.addItem(.separator())

        let toggle = NSMenuItem(title: "Creak sound", action: #selector(toggleEnabled), keyEquivalent: "")
        toggle.target = self
        toggle.state = enabled ? .on : .off
        menu.addItem(toggle)

        let test = NSMenuItem(title: sound.isReady ? "Test creak" : "Test creak (no sound file)",
                              action: #selector(testCreak), keyEquivalent: "")
        test.target = self
        test.isEnabled = sound.isReady
        menu.addItem(test)
        menu.addItem(.separator())

        let live = NSMenuItem(title: liveTitle(), action: nil, keyEquivalent: "")
        live.isEnabled = false
        live.tag = 100
        menu.addItem(live)

        let sens = NSMenuItem(title: "Sensitivity", action: nil, keyEquivalent: "")
        let sub = NSMenu()
        for t in Threshold.allCases {
            let it = NSMenuItem(title: t.label, action: #selector(setThreshold(_:)), keyEquivalent: "")
            it.target = self
            it.representedObject = t.rawValue
            it.state = (abs(threshold - t.rawValue) < 0.01) ? .on : .off
            sub.addItem(it)
        }
        sens.submenu = sub
        menu.addItem(sens)
        menu.addItem(.separator())

        let login = NSMenuItem(title: "Start at login", action: #selector(toggleLogin), keyEquivalent: "")
        login.target = self
        login.state = LoginItem.isEnabled ? .on : .off
        menu.addItem(login)

        let reveal = NSMenuItem(title: "Reveal sound file", action: #selector(revealSound), keyEquivalent: "")
        reveal.target = self
        reveal.isEnabled = sound.preparedURL != nil
        menu.addItem(reveal)

        let logItem = NSMenuItem(title: "Open log", action: #selector(openLog), keyEquivalent: "")
        logItem.target = self
        menu.addItem(logItem)
        menu.addItem(.separator())

        let quit = NSMenuItem(title: "Quit Lid Creak", action: #selector(quit), keyEquivalent: "q")
        quit.target = self
        menu.addItem(quit)
    }

    func menuNeedsUpdate(_ menu: NSMenu) {
        rebuild(menu)
    }

    private func liveTitle() -> String {
        guard sensor.lastAngleValue != nil else { return "Lid: closed / not found" }
        let a = sensor.lastAngleValue!
        let s = sensor.lastSpeed
        let state = s >= threshold ? "moving \(Int(s.rounded())) °/s" : "still"
        return String(format: "Lid: %.0f°  ·  %@  ·  %d creaks", a, state, creakCount)
    }

    // MARK: actions

    @objc private func toggleEnabled() {
        setEnabled(!enabled)
        statusItem.menu?.item(withTag: 100)?.title = liveTitle()
    }

    @objc private func testCreak() {
        fireCreak(speed: 40)
    }

    @objc private func setThreshold(_ item: NSMenuItem) {
        guard let v = item.representedObject as? Double else { return }
        setThresholdValue(v)
    }

    @objc private func toggleLogin() {
        LoginItem.setEnabled(!LoginItem.isEnabled)
        logLine("start at login=\(LoginItem.isEnabled)")
        refreshWindow()
    }

    @objc private func openWindow() {
        windowController.show()
    }

    @objc private func revealSound() {
        if let u = sound.preparedURL { NSWorkspace.shared.activateFileViewerSelecting([u]) }
    }

    @objc private func openLog() {
        NSWorkspace.shared.open(logURL)
    }

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

    // called from the sensor queue's main-hop
    func fireCreak(speed: Double) {
        guard enabled else { return }
        let vol = Float(min(1.0, 0.55 + speed / 120.0))
        sound.play(volume: vol)
        creakCount += 1
        logLine(String(format: "creak vol=%.2f speed=%.0f deg/s", vol, speed))
        if let m = statusItem.menu?.item(withTag: 100) { m.title = liveTitle() }
        refreshWindow()
    }

    func refreshLiveItem() {
        if let m = statusItem.menu?.item(withTag: 100) { m.title = liveTitle() }
    }
}

// MARK: - audio state

func systemAudio() -> (volume: Int, muted: Bool) {
    var vol = -1, muted = false
    let script = NSAppleScript(source: "output volume of (get volume settings)")
    var err: NSDictionary?
    if let r = script?.executeAndReturnError(&err) { vol = Int(r.int32Value) }
    let m = NSAppleScript(source: "output muted of (get volume settings)")
    if let r = m?.executeAndReturnError(&err) { muted = r.booleanValue }
    return (vol, muted)
}

// MARK: - entry

let args = CommandLine.arguments

if let li = args.firstIndex(of: "--login-item"), let v = args[safe: li + 1] {
    LoginItem.setEnabled(v.lowercased() == "on")
    print("start at login: \(LoginItem.isEnabled ? "on" : "off") (\(LoginItem.plistURL.path))")
    exit(LoginItem.isEnabled == (v.lowercased() == "on") ? 0 : 1)
}

if args.contains("--trigger-test") {
    var fails = 0
    func expect(_ label: String, _ got: Bool, _ want: Bool) {
        let ok = got == want
        if !ok { fails += 1 }
        print(String(format: "%-52@ %@", label as NSString, ok ? "PASS" : "FAIL (got \(got))"))
    }
    let low = Threshold.low.rawValue, norm = Threshold.normal.rawValue, high = Threshold.high.rawValue
    // the exact complaint: a 1° nudge must never creak, at any sensitivity
    expect("1° travel @ 20 °/s, High (the old bug)      -> no",
           LidSensor.shouldPlay(speed: 20, travel: 1, threshold: high, enabled: true, sinceLast: 5), false)
    expect("2.9° travel @ 80 °/s, High                  -> no",
           LidSensor.shouldPlay(speed: 80, travel: 2.9, threshold: high, enabled: true, sinceLast: 5), false)
    expect("3.5° travel @ 45 °/s, Low                   -> yes",
           LidSensor.shouldPlay(speed: 45, travel: 3.5, threshold: low, enabled: true, sinceLast: 5), true)
    expect("3.5° travel @ 20 °/s, Normal (too slow)     -> no",
           LidSensor.shouldPlay(speed: 20, travel: 3.5, threshold: norm, enabled: true, sinceLast: 5), false)
    expect("3.5° travel @ 25 °/s, Normal                -> yes",
           LidSensor.shouldPlay(speed: 25, travel: 3.5, threshold: norm, enabled: true, sinceLast: 5), true)
    expect("12° travel @ 80 °/s, Low                    -> yes",
           LidSensor.shouldPlay(speed: 80, travel: 12, threshold: low, enabled: true, sinceLast: 5), true)
    expect("8° travel @ 12 °/s, High (gentle, too slow) -> no",
           LidSensor.shouldPlay(speed: 12, travel: 8, threshold: high, enabled: true, sinceLast: 5), false)
    expect("8° travel @ 15 °/s, High                    -> yes",
           LidSensor.shouldPlay(speed: 15, travel: 8, threshold: high, enabled: true, sinceLast: 5), true)
    expect("3.5° travel @ 45 °/s, disabled              -> no",
           LidSensor.shouldPlay(speed: 45, travel: 3.5, threshold: low, enabled: false, sinceLast: 5), false)
    expect("3.5° travel @ 45 °/s, 0.5 s since last      -> no",
           LidSensor.shouldPlay(speed: 45, travel: 3.5, threshold: low, enabled: true, sinceLast: 0.5), false)
    print(fails == 0 ? "10/10 PASS" : "\(fails) FAILED")
    exit(fails == 0 ? 0 : 1)
}

if args.contains("--simulate") {
    // Drive the real Detector with scripted lid timelines: the same code path
    // the IOHID samples go through, without needing a human to move the lid.
    func timeline(_ segs: [(Double, Double, Double)]) -> [(Double, Double)] {
        var out: [(Double, Double)] = []
        var t = 0.0
        let step = 1.0 / kSampleHz
        for (secs, from, to) in segs {
            var elapsed = 0.0
            while elapsed <= secs + 1e-9 {
                let f = secs <= 0 ? 1 : min(1, elapsed / secs)
                out.append((t, from + (to - from) * f))
                t += step
                elapsed += step
            }
        }
        return out
    }

    func run(_ label: String, threshold: Threshold, _ segs: [(Double, Double, Double)],
             expectFire: Bool) -> Bool {
        let d = Detector()
        d.threshold = threshold.rawValue
        d.enabled = true
        var fires = 0
        var peak = 0.0
        for (t, a) in timeline(segs) {
            if let sp = d.feed(t: t, angle: a) { fires += 1; peak = max(peak, sp) }
            peak = max(peak, d.lastSpeed)
        }
        let ok = (fires > 0) == expectFire
        print(String(format: "%-44@ fires=%d peak=%.1f °/s  %@",
                     label as NSString, fires, peak, ok ? "PASS" : "FAIL"))
        return ok
    }

    var ok = true
    ok = run("1° blip then held still", threshold: .high,
             [(2, 115, 115), (0, 115, 116), (1.5, 116, 116)], expectFire: false) && ok
    ok = run("1° every 300 ms (slow creep)", threshold: .high,
             [(1, 115, 115), (0.3, 115, 116), (0.3, 116, 117), (0.3, 117, 118),
              (1.5, 118, 118)], expectFire: false) && ok
    ok = run("12 °/s opening, High (needs 14)", threshold: .high,
             [(1, 115, 115), (1.5, 115, 133), (1, 133, 133)], expectFire: false) && ok
    ok = run("3° tap in 150 ms, High (a tap, not a slide)", threshold: .high,
             [(1, 115, 115), (0.15, 115, 118), (1, 118, 118)], expectFire: false) && ok
    ok = run("16 °/s sustained 2 s, High (gentle slide)", threshold: .high,
             [(1, 115, 115), (2, 115, 147), (1, 147, 147)], expectFire: true) && ok
    ok = run("22 °/s opening, Normal", threshold: .normal,
             [(1, 115, 115), (1.5, 115, 148), (1, 148, 148)], expectFire: true) && ok
    ok = run("80 °/s slam, Low", threshold: .low,
             [(1, 115, 115), (0.5, 115, 155), (1, 155, 155)], expectFire: true) && ok
    ok = run("20 °/s opening, Low (needs 35)", threshold: .low,
             [(1, 115, 115), (1.5, 115, 145), (1, 145, 145)], expectFire: false) && ok
    print(ok ? "SIMULATE PASS" : "SIMULATE FAIL")
    exit(ok ? 0 : 1)
}

if let ri = args.firstIndex(of: "--record") {
    // raw sensor timeline, for measuring real noise and real lid motion
    let secs = Double(args[safe: ri + 1] ?? "10") ?? 10
    let s = LidSensor()
    s.start()
    let t0 = CACurrentMediaTime()
    var rows: [String] = []
    while CACurrentMediaTime() - t0 < secs {
        RunLoop.current.run(until: Date().addingTimeInterval(1.0 / kSampleHz))
        let t = CACurrentMediaTime() - t0
        rows.append(String(format: "%.3f %@", t,
                           s.lastAngleValue.map { String(format: "%.0f", $0) } ?? "nil"))
    }
    s.stop()
    print(rows.joined(separator: "\n"))
    exit(0)
}

if let oi = args.firstIndex(of: "--once") {
    let n = Int(args[safe: oi + 1] ?? "5") ?? 5
    let s = LidSensor()
    s.start()
    var seen: [Double] = []
    let deadline = Date().addingTimeInterval(3)
    while seen.count < n && Date() < deadline {
        RunLoop.current.run(until: Date().addingTimeInterval(0.1))
        if let a = s.lastAngleValue, seen.last != a { seen.append(a) }
    }
    s.stop()
    print("samples: " + seen.map { String(format: "%.0f", $0) }.joined(separator: " "))
    exit(seen.isEmpty ? 2 : 0)
}

if args.contains("--selftest") {
    setvbuf(stdout, nil, _IONBF, 0)
    func note(_ s: String) { print("  · \(s)"); fflush(stdout) }
    print("LidCreak \(kVersion)")
    let sound = CreakSound()
    sound.locate()
    print("sound source: \(sound.sourceURL?.path ?? "NOT FOUND (install FreeSlapMac)")")
    note("preparing sound…")
    do {
        if let u = try sound.prepare() {
            let a = try FileManager.default.attributesOfItem(atPath: u.path)
            print("prepared: \(u.path) (\(a[.size] ?? 0) bytes, peak \(String(format: "%.3f", sound.peakBefore)) × gain \(String(format: "%.2f", sound.gainApplied)))")
        }
    } catch {
        print("prepare failed: \(error)")
    }
    note("loading players…")
    sound.loadPlayers()
    print("players: \(sound.isReady ? "ready" : "none")")
    let au = systemAudio()
    print("system output: volume=\(au.volume) muted=\(au.muted)")
    note("reading lid sensor…")
    let s = LidSensor()
    s.start()
    var angle: Double?
    let deadline = Date().addingTimeInterval(3)
    while angle == nil && Date() < deadline {
        RunLoop.current.run(until: Date().addingTimeInterval(0.1))
        angle = s.lastAngleValue
    }
    s.stop()
    print("lid angle: \(angle.map { String(format: "%.0f°", $0) } ?? "unavailable (lid closed?)")")
    var ok = angle != nil && sound.isReady
    if sound.isReady {
        note("playing…")
        sound.play(volume: 1.0)
        print("played test creak")
        let until = Date().addingTimeInterval(2.0)
        while Date() < until { RunLoop.current.run(until: Date().addingTimeInterval(0.05)) }
    }
    if au.volume == 0 || au.muted {
        print("WARNING: output is muted — nothing will be audible")
        ok = false
    }
    print(ok ? "SELFTEST PASS" : "SELFTEST FAIL")
    exit(ok ? 0 : 2)
}

if let si = args.firstIndex(of: "--snapshot") {
    let out = args[safe: si + 1] ?? "/tmp/lidcreak-window.png"
    let nsapp = NSApplication.shared
    nsapp.setActivationPolicy(.prohibited)
    let wc = MainWindowController()
    wc.update(angle: 102, speed: 34, creaks: 12, enabled: !args.contains("--off"),
              threshold: Threshold.normal.rawValue, soundReady: true,
              soundPath: "/Applications/FreeSlapMac.app/Contents/Resources/Sounds/creaking.mp3")
    wc.window.orderFront(nil)
    wc.window.contentView?.layoutSubtreeIfNeeded()
    RunLoop.current.run(until: Date().addingTimeInterval(0.4))
    guard let view = wc.window.contentView else { exit(3) }
    view.layoutSubtreeIfNeeded()
    let bounds = view.bounds
    guard let rep = view.bitmapImageRepForCachingDisplay(in: bounds) else {
        print("snapshot failed: no bitmap rep"); exit(4)
    }
    view.cacheDisplay(in: bounds, to: rep)
    guard let png = rep.representation(using: .png, properties: [:]) else {
        print("snapshot failed: no png"); exit(5)
    }
    do { try png.write(to: URL(fileURLWithPath: out)) }
    catch { print("snapshot write failed: \(error)"); exit(6) }
    print("snapshot: switch=\(wc.debugSwitchOn) fit=\(wc.debugFit) windowKey=\(wc.window.isKeyWindow) \(out) pixels=\(rep.pixelsWide)x\(rep.pixelsHigh) "
          + "content=\(Int(bounds.width))x\(Int(bounds.height))")
    exit(0)
}

let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
app.run()

extension Array {
    subscript(safe i: Int) -> Element? { indices.contains(i) ? self[i] : nil }
}
