import Foundation
import AppKit
import Darwin
import os

private let log = Logger(subsystem: "com.slicc.sliccstart", category: "SliccProcess")

enum AppStartBlocker: Equatable {
    case needsPermission
    case needsDebugBuild
}

enum AppRuntimeState: Equatable {
    case notRunning
    case runningWithoutDebug
    case runningWithDebug(cdpPort: UInt16?)
    case startFailed(message: String)
    case cannotStart(AppStartBlocker)

    var isRunning: Bool {
        switch self {
        case .runningWithoutDebug, .runningWithDebug:
            return true
        case .notRunning, .startFailed, .cannotStart:
            return false
        }
    }

    static func resolve(
        targetType: AppTargetType,
        debugSupport: ElectronDebugSupport = .supported,
        hasAppManagementPermission: Bool = true,
        debugPort: UInt16? = nil,
        launchFailure: String? = nil,
        appIsRunning: Bool = false
    ) -> AppRuntimeState {
        if targetType == .electronApp {
            if !hasAppManagementPermission {
                return .cannotStart(.needsPermission)
            }
            if debugSupport == .disabled {
                return .cannotStart(.needsDebugBuild)
            }
        }

        if debugPort != nil {
            return .runningWithDebug(cdpPort: debugPort)
        }
        if targetType == .electronApp && appIsRunning {
            return .runningWithoutDebug
        }
        if let launchFailure {
            return .startFailed(message: launchFailure)
        }
        return .notRunning
    }
}

@Observable
final class SliccProcess {
    struct LaunchConfiguration: Equatable {
        let executablePath: String
        let arguments: [String]
        let logLabel: String
    }

    private struct LaunchRecord {
        let process: Process
        let targetType: AppTargetType
        let launchedAppPaths: [String]
        let cdpPort: UInt16
        let servePort: UInt16
        let electronAppPath: String?
        let targetName: String
        let startedAt: Date
        var observedAppPID: pid_t?
        var staticRoot: String?
    }

    /// SLICC helper/server processes keyed by AppTarget.id.
    private var launchRecords: [String: LaunchRecord] = [:]
    private var startFailures: [String: String] = [:]
    private var intentionallyStoppingTargets: Set<String> = []

    /// Optional UI overlay root applied to every spawn. Set by the Phase-C
    /// webapp-only update path so newly-launched slicc-servers serve the
    /// downloaded `dist/ui` instead of the bundle's copy.
    var uiOverlayRoot: String?

    /// Set by the AppUpdater install flow so `applicationWillTerminate`
    /// takes the detach path (browsers survive, records persisted) instead
    /// of the legacy stopAll() path.
    var isPreparingForUpdate = false

    /// One-shot latch tracking whether `detachAll()` already ran. The
    /// full-app update path calls it from `onBeginUpdate` and the
    /// delegate calls it again from `applicationWillTerminate`; without
    /// this guard the second call would persist an empty snapshot
    /// (because the first call cleared `launchRecords`) and erase the
    /// reattach data the next launch needs.
    private var hasDetached = false

    let recordStore: LaunchRecordStore
    let cdpLiveProbe: CDPLiveProbe

    init(
        recordStore: LaunchRecordStore = LaunchRecordStore(),
        cdpLiveProbe: CDPLiveProbe = .default
    ) {
        self.recordStore = recordStore
        self.cdpLiveProbe = cdpLiveProbe
    }

    var resolvedSliccDir: String { sliccDir }
    private var sliccDir: String {
        // Priority 1: SLICC_DIR env var (development override)
        if let env = ProcessInfo.processInfo.environment["SLICC_DIR"], !env.isEmpty {
            log.info("sliccDir: using SLICC_DIR env = \(env, privacy: .public)")
            return env
        }
        // Priority 2: Bundled inside the .app (production)
        if let bundled = SliccBootstrapper.bundledSliccDir {
            log.info("sliccDir: using bundled = \(bundled, privacy: .public)")
            return bundled
        }
        // Priority 3: Walk up from bundle location (development — running from source tree)
        let parentDir = (Bundle.main.bundlePath as NSString).deletingLastPathComponent
        var dir = parentDir
        for _ in 0..<5 {
            if FileManager.default.fileExists(atPath: dir + "/package.json") &&
               FileManager.default.fileExists(atPath: dir + "/packages/node-server/src/index.ts") {
                log.info("sliccDir: found source tree at \(dir, privacy: .public)")
                return dir
            }
            dir = (dir as NSString).deletingLastPathComponent
        }
        log.warning("sliccDir: falling back to default \(SliccBootstrapper.defaultSliccDir)")
        return SliccBootstrapper.defaultSliccDir
    }

