//
//  BTTSwiftScriptPrelude.swift
//  BetterTouchTool
//
//  Compiled together with every "Run Swift Script" action (as Prelude.swift).
//  Defines the `BTTScript` API that scripts receive in `run(_ btt: BTTScript)`.
//  The ObjC protocols come from BTTSwiftScriptHeader.h (imported as the
//  bridging header of the script build).
//
//  Any change here changes the cache key of every script (the prelude text is
//  part of the content hash), so edits force a one-time recompile - fine, but
//  keep the public API stable.
//

import Foundation

/// Error thrown by `BTTScript.call` / `triggerNamed` when BetterTouchTool
/// reports a failure.
public struct BTTScriptError: Error, CustomStringConvertible {
    public let message: String
    public init(_ message: String) { self.message = message }
    public var description: String { message }
}

/// The object BetterTouchTool hands to `run(_:)`.
public final class BTTScript: @unchecked Sendable {

    let context: BTTSwiftScriptContext

    /// UUID of the trigger that executes this script.
    public let triggerUUID: String
    /// Named-trigger name of the executing trigger (if it has one).
    public let triggerName: String?
    /// Directory of the trigger's preset (scripts may store files there).
    public let presetPath: String
    /// `true` when the script runs inside BetterTouchTool ("Inside
    /// BetterTouchTool" execution mode), `false` in the separate runner process.
    public let runsInsideBetterTouchTool: Bool

    public init(_ context: BTTSwiftScriptContext) {
        self.context = context
        let info = context.scriptInfo()
        triggerUUID = info["triggerUUID"] as? String ?? ""
        triggerName = info["triggerName"] as? String
        presetPath = info["presetPath"] as? String ?? ""
        runsInsideBetterTouchTool = (info["inProcess"] as? NSNumber)?.boolValue ?? false
    }

    // MARK: Variables

    /// Raw value of a BetterTouchTool variable (String, number or nil).
    public func getVariable(_ name: String) async -> Any? {
        await withCheckedContinuation { (continuation: CheckedContinuation<Any?, Never>) in
            context.variable(forName: name) { value in
                continuation.resume(returning: BTTScript.unwrapNull(value))
            }
        }
    }

    /// String value of a BetterTouchTool variable.
    public func getString(_ name: String) async -> String? {
        guard let value = await getVariable(name) else { return nil }
        if let string = value as? String { return string }
        if let number = value as? NSNumber { return number.stringValue }
        return String(describing: value)
    }

    /// Numeric value of a BetterTouchTool variable.
    public func getNumber(_ name: String) async -> Double? {
        guard let value = await getVariable(name) else { return nil }
        if let number = value as? NSNumber { return number.doubleValue }
        if let string = value as? String { return Double(string.trimmingCharacters(in: .whitespaces)) }
        return nil
    }

    /// Set a BetterTouchTool variable. Strings and numbers are stored as-is,
    /// Bool becomes 1/0, everything else is stored as `String(describing:)`.
    /// `persist: true` keeps the value across restarts.
    public func setVariable(_ name: String, _ value: Any?, persist: Bool = false) async {
        context.setVariable(name, value: BTTScript.coerceVariable(value), persist: persist)
    }

    // MARK: Triggers & scripting commands

    /// Runs the actions of a named trigger and returns its result (like
    /// `trigger_named`).
    public func triggerNamed(_ name: String) async throws -> String? {
        try await call("trigger_named", ["trigger_name": name])
    }

    /// Runs a named trigger without waiting for its result.
    public func triggerNamedAsync(_ name: String) async {
        _ = try? await call("trigger_named_async_without_response", ["trigger_name": name])
    }

    /// Calls any BetterTouchTool scripting route (the same commands that are
    /// available to AppleScript / JavaScript / the web server, e.g.
    /// "get_clipboard_content", "display_notification", "update_menu_item").
    /// Dictionary / array results are returned JSON encoded.
    public func call(_ route: String, _ params: [String: Any] = [:]) async throws -> String? {
        try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<String?, Error>) in
            context.runScriptCommand(route, parameters: params as NSDictionary as? [AnyHashable: Any]) { result, error in
                if let error {
                    continuation.resume(throwing: BTTScriptError(error.localizedDescription))
                } else {
                    continuation.resume(returning: BTTScript.stringify(result))
                }
            }
        }
    }

    /// Writes to the BetterTouchTool log (and the editor's result area).
    public func log(_ message: String) {
        context.logMessage(message)
    }

    // MARK: Coercion helpers (used by the generated entry file)

    static func unwrapNull(_ value: Any?) -> Any? {
        if value == nil || value is NSNull { return nil }
        return value
    }

    static func stringify(_ value: Any?) -> String? {
        guard let value = unwrapNull(value) else { return nil }
        if let string = value as? String { return string }
        if let number = value as? NSNumber { return number.stringValue }
        if JSONSerialization.isValidJSONObject(value),
           let data = try? JSONSerialization.data(withJSONObject: value, options: [.withoutEscapingSlashes]),
           let json = String(data: data, encoding: .utf8) {
            return json
        }
        return String(describing: value)
    }

    static func coerceVariable(_ value: Any?) -> Any? {
        guard let value = unwrapNull(value) else { return nil }
        if let bool = value as? Bool { return NSNumber(value: bool ? 1 : 0) }
        if let string = value as? String { return string }
        if let number = value as? NSNumber { return number }
        if let int = value as? Int { return NSNumber(value: int) }
        if let double = value as? Double { return NSNumber(value: double) }
        return String(describing: value)
    }

    /// Converts whatever `run` returned into an XPC-safe / reply-safe value:
    /// String, NSNumber (Bool/Int/Double), Data, Date, and arrays or
    /// dictionaries of those pass through; anything else becomes
    /// `String(describing:)`. `Void` / nil become nil.
    public static func coerceResult(_ value: Any?) -> Any? {
        guard let value = unwrapNull(value) else { return nil }
        if value is Void || value is () { return nil }
        switch value {
        case let string as String: return string
        case let bool as Bool: return NSNumber(value: bool)
        case let number as NSNumber: return number
        case let int as Int: return NSNumber(value: int)
        case let double as Double: return NSNumber(value: double)
        case let float as Float: return NSNumber(value: float)
        case let data as Data: return data
        case let date as Date: return date
        case let array as [Any]: return array.map { coerceResult($0) ?? NSNull() }
        case let dict as [String: Any]:
            var out: [String: Any] = [:]
            for (key, item) in dict { out[key] = coerceResult(item) ?? NSNull() }
            return out
        case let dict as [AnyHashable: Any]:
            var out: [String: Any] = [:]
            for (key, item) in dict { out[String(describing: key)] = coerceResult(item) ?? NSNull() }
            return out
        default:
            return String(describing: value)
        }
    }
}