    /// Port allocation: browser gets 5710, electron apps get 5711, 5712, ...
    /// CDP ports: browser gets 9222, electron apps get 9223, 9224, ...
    private static let browserPort: UInt16 = 5710
    private static let browserCdpPort: UInt16 = 9222
    private static let electronBasePort: UInt16 = 5711
    private static let electronBaseCdpPort: UInt16 = 9223
    private static let electronLaunchStaleTimeout: TimeInterval = 30

    func isRunning(_ target: AppTarget) -> Bool {
        runtimeState(for: target).isRunning
    }

    func runtimeState(
        for target: AppTarget,
        hasAppManagementPermission: Bool = true
    ) -> AppRuntimeState {
        let debugPort = activeDebugPort(for: target)
        let appIsRunning = target.type == .electronApp && isElectronAppRunning(target)
        return AppRuntimeState.resolve(
            targetType: target.type,
            debugSupport: target.debugSupport,
            hasAppManagementPermission: hasAppManagementPermission,
            debugPort: debugPort,
            launchFailure: startFailures[target.id],
            appIsRunning: appIsRunning
        )
    }

    func refreshRuntimeStates(for targets: [AppTarget]) {
        for target in targets {
            refreshRuntimeState(for: target)
        }
    }

    // MARK: - Browser mode

    func launchStandalone(_ browser: AppTarget) throws {
        refreshRuntimeState(for: browser)
        if isRunning(browser) {
            log.info("launchStandalone: \(browser.name) already running")
            return
        }
        startFailures.removeValue(forKey: browser.id)
        guard !Self.isPortInUse(Self.browserPort) else { throw LaunchError.portInUse(Self.browserPort) }
        log.info("launchStandalone: \(browser.name, privacy: .public) on port \(Self.browserPort)")
        do {
            try spawn(
                target: browser,
                extraArgs: Self.applyOverlay(["--cdp-port=\(Self.browserCdpPort)"], overlay: uiOverlayRoot),
                env: [
                    "CHROME_PATH": browser.executablePath,
                    "PORT": "\(Self.browserPort)",
                ],
                cdpPort: Self.browserCdpPort,
                servePort: Self.browserPort,
                electronAppPath: nil
            )
        } catch {
            recordStartFailure(for: browser, message: error.localizedDescription)
            throw error
        }
    }

    // MARK: - Electron mode (each app gets its own port)

    func launchWithElectronApp(_ app: AppTarget, forceRestartExistingApp: Bool = false) throws {
        refreshRuntimeState(for: app)
        if isRunning(app) {
            if case .runningWithDebug = runtimeState(for: app) {
                log.info("launchWithElectronApp: \(app.name) already running with SLICC")
                return
            }
        }
        if forceRestartExistingApp {
            terminateElectronApplications(atAppPaths: Self.relatedAppPaths(for: app))
        }
        startFailures.removeValue(forKey: app.id)
        let (port, cdpPort) = nextElectronPorts()
        guard !Self.isPortInUse(port) else { throw LaunchError.portInUse(port) }
        log.info("launchWithElectronApp: \(app.name, privacy: .public) on port \(port), cdp \(cdpPort)")
        do {
            try spawn(
                target: app,
                extraArgs: Self.applyOverlay(
                    [
                        "--electron-app=\(app.path)",
                        "--kill",
                        "--cdp-port=\(cdpPort)",
                    ],
                    overlay: uiOverlayRoot
                ),
                env: ["PORT": "\(port)"],
                cdpPort: cdpPort,
                servePort: port,
                electronAppPath: app.path
            )
        } catch {
            recordStartFailure(for: app, message: error.localizedDescription)
            throw error
        }
    }

    private static func applyOverlay(_ args: [String], overlay: String?) -> [String] {
        guard let overlay, !overlay.isEmpty else { return args }
        return args + ["--static-root=\(overlay)"]
    }

    /// Find the next available port pair for an Electron app.
    private func nextElectronPorts() -> (port: UInt16, cdpPort: UInt16) {
        let electronCount = UInt16(launchRecords.count) // offset from base
        for i: UInt16 in 0...20 {
            let port = Self.electronBasePort + electronCount + i
            let cdpPort = Self.electronBaseCdpPort + electronCount + i
            if !Self.isPortInUse(port) && !Self.isPortInUse(cdpPort) {
                return (port, cdpPort)
            }
        }
        // Fallback — try anyway
        let port = Self.electronBasePort + electronCount
        return (port, Self.electronBaseCdpPort + electronCount)
    }

    // MARK: - Chrome Web Store

    static let chromeWebStoreURL = "https://chromewebstore.google.com/detail/slicc/akjjllgokmbgpbdbmafpiefnhidlmbgf"

    func openChromeWebStore() {
        guard let url = URL(string: Self.chromeWebStoreURL) else { return }
        if let chromeURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.google.Chrome") {
            log.info("openChromeWebStore: opening in Chrome")
            NSWorkspace.shared.open([url], withApplicationAt: chromeURL, configuration: NSWorkspace.OpenConfiguration())
        } else {
            log.warning("openChromeWebStore: Chrome not found, opening in default browser")
            NSWorkspace.shared.open(url)
        }
    }

    // MARK: - Lifecycle

    func stop(_ target: AppTarget) {
        log.info("stop: \(target.name)")
        stopLaunchRecord(id: target.id, terminateApps: true)
        startFailures.removeValue(forKey: target.id)
    }

    func stopAll() {
        log.info("stopAll: terminating \(self.launchRecords.count) processes")
        for id in Array(launchRecords.keys) {
            stopLaunchRecord(id: id, terminateApps: true)
        }
        startFailures.removeAll()
    }

    /// Live respawn every running slicc-server with the current
    /// `uiOverlayRoot`. Used by the webapp-only update path: the caller
    /// updates the overlay pointer, calls this, and the browsers see the
    /// new UI on next page load (or on slicc-server reconnect). Browsers
    /// and Electron apps are NOT touched.
    func respawnAllForOverlayChange() async {
        let snapshot = launchRecords.map { id, record -> (String, AppTarget?, PersistedLaunchRecord) in
            let persisted = PersistedLaunchRecord(
                targetId: id,
                targetName: record.targetName,
                targetType: record.targetType,
                electronAppPath: record.electronAppPath,
                servePort: record.servePort,
                cdpPort: record.cdpPort,
                staticRoot: uiOverlayRoot
            )
            return (id, nil as AppTarget?, persisted)
        }
        guard !snapshot.isEmpty else { return }
        // Detach each existing server (SIGUSR1) so browsers/Electron stay alive.
        for id in Array(launchRecords.keys) {
            detachLaunchRecord(id: id)
        }
        // Re-spawn in serve-only mode against the same CDP port.
        for (_, _, persisted) in snapshot {
            guard await cdpLiveProbe.isAlive(cdpPort: persisted.cdpPort) else {
                log.info("respawnAllForOverlayChange: skipping \(persisted.targetName, privacy: .public) — CDP \(persisted.cdpPort) is gone")
                continue
            }
            // Reconstruct a minimal AppTarget surface from the snapshot.
            let target = AppTarget(
                id: persisted.targetId,
                name: persisted.targetName,
                path: persisted.electronAppPath ?? persisted.targetId,
                executablePath: persisted.electronAppPath.map { "\($0)/Contents/MacOS/\(persisted.targetName)" } ?? "",
                type: persisted.targetType,
                icon: NSImage(size: NSSize(width: 1, height: 1)),
                debugSupport: .supported,
                isDebugBuild: false,
                originalAppPath: nil
            )
            do {
                try reattach(target: target, record: persisted)
            } catch {
                log.error("respawnAllForOverlayChange: failed for \(persisted.targetName, privacy: .public): \(error.localizedDescription, privacy: .public)")
            }
        }
    }

    // MARK: - Detach / reattach (smooth-upgrade path)

    /// Snapshot every live launch record to disk and shut every slicc-server
    /// child down in *detach* mode (SIGUSR1) so the browsers/Electron apps
    /// keep running. Called immediately before AppUpdater swaps the .app
    /// bundle and relaunches Sliccstart.
    @discardableResult
    /// Test-only seam — inserts a synthetic `LaunchRecord` into the
    /// in-memory map so `detachAll()` has something to snapshot in unit
    /// tests. Underscored to make the intent obvious at call sites.
    func _testing_seedLaunchRecord(
        id: String,
        process: Process,
        targetType: AppTargetType,
        launchedAppPaths: [String] = [],
        cdpPort: UInt16,
        servePort: UInt16,
        electronAppPath: String? = nil,
        targetName: String,
        staticRoot: String? = nil
    ) {
        launchRecords[id] = LaunchRecord(
            process: process,
            targetType: targetType,
            launchedAppPaths: launchedAppPaths,
            cdpPort: cdpPort,
            servePort: servePort,
            electronAppPath: electronAppPath,
            targetName: targetName,
            startedAt: Date(),
            observedAppPID: nil,
            staticRoot: staticRoot
        )
    }

    @discardableResult
    func detachAll() -> [PersistedLaunchRecord] {
        // Idempotency latch — `applicationWillTerminate` re-invokes this
        // after `onBeginUpdate` already did, and the second pass would
        // otherwise overwrite the persisted JSON with an empty array
        // (because the first pass cleared `launchRecords`).
        if hasDetached {
            log.info("detachAll: already detached; returning persisted snapshot")
            return recordStore.load()
        }
        hasDetached = true
        let snapshot = launchRecords.compactMap { id, record -> PersistedLaunchRecord? in
            guard record.process.isRunning else { return nil }
            return PersistedLaunchRecord(
                targetId: id,
                targetName: record.targetName,
                targetType: record.targetType,
                electronAppPath: record.electronAppPath,
                servePort: record.servePort,
                cdpPort: record.cdpPort,
                staticRoot: record.staticRoot
            )
        }
        do {
            try recordStore.save(snapshot)
        } catch {
            log.error("detachAll: failed to persist records: \(error.localizedDescription, privacy: .public)")
        }

        log.info("detachAll: detaching \(self.launchRecords.count) processes")
        for id in Array(launchRecords.keys) {
            detachLaunchRecord(id: id)
        }
        startFailures.removeAll()
        return snapshot
    }

    /// Re-spawn slicc-server children for every persisted record whose CDP
    /// port still answers. Records whose browser died during the update are
    /// dropped silently. Returns the targetIds that were reattached so the
    /// caller can decide what to refresh in the UI.
    @discardableResult
    func reattachPersistedRecords(targets: [AppTarget]) async -> [String] {
        let records = recordStore.load()
        guard !records.isEmpty else { return [] }
        let targetsById = Dictionary(uniqueKeysWithValues: targets.map { ($0.id, $0) })

        var reattached: [String] = []
        for record in records {
            guard let target = targetsById[record.targetId] else {
                log.info("reattach: skipping \(record.targetName, privacy: .public) — target no longer present in scan")
                continue
            }
            let isAlive = await cdpLiveProbe.isAlive(cdpPort: record.cdpPort)
            guard isAlive else {
                log.info("reattach: skipping \(record.targetName, privacy: .public) — CDP \(record.cdpPort) not responding")
                continue
            }
            do {
                try reattach(target: target, record: record)
                reattached.append(record.targetId)
            } catch {
                log.error("reattach: failed for \(record.targetName, privacy: .public): \(error.localizedDescription, privacy: .public)")
            }
        }
        recordStore.clear()
        return reattached
    }

    private func reattach(target: AppTarget, record: PersistedLaunchRecord) throws {
        // Re-spawn slicc-server in --serve-only mode so it reuses the
        // existing browser/Electron without re-launching it. Same ports
        // as before so the UI's bookmarked URL still works.
        guard !Self.isPortInUse(record.servePort) else {
            throw LaunchError.portInUse(record.servePort)
        }
        var extraArgs: [String] = [
            "--serve-only",
            "--cdp-port=\(record.cdpPort)",
        ]
        if target.type == .electronApp {
            extraArgs.append("--electron-app=\(target.path)")
            extraArgs.append("--electron")
        }
        if let staticRoot = record.staticRoot ?? uiOverlayRoot, !staticRoot.isEmpty {
            extraArgs.append("--static-root=\(staticRoot)")
        }
        var env: [String: String] = ["PORT": "\(record.servePort)"]
        if target.type == .chromiumBrowser {
            env["CHROME_PATH"] = target.executablePath
        }
        try spawn(
            target: target,
            extraArgs: extraArgs,
            env: env,
            cdpPort: record.cdpPort,
            servePort: record.servePort,
            electronAppPath: record.electronAppPath
        )
    }

    // MARK: - Private

    static func resolveLaunchConfiguration(
        sliccDir: String,
        extraArgs: [String],
        resourcePath: String? = Bundle.main.resourcePath
    ) throws -> LaunchConfiguration {
        if let serverBinary = SliccBootstrapper.findServerBinary(
            sliccDir: sliccDir,
            resourcePath: resourcePath
        ) {
            return LaunchConfiguration(
                executablePath: serverBinary,
                arguments: extraArgs,
                logLabel: "server"
            )
        }

        log.error("resolveLaunchConfiguration: slicc-server binary not found")
        throw LaunchError.serverBinaryNotFound
    }

    private func spawn(
        target: AppTarget,
        extraArgs: [String],
        env: [String: String],
        cdpPort: UInt16,
        servePort: UInt16,
        electronAppPath: String?
    ) throws {
        let launchConfig = try Self.resolveLaunchConfiguration(sliccDir: sliccDir, extraArgs: extraArgs)
        log.info("spawn: \(launchConfig.executablePath, privacy: .public) \(launchConfig.arguments.joined(separator: " "), privacy: .public)")
        log.info("spawn: cwd = \(self.sliccDir, privacy: .public)")

        let proc = Process()
        proc.executableURL = URL(fileURLWithPath: launchConfig.executablePath)
        proc.arguments = launchConfig.arguments
        proc.environment = ProcessInfo.processInfo.environment.merging(env) { _, new in new }
        proc.currentDirectoryURL = URL(fileURLWithPath: sliccDir)

        // Capture stdout/stderr and forward to os.log
        let stdoutPipe = Pipe()
        let stderrPipe = Pipe()
        proc.standardOutput = stdoutPipe
        proc.standardError = stderrPipe

        stdoutPipe.fileHandleForReading.readabilityHandler = { handle in
            let data = handle.availableData
            guard !data.isEmpty, let line = String(data: data, encoding: .utf8) else { return }
            for l in line.split(separator: "\n", omittingEmptySubsequences: true) {
                log.info("[\(launchConfig.logLabel, privacy: .public)/\(target.name, privacy: .public)] \(l, privacy: .public)")
            }
        }
        stderrPipe.fileHandleForReading.readabilityHandler = { handle in
            let data = handle.availableData
            guard !data.isEmpty, let line = String(data: data, encoding: .utf8) else { return }
            for l in line.split(separator: "\n", omittingEmptySubsequences: true) {
                log.error("[\(launchConfig.logLabel, privacy: .public)/\(target.name, privacy: .public)] \(l, privacy: .public)")
            }
        }

        proc.terminationHandler = { [weak self] p in
            log.info("process exited: \(target.name, privacy: .public) code=\(p.terminationStatus)")
            // Clean up pipe handlers
            stdoutPipe.fileHandleForReading.readabilityHandler = nil
            stderrPipe.fileHandleForReading.readabilityHandler = nil
            DispatchQueue.main.async {
                guard let self else { return }
                let wasIntentional = self.intentionallyStoppingTargets.remove(target.id) != nil
                let isCurrentRecord = self.launchRecords[target.id]?.process === p
                if isCurrentRecord {
                    self.launchRecords.removeValue(forKey: target.id)
                }
                if !wasIntentional && p.terminationStatus != 0 && isCurrentRecord {
                    self.recordStartFailure(
                        for: target,
                        message: "SLICC exited with code \(p.terminationStatus)."
                    )
                }
            }
        }
        try proc.run()
        log.info("spawn: pid=\(proc.processIdentifier) for \(target.name, privacy: .public)")
        launchRecords[target.id] = LaunchRecord(
            process: proc,
            targetType: target.type,
            launchedAppPaths: target.type == .electronApp ? Self.launchedAppPaths(for: target) : [],
            cdpPort: cdpPort,
            servePort: servePort,
            electronAppPath: electronAppPath,
            targetName: target.name,
            startedAt: Date(),
            observedAppPID: nil,
            staticRoot: uiOverlayRoot
        )
    }

    private func refreshRuntimeState(for target: AppTarget) {
        guard var record = launchRecords[target.id] else { return }
        guard record.process.isRunning else {
            launchRecords.removeValue(forKey: target.id)
            return
        }

        guard record.targetType == .electronApp else {
            return
        }

        let runningApps = runningElectronApplications(for: target)
        if let app = runningApps.first {
            record.observedAppPID = app.processIdentifier
            launchRecords[target.id] = record
            return
        }

        guard let observedAppPID = record.observedAppPID else {
            if Date().timeIntervalSince(record.startedAt) > Self.electronLaunchStaleTimeout,
               !Self.isPortInUse(record.cdpPort) {
                log.info("refreshRuntimeState: \(target.name, privacy: .public) has no app pid or CDP listener; stopping stale helper")
                stopLaunchRecord(id: target.id, terminateApps: false)
                return
            }
            return
        }

        if !Self.isPIDRunning(observedAppPID) {
            log.info("refreshRuntimeState: \(target.name, privacy: .public) app pid \(observedAppPID) exited; stopping helper")
            stopLaunchRecord(id: target.id, terminateApps: false)
        }
    }

    private func activeDebugPort(for target: AppTarget) -> UInt16? {
        guard let record = launchRecords[target.id], record.process.isRunning else {
            return nil
        }
        if record.targetType == .electronApp,
           !Self.isPortInUse(record.cdpPort) {
            return nil
        }
        if record.targetType == .electronApp,
           let observedAppPID = record.observedAppPID,
           !Self.isPIDRunning(observedAppPID),
           !isElectronAppRunning(target) {
            return nil
        }
        return record.cdpPort
    }

    private func stopLaunchRecord(id: String, terminateApps: Bool) {
        guard let record = launchRecords.removeValue(forKey: id) else {
            intentionallyStoppingTargets.remove(id)
            return
        }

        intentionallyStoppingTargets.insert(id)
        if terminateApps {
            terminateElectronApplications(atAppPaths: record.launchedAppPaths)
        }
        if record.process.isRunning {
            record.process.terminate()
        } else {
            intentionallyStoppingTargets.remove(id)
        }
    }

    /// Detach a single record: SIGUSR1 to slicc-server (graceful shutdown
    /// that skips Browser.close) and explicitly do NOT terminate the
    /// Electron app. The slicc-server child has up to a few seconds to
    /// exit; if it ignores SIGUSR1 we fall back to SIGTERM to avoid
    /// leaking processes across the update.
    private func detachLaunchRecord(id: String) {
        guard let record = launchRecords.removeValue(forKey: id) else {
            intentionallyStoppingTargets.remove(id)
            return
        }

        intentionallyStoppingTargets.insert(id)
        if record.process.isRunning {
            let pid = record.process.processIdentifier
            if pid > 0 {
                _ = Darwin.kill(pid, SIGUSR1)
            }
            // Best-effort wait for the detach path to land before AppUpdater
            // swaps the .app from under us. 1.5s mirrors the
            // browserExitTimeoutNanoseconds budget on the server side.
            let deadline = Date().addingTimeInterval(1.5)
            while record.process.isRunning && Date() < deadline {
                Thread.sleep(forTimeInterval: 0.05)
            }
            if record.process.isRunning {
                log.info("detachLaunchRecord: SIGUSR1 ignored, falling back to terminate() for \(record.targetName, privacy: .public)")
                record.process.terminate()
            }
        } else {
            intentionallyStoppingTargets.remove(id)
        }
    }

    private func recordStartFailure(for target: AppTarget, message: String) {
        startFailures[target.id] = message
    }

    private func isElectronAppRunning(_ target: AppTarget) -> Bool {
        !runningElectronApplications(for: target).isEmpty
    }

    private func runningElectronApplications(for target: AppTarget) -> [NSRunningApplication] {
        Self.runningElectronApplications(atAppPaths: Self.relatedAppPaths(for: target))
    }

    private func terminateElectronApplications(atAppPaths appPaths: [String]) {
        for app in Self.runningElectronApplications(atAppPaths: appPaths) {
            log.info("terminating app: \(app.localizedName ?? app.bundleURL?.path ?? "unknown", privacy: .public)")
            app.terminate()
        }
    }

    static func launchedAppPaths(for target: AppTarget) -> [String] {
        [target.path]
    }

    static func relatedAppPaths(for target: AppTarget) -> [String] {
        var paths = [target.path]
        if let originalAppPath = target.originalAppPath, originalAppPath != target.path {
            paths.append(originalAppPath)
        }
        return paths
    }

    private static func runningElectronApplications(atAppPaths appPaths: [String]) -> [NSRunningApplication] {
        let appURLs = Set(appPaths.map { standardizedFileURL(path: $0) })
        return NSWorkspace.shared.runningApplications.filter { app in
            guard !app.isTerminated else { return false }
            if let bundleURL = app.bundleURL.map({ standardizedFileURL(path: $0.path) }),
               appURLs.contains(bundleURL) {
                return true
            }
            if let executableURL = app.executableURL?.standardizedFileURL.resolvingSymlinksInPath() {
                return appURLs.contains { appURL in
                    executableURL.path.hasPrefix(appURL.appendingPathComponent("Contents/MacOS").path)
                }
            }
            return false
        }
    }

    private static func standardizedFileURL(path: String) -> URL {
        URL(fileURLWithPath: NSString(string: path).expandingTildeInPath)
            .standardizedFileURL
            .resolvingSymlinksInPath()
    }

    private static func isPIDRunning(_ pid: pid_t) -> Bool {
        guard pid > 0 else { return false }
        if kill(pid, 0) == 0 {
            return true
        }
        return errno == EPERM
    }

    private static func isPortInUse(_ port: UInt16) -> Bool {
        let sock = socket(AF_INET, SOCK_STREAM, 0)
        guard sock >= 0 else { return false }
        defer { close(sock) }

        var addr = sockaddr_in()
        addr.sin_family = sa_family_t(AF_INET)
        addr.sin_port = port.bigEndian
        addr.sin_addr.s_addr = inet_addr("127.0.0.1")

        let result = withUnsafePointer(to: &addr) { ptr in
            ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in
                connect(sock, sockPtr, socklen_t(MemoryLayout<sockaddr_in>.size))
            }
        }
        return result == 0
    }

    enum LaunchError: LocalizedError {
        case serverBinaryNotFound
        case portInUse(UInt16)
        var errorDescription: String? {
            switch self {
            case .serverBinaryNotFound: return "SLICC server binary not found. Build or bundle slicc-server before launching."
            case .portInUse(let port): return "Port \(port) is already in use."
            }
        }
    }
}
