local obs = obslua

local SCRIPT_VERSION = "0.3.0"
local DEFAULT_SITE_URL = "https://kynto.io"
local KYNTO_API_URL = "https://kynto.io/game/api/v1"
local API_PATH = "/game/api/v1"
local DEFAULT_SOURCE_NAME = "Kynto"
local OVERLAY_POSTURE_PARAM = "kobs"
local OVERLAY_MODE_OFF = "off"
local OVERLAY_MODE_CLEAN = "clean"
local OVERLAY_MODE_TRANSPARENT = "transparent"
local OVERLAY_PARAM_VALUES = { clean = "1", transparent = "overlay" }
local ROOM_TOKEN_PARAM = "ksource"
local PAIR_QUERY_PARAM = "kobspair"
local PAIR_POLL_INTERVAL_MS = 2000
local PAIR_POLL_LIMIT = 150
local CURL_TIMEOUT_SECONDS = 10
local HTTP_STATUS_MARKER = "KYNTO_HTTP_STATUS:"
local PAGE_EVENT_NAME = "kynto-obs"
local TRANSPARENT_BODY_CSS = "body{background:rgba(0,0,0,0) !important;margin:0;padding:0;overflow:hidden;}"
local HARD_RELOAD_CSS_NONCE = " "
local ACTION_HOTKEY_SLOT_COUNT = 4
local AUDIO_TRACK_COUNT = 6
local ALL_TRACKS_MASK = 63
local ALIGN_TOP_LEFT = 5
local CONTROL_LEVEL_NONE = 0
local CONTROL_LEVEL_READ_STATUS = 1
local CONTROL_LEVEL_ADVANCED = 4
local SAFE_URL_PATTERN = "^https?://[%w%.%-_:/?=&%%#~+]+$"
local NOT_CONNECTED_STATUS = "Not connected. Press Connect to pair this OBS with your Kynto account, or paste a room link above."

local config = {
    url = DEFAULT_SITE_URL,
    sourceName = DEFAULT_SOURCE_NAME,
    overlayMode = OVERLAY_MODE_CLEAN,
    width = 1920,
    height = 1080,
    fitToCanvas = true,
    keepOnTop = true,
    lockItem = false,
    rerouteAudio = true,
    monitoringType = 2,
    trackMask = ALL_TRACKS_MASK,
    controlLevel = CONTROL_LEVEL_READ_STATUS,
    fpsCustom = false,
    fps = 30,
    shutdownWhenHidden = false,
    refreshWhenActive = false,
    actionIds = {}
}

local scriptSettings = nil
local hardReloadNonceOn = false
local hotkeyIds = {}
local connectStatus = NOT_CONNECTED_STATUS
local connectStatusProperty = nil
local pairingActive = false
local pairPollSecret = nil
local pairPollCount = 0
local pairPollLimit = PAIR_POLL_LIMIT
local pollPairStatus = nil

local function redactRoomToken(text)
    return (tostring(text):gsub(ROOM_TOKEN_PARAM .. "=[^&#%s]+", ROOM_TOKEN_PARAM .. "=redacted"))
end

local function log(level, message)
    obs.script_log(level, "Kynto: " .. redactRoomToken(message))
end

local function trim(text)
    return (tostring(text or ""):gsub("^%s+", ""):gsub("%s+$", ""))
end

local function isSafeUrl(url)
    return url ~= nil and url:match(SAFE_URL_PATTERN) ~= nil
end

local function isHexToken(text)
    return text ~= nil and text:match("^%x+$") ~= nil
end

local function isOverlayMode(mode)
    return mode == OVERLAY_MODE_OFF or OVERLAY_PARAM_VALUES[mode] ~= nil
end

local function withOverlayPosture(url)
    local paramValue = OVERLAY_PARAM_VALUES[config.overlayMode]
    if paramValue == nil then return url end
    if url:find(OVERLAY_POSTURE_PARAM .. "=", 1, true) then return url end
    local separator = url:find("?", 1, true) and "&" or "?"
    return url .. separator .. OVERLAY_POSTURE_PARAM .. "=" .. paramValue
end

local function hostOf(url)
    return (tostring(url):match("^https?://([^/:?#]+)"))
end

local function originOf(url)
    return (tostring(url):match("^(https?://[^/?#]+)"))
end

local function isKyntoHost(host)
    return host == "kynto.io" or host:sub(-9) == ".kynto.io" or host == "localhost" or host == "127.0.0.1"
end

local function apiBaseUrl()
    local roomHost = hostOf(config.url)
    if roomHost ~= nil and isKyntoHost(roomHost) then return originOf(config.url) .. API_PATH end
    return KYNTO_API_URL
end

local function encodeJsonString(text)
    local escaped = tostring(text):gsub('[%c"\\]', function(character)
        if character == '"' then return '\\"' end
        if character == "\\" then return "\\\\" end
        return string.format("\\u%04x", character:byte())
    end)
    return '"' .. escaped .. '"'
end

local function encodeJsonObject(fields)
    local keys = {}
    for key in pairs(fields) do keys[#keys + 1] = key end
    table.sort(keys)
    local parts = {}
    for _, key in ipairs(keys) do
        parts[#parts + 1] = encodeJsonString(key) .. ":" .. encodeJsonString(fields[key])
    end
    return "{" .. table.concat(parts, ",") .. "}"
end

local function readJsonString(json, key)
    local value = tostring(json or ""):match('"' .. key .. '"%s*:%s*"([^"]*)"')
    if value == nil then return nil end
    return (value:gsub("\\/", "/"))
end

local function readJsonNumber(json, key)
    return tonumber(tostring(json or ""):match('"' .. key .. '"%s*:%s*(-?%d+%.?%d*)'))
end

local function canvasSize()
    local videoInfo = obs.obs_video_info()
    if obs.obs_get_video_info(videoInfo) then
        return videoInfo.base_width, videoInfo.base_height
    end
    return nil, nil
end

local function runCommand(command)
    local handle = io.popen(command)
    if handle == nil then return nil end
    local output = handle:read("*a")
    handle:close()
    return output
end

local function currentPlatform()
    if package.config:sub(1, 1) == "\\" then return "windows" end
    local unameOutput = runCommand("uname") or ""
    return unameOutput:find("Darwin") and "macos" or "linux"
end

local function openInSystemBrowser(url)
    if not isSafeUrl(url) then
        log(obs.LOG_WARNING, "refusing to open a URL that is not a plain https link: " .. tostring(url))
        return
    end
    local platform = currentPlatform()
    if platform == "windows" then
        os.execute('start "" "' .. url .. '"')
    elseif platform == "macos" then
        os.execute('open "' .. url .. '"')
    else
        os.execute('xdg-open "' .. url .. '" >/dev/null 2>&1 &')
    end
end

local function curlIsAvailable()
    local versionOutput = runCommand("curl --version 2>&1")
    return versionOutput ~= nil and versionOutput:match("^%s*curl %d") ~= nil
end

local function httpRequest(method, url, body)
    if not isSafeUrl(url) then return nil, nil end
    local command = "curl -s -S --max-time " .. CURL_TIMEOUT_SECONDS .. " -X " .. method
        .. ' -H "Content-Type: application/json" -H "Accept: application/json"'
        .. (body and (' --data "' .. body .. '"') or "")
        .. ' -w "' .. HTTP_STATUS_MARKER .. '%{http_code}" "' .. url .. '"'
    local output = runCommand(command)
    if output == nil then return nil, nil end
    local markerStart = output:find(HTTP_STATUS_MARKER, 1, true)
    if markerStart == nil then return nil, output end
    local responseBody = output:sub(1, markerStart - 1)
    local statusCode = tonumber(output:sub(markerStart + #HTTP_STATUS_MARKER))
    return statusCode, responseBody
end

local function findKyntoSource()
    return obs.obs_get_source_by_name(config.sourceName)
end

local function describeControlLevel(level)
    if level == CONTROL_LEVEL_ADVANCED then return "advanced (Kynto may switch scenes)" end
    if level == CONTROL_LEVEL_NONE then return "none" end
    return "read status"
end

local function describeTracks(mask)
    if mask == ALL_TRACKS_MASK then return "all tracks" end
    local enabledTracks = {}
    for trackIndex = 1, AUDIO_TRACK_COUNT do
        if mask % (2 ^ trackIndex) >= 2 ^ (trackIndex - 1) then enabledTracks[#enabledTracks + 1] = tostring(trackIndex) end
    end
    if #enabledTracks == 0 then return "no tracks" end
    return "tracks " .. table.concat(enabledTracks, ",")
end

local function describeOverlayMode(mode)
    if mode == OVERLAY_MODE_TRANSPARENT then return "transparent world" end
    if mode == OVERLAY_MODE_OFF then return "full app" end
    return "clean feed"
end

local function countScenesHoldingSource()
    local scenes = obs.obs_frontend_get_scenes()
    if scenes == nil then return 0 end
    local holdingCount = 0
    for _, sceneSource in ipairs(scenes) do
        local scene = obs.obs_scene_from_source(sceneSource)
        if obs.obs_scene_find_source(scene, config.sourceName) ~= nil then holdingCount = holdingCount + 1 end
    end
    obs.source_list_release(scenes)
    return holdingCount
end

local function describeStatus()
    local existingSource = findKyntoSource()
    local presence = "source '" .. config.sourceName .. "' not created yet"
    if existingSource ~= nil then
        presence = "source '" .. config.sourceName .. "' in " .. countScenesHoldingSource() .. " scene(s)"
        obs.obs_source_release(existingSource)
    end
    local audio = config.rerouteAudio and ("audio via OBS, " .. describeTracks(config.trackMask)) or "audio not routed"
    return string.format("Kynto source %s · %s · %dx%d · overlay: %s · %s · page permission: %s · OBS %s",
        SCRIPT_VERSION, presence, config.width, config.height, describeOverlayMode(config.overlayMode), audio,
        describeControlLevel(config.controlLevel), obs.obs_get_version_string())
end

local function logResolvedConfig(reason)
    log(obs.LOG_INFO, reason .. " · " .. describeStatus())
    log(obs.LOG_INFO, "url " .. withOverlayPosture(config.url)
        .. " · overlay " .. config.overlayMode
        .. " · api " .. apiBaseUrl()
        .. " · fit " .. tostring(config.fitToCanvas) .. " · top " .. tostring(config.keepOnTop)
        .. " · lock " .. tostring(config.lockItem) .. " · monitoring " .. tostring(config.monitoringType)
        .. " · fps " .. (config.fpsCustom and tostring(config.fps) or "canvas")
        .. " · shutdown " .. tostring(config.shutdownWhenHidden) .. " · refresh " .. tostring(config.refreshWhenActive))
end

local function buildSourceSettings()
    local sourceSettings = obs.obs_data_create()
    obs.obs_data_set_string(sourceSettings, "url", withOverlayPosture(config.url))
    obs.obs_data_set_int(sourceSettings, "width", config.width)
    obs.obs_data_set_int(sourceSettings, "height", config.height)
    obs.obs_data_set_string(sourceSettings, "css", TRANSPARENT_BODY_CSS .. (hardReloadNonceOn and HARD_RELOAD_CSS_NONCE or ""))
    obs.obs_data_set_bool(sourceSettings, "reroute_audio", config.rerouteAudio)
    obs.obs_data_set_bool(sourceSettings, "shutdown", config.shutdownWhenHidden)
    obs.obs_data_set_bool(sourceSettings, "restart_when_active", config.refreshWhenActive)
    obs.obs_data_set_bool(sourceSettings, "fps_custom", config.fpsCustom)
    obs.obs_data_set_int(sourceSettings, "fps", config.fps)
    obs.obs_data_set_int(sourceSettings, "webpage_control_level", config.controlLevel)
    return sourceSettings
end

local function applyAudioRouting(kyntoSource)
    obs.obs_source_set_monitoring_type(kyntoSource, config.monitoringType)
    obs.obs_source_set_audio_mixers(kyntoSource, config.trackMask)
end

local function applySceneItemLayout(sceneItem)
    if config.fitToCanvas then
        local canvasWidth, canvasHeight = canvasSize()
        if canvasWidth ~= nil then
            local origin = obs.vec2()
            origin.x = 0
            origin.y = 0
            local bounds = obs.vec2()
            bounds.x = canvasWidth
            bounds.y = canvasHeight
            obs.obs_sceneitem_set_alignment(sceneItem, ALIGN_TOP_LEFT)
            obs.obs_sceneitem_set_pos(sceneItem, origin)
            obs.obs_sceneitem_set_bounds_type(sceneItem, obs.OBS_BOUNDS_STRETCH)
            obs.obs_sceneitem_set_bounds_alignment(sceneItem, ALIGN_TOP_LEFT)
            obs.obs_sceneitem_set_bounds(sceneItem, bounds)
        end
    end
    if config.keepOnTop then obs.obs_sceneitem_set_order(sceneItem, obs.OBS_ORDER_MOVE_TOP) end
    obs.obs_sceneitem_set_locked(sceneItem, config.lockItem)
end

local function ensureSourceExists()
    local existingSource = findKyntoSource()
    local sourceSettings = buildSourceSettings()
    if existingSource ~= nil then
        obs.obs_source_update(existingSource, sourceSettings)
        obs.obs_data_release(sourceSettings)
        return existingSource, false
    end
    local createdSource = obs.obs_source_create("browser_source", config.sourceName, sourceSettings, nil)
    obs.obs_data_release(sourceSettings)
    return createdSource, true
end

local function ensureSceneHoldsSource(sceneSource, kyntoSource)
    local scene = obs.obs_scene_from_source(sceneSource)
    local sceneItem = obs.obs_scene_find_source(scene, config.sourceName)
    local added = false
    if sceneItem == nil then
        sceneItem = obs.obs_scene_add(scene, kyntoSource)
        added = true
    end
    if sceneItem ~= nil then applySceneItemLayout(sceneItem) end
    return added
end

local function placeInCurrentScene()
    local currentSceneSource = obs.obs_frontend_get_current_scene()
    if currentSceneSource == nil then
        log(obs.LOG_WARNING, "no active scene to add the source to")
        return
    end
    local kyntoSource, created = ensureSourceExists()
    applyAudioRouting(kyntoSource)
    local added = ensureSceneHoldsSource(currentSceneSource, kyntoSource)
    log(obs.LOG_INFO, (created and "created" or "updated") .. " source" .. (added and ", added to the current scene" or "")
        .. " -> " .. withOverlayPosture(config.url))
    obs.obs_source_release(kyntoSource)
    obs.obs_source_release(currentSceneSource)
end

local function placeInEveryScene()
    local kyntoSource = ensureSourceExists()
    applyAudioRouting(kyntoSource)
    local scenes = obs.obs_frontend_get_scenes()
    local addedCount = 0
    if scenes ~= nil then
        for _, sceneSource in ipairs(scenes) do
            if ensureSceneHoldsSource(sceneSource, kyntoSource) then addedCount = addedCount + 1 end
        end
        obs.source_list_release(scenes)
    end
    log(obs.LOG_INFO, "added to " .. addedCount .. " scene(s) that did not hold it")
    obs.obs_source_release(kyntoSource)
end

local function dispatchPageEvent(fields)
    local kyntoSource = findKyntoSource()
    if kyntoSource == nil then
        log(obs.LOG_WARNING, "no source to send '" .. tostring(fields.action) .. "' to; create it first")
        return false
    end
    local procHandler = obs.obs_source_get_proc_handler(kyntoSource)
    local callData = obs.calldata_create()
    obs.calldata_set_string(callData, "eventName", PAGE_EVENT_NAME)
    obs.calldata_set_string(callData, "jsonString", encodeJsonObject(fields))
    obs.proc_handler_call(procHandler, "javascript_event", callData)
    obs.calldata_destroy(callData)
    obs.obs_source_release(kyntoSource)
    return true
end

local function softReload()
    dispatchPageEvent({ action = "reload" })
end

local function hardReload()
    local kyntoSource = findKyntoSource()
    if kyntoSource == nil then
        log(obs.LOG_WARNING, "no source to reload; create it first")
        return
    end
    hardReloadNonceOn = not hardReloadNonceOn
    local sourceSettings = buildSourceSettings()
    obs.obs_source_update(kyntoSource, sourceSettings)
    obs.obs_data_release(sourceSettings)
    obs.obs_source_release(kyntoSource)
    log(obs.LOG_INFO, "browser recreated")
end

local function toggleVisibilityInCurrentScene()
    local currentSceneSource = obs.obs_frontend_get_current_scene()
    if currentSceneSource == nil then return end
    local scene = obs.obs_scene_from_source(currentSceneSource)
    local sceneItem = obs.obs_scene_find_source(scene, config.sourceName)
    if sceneItem ~= nil then
        obs.obs_sceneitem_set_visible(sceneItem, not obs.obs_sceneitem_visible(sceneItem))
    end
    obs.obs_source_release(currentSceneSource)
end

local function fireActionSlot(slotIndex)
    local actionId = trim(config.actionIds[slotIndex])
    if actionId == "" then
        log(obs.LOG_INFO, "action slot " .. slotIndex .. " has no deck action id")
        return
    end
    dispatchPageEvent({ action = "deck", id = actionId, slot = tostring(slotIndex) })
end

local function openKyntoWindowInObs()
    local kyntoSource = findKyntoSource()
    if kyntoSource == nil then
        log(obs.LOG_WARNING, "create the source before opening its window")
        return
    end
    if obs.obs_frontend_open_source_interaction ~= nil then
        obs.obs_frontend_open_source_interaction(kyntoSource)
    else
        log(obs.LOG_WARNING, "this OBS build cannot open the Interact window from a script; right-click the source and choose Interact")
    end
    obs.obs_source_release(kyntoSource)
end

local function setConnectStatus(text)
    connectStatus = text
    if connectStatusProperty ~= nil then obs.obs_property_set_description(connectStatusProperty, text) end
end

local function stopPairing()
    if pairingActive then obs.timer_remove(pollPairStatus) end
    pairingActive = false
    pairPollSecret = nil
end

local function connectedRoomLabel(roomUrl)
    return roomUrl:match("/r/([^/?#]+)") or "your room"
end

local function adoptPairedRoom(roomUrl)
    if scriptSettings ~= nil then obs.obs_data_set_string(scriptSettings, "url", roomUrl) end
    config.url = roomUrl
    placeInCurrentScene()
    setConnectStatus("Connected to room " .. connectedRoomLabel(roomUrl) .. ". The Kynto source now carries your access.")
    log(obs.LOG_INFO, "paired")
end

pollPairStatus = function()
    pairPollCount = pairPollCount + 1
    local statusCode, responseBody = httpRequest("GET", apiBaseUrl() .. "/obs/pair/status/" .. pairPollSecret)
    local pairStatus = readJsonString(responseBody, "status")
    if statusCode == 200 and pairStatus == "paired" then
        stopPairing()
        local roomUrl = readJsonString(responseBody, "roomUrl")
        if not isSafeUrl(roomUrl) then
            setConnectStatus("Kynto answered without a usable room link. Press Connect to try again.")
            log(obs.LOG_WARNING, "pairing answered without a usable room link")
            return
        end
        adoptPairedRoom(roomUrl)
        return
    end
    if statusCode == 404 or pairStatus == "unknown" then
        stopPairing()
        setConnectStatus("That code expired or was already used. Press Connect for a new one.")
        log(obs.LOG_INFO, "pairing code expired or consumed")
        return
    end
    if pairPollCount >= pairPollLimit then
        stopPairing()
        setConnectStatus("Timed out waiting for the pairing. Press Connect for a new code.")
        log(obs.LOG_INFO, "pairing timed out")
    end
end

local function pollLimitFor(expiresAtMs)
    if expiresAtMs == nil then return PAIR_POLL_LIMIT end
    local secondsLeft = math.floor(expiresAtMs / 1000 - os.time())
    if secondsLeft <= 0 then return PAIR_POLL_LIMIT end
    return math.min(PAIR_POLL_LIMIT, math.ceil(secondsLeft * 1000 / PAIR_POLL_INTERVAL_MS))
end

local function startPairing()
    stopPairing()
    if not curlIsAvailable() then
        setConnectStatus("curl was not found on this computer, so Connect cannot run. Paste a room link into Room URL instead; everything else works.")
        log(obs.LOG_WARNING, "curl not found; Connect is unavailable, a pasted room link still works")
        return
    end
    local baseUrl = apiBaseUrl()
    local statusCode, responseBody = httpRequest("POST", baseUrl .. "/obs/pair/start", "{}")
    if statusCode == 429 then
        setConnectStatus("Too many pairing attempts. Wait a minute and press Connect again.")
        log(obs.LOG_WARNING, "pairing start throttled")
        return
    end
    local pairCode = readJsonString(responseBody, "code")
    local pollSecret = readJsonString(responseBody, "pollSecret")
    local codeIsUsable = pairCode ~= nil and pairCode:match("^%d+$") ~= nil
    if statusCode ~= 200 or not codeIsUsable or not isHexToken(pollSecret) then
        setConnectStatus("Kynto did not answer the pairing request (" .. tostring(statusCode or "no reply") .. "). Check your connection and press Connect again.")
        log(obs.LOG_WARNING, "pairing start failed: " .. tostring(statusCode or "no reply"))
        return
    end
    local apiOrigin = originOf(baseUrl)
    local pairUrl = readJsonString(responseBody, "pairUrl")
    if pairUrl == nil or originOf(pairUrl) ~= apiOrigin then pairUrl = apiOrigin .. "/?" .. PAIR_QUERY_PARAM .. "=" .. pairCode end
    pairPollSecret = pollSecret
    pairPollCount = 0
    pairPollLimit = pollLimitFor(readJsonNumber(responseBody, "expiresAt"))
    pairingActive = true
    obs.timer_add(pollPairStatus, PAIR_POLL_INTERVAL_MS)
    openInSystemBrowser(pairUrl)
    setConnectStatus("Your code is " .. pairCode .. ". Enter it on the Kynto page that just opened, signed in as you. Waiting up to five minutes.")
    log(obs.LOG_INFO, "pairing code " .. pairCode .. " shown; waiting for the browser")
end

local function onCreateOrUpdateClicked()
    placeInCurrentScene()
    return true
end

local function onAddToEveryScene()
    placeInEveryScene()
    return true
end

local function onMatchCanvasClicked()
    local canvasWidth, canvasHeight = canvasSize()
    if canvasWidth == nil or scriptSettings == nil then return false end
    obs.obs_data_set_int(scriptSettings, "width", canvasWidth)
    obs.obs_data_set_int(scriptSettings, "height", canvasHeight)
    config.width = canvasWidth
    config.height = canvasHeight
    return true
end

local function onSoftReloadClicked()
    softReload()
    return false
end

local function onHardReloadClicked()
    hardReload()
    return true
end

local function onOpenSiteClicked()
    openInSystemBrowser(config.url)
    return false
end

local function onOpenWindowClicked()
    openKyntoWindowInObs()
    return false
end

local function onConnectAccountClicked()
    startPairing()
    return true
end

local function onFpsCustomModified(properties, property, settings)
    obs.obs_property_set_visible(obs.obs_properties_get(properties, "fps"), obs.obs_data_get_bool(settings, "fps_custom"))
    return true
end

local function onHotkeyReload(pressed)
    if pressed then softReload() end
end

local function onHotkeyToggle(pressed)
    if pressed then toggleVisibilityInCurrentScene() end
end

local function actionHotkeyCallback(slotIndex)
    return function(pressed)
        if pressed then fireActionSlot(slotIndex) end
    end
end

local function hotkeyDefinitions()
    local definitions = {
        { id = "kynto_refresh", label = "Kynto: reload page", callback = onHotkeyReload },
        { id = "kynto_toggle", label = "Kynto: toggle visibility", callback = onHotkeyToggle }
    }
    for slotIndex = 1, ACTION_HOTKEY_SLOT_COUNT do
        definitions[#definitions + 1] = {
            id = "kynto_action_" .. slotIndex,
            label = "Kynto: action " .. slotIndex,
            callback = actionHotkeyCallback(slotIndex)
        }
    end
    return definitions
end

local function onFrontendEvent(event)
    if event == obs.OBS_FRONTEND_EVENT_FINISHED_LOADING then logResolvedConfig("OBS finished loading") end
end

function script_description()
    return [[<h2>Kynto source ]] .. SCRIPT_VERSION .. [[</h2>
<p>Puts a Kynto room on your stream as a transparent browser source with its audio in the OBS mixer.
Press <b>Connect Kynto account</b> or paste your room link, click <b>Create or update in current scene</b>,
and bind the hotkeys you want under Settings, Hotkeys.</p>
<p>Each <b>Kynto: action</b> hotkey sends the deck action id you type below into the room, so one key
can start a storm, switch the view or play a sound without leaving your game.</p>
<p><a href="]] .. DEFAULT_SITE_URL .. [[">kynto.io</a></p>]]
end

function script_defaults(settings)
    obs.obs_data_set_default_string(settings, "url", DEFAULT_SITE_URL)
    obs.obs_data_set_default_string(settings, "source_name", DEFAULT_SOURCE_NAME)
    obs.obs_data_set_default_string(settings, "overlay_mode", OVERLAY_MODE_CLEAN)
    obs.obs_data_set_default_int(settings, "width", 1920)
    obs.obs_data_set_default_int(settings, "height", 1080)
    obs.obs_data_set_default_bool(settings, "fit_to_canvas", true)
    obs.obs_data_set_default_bool(settings, "keep_on_top", true)
    obs.obs_data_set_default_bool(settings, "lock_item", false)
    obs.obs_data_set_default_bool(settings, "reroute_audio", true)
    obs.obs_data_set_default_int(settings, "monitoring_type", 2)
    for trackIndex = 1, AUDIO_TRACK_COUNT do
        obs.obs_data_set_default_bool(settings, "track_" .. trackIndex, true)
    end
    obs.obs_data_set_default_int(settings, "control_level", CONTROL_LEVEL_READ_STATUS)
    obs.obs_data_set_default_bool(settings, "fps_custom", false)
    obs.obs_data_set_default_int(settings, "fps", 30)
    obs.obs_data_set_default_bool(settings, "shutdown_when_hidden", false)
    obs.obs_data_set_default_bool(settings, "refresh_when_active", false)
    for slotIndex = 1, ACTION_HOTKEY_SLOT_COUNT do
        obs.obs_data_set_default_string(settings, "action_" .. slotIndex, "")
    end
end

local function readOverlayMode(settings)
    local hasMode = obs.obs_data_has_user_value(settings, "overlay_mode")
    local hasLegacyPosture = obs.obs_data_has_user_value(settings, "overlay_posture")
    if not hasMode and hasLegacyPosture then
        return obs.obs_data_get_bool(settings, "overlay_posture") and OVERLAY_MODE_CLEAN or OVERLAY_MODE_OFF
    end
    local requestedMode = obs.obs_data_get_string(settings, "overlay_mode")
    return isOverlayMode(requestedMode) and requestedMode or OVERLAY_MODE_CLEAN
end

function script_update(settings)
    scriptSettings = settings
    local requestedUrl = trim(obs.obs_data_get_string(settings, "url"))
    config.url = requestedUrl ~= "" and requestedUrl or DEFAULT_SITE_URL
    local requestedName = trim(obs.obs_data_get_string(settings, "source_name"))
    config.sourceName = requestedName ~= "" and requestedName or DEFAULT_SOURCE_NAME
    config.overlayMode = readOverlayMode(settings)
    config.width = obs.obs_data_get_int(settings, "width")
    config.height = obs.obs_data_get_int(settings, "height")
    config.fitToCanvas = obs.obs_data_get_bool(settings, "fit_to_canvas")
    config.keepOnTop = obs.obs_data_get_bool(settings, "keep_on_top")
    config.lockItem = obs.obs_data_get_bool(settings, "lock_item")
    config.rerouteAudio = obs.obs_data_get_bool(settings, "reroute_audio")
    config.monitoringType = obs.obs_data_get_int(settings, "monitoring_type")
    local trackMask = 0
    for trackIndex = 1, AUDIO_TRACK_COUNT do
        if obs.obs_data_get_bool(settings, "track_" .. trackIndex) then trackMask = trackMask + 2 ^ (trackIndex - 1) end
    end
    config.trackMask = trackMask
    config.controlLevel = obs.obs_data_get_int(settings, "control_level")
    config.fpsCustom = obs.obs_data_get_bool(settings, "fps_custom")
    config.fps = obs.obs_data_get_int(settings, "fps")
    config.shutdownWhenHidden = obs.obs_data_get_bool(settings, "shutdown_when_hidden")
    config.refreshWhenActive = obs.obs_data_get_bool(settings, "refresh_when_active")
    for slotIndex = 1, ACTION_HOTKEY_SLOT_COUNT do
        config.actionIds[slotIndex] = obs.obs_data_get_string(settings, "action_" .. slotIndex)
    end
end

local function addRoomGroup(properties)
    local roomProperties = obs.obs_properties_create()
    local urlProperty = obs.obs_properties_add_text(roomProperties, "url", "Room URL", obs.OBS_TEXT_DEFAULT)
    obs.obs_property_set_long_description(urlProperty, "Your room link from Kynto. Connect fills it in for you; the site root opens the room you last visited.")
    local nameProperty = obs.obs_properties_add_text(roomProperties, "source_name", "Source name", obs.OBS_TEXT_DEFAULT)
    obs.obs_property_set_long_description(nameProperty, "Change it to run two rooms side by side; each name is one browser source.")
    local overlayProperty = obs.obs_properties_add_list(roomProperties, "overlay_mode", "Overlay", obs.OBS_COMBO_TYPE_LIST, obs.OBS_COMBO_FORMAT_STRING)
    obs.obs_property_list_add_string(overlayProperty, "Off (the full Kynto app)", OVERLAY_MODE_OFF)
    obs.obs_property_list_add_string(overlayProperty, "Clean feed (world and avatars, no Kynto chrome)", OVERLAY_MODE_CLEAN)
    obs.obs_property_list_add_string(overlayProperty, "Transparent (avatars over your camera or game)", OVERLAY_MODE_TRANSPARENT)
    obs.obs_property_set_long_description(overlayProperty, "Clean adds " .. OVERLAY_POSTURE_PARAM .. "=1 so the page boots as world and avatars only. Transparent adds "
        .. OVERLAY_POSTURE_PARAM .. "=overlay so the world also drops its background and the avatars sit over whatever is under the source.")
    obs.obs_properties_add_button(roomProperties, "open_site_btn", "Open Kynto in your browser", onOpenSiteClicked)
    obs.obs_properties_add_button(roomProperties, "open_window_btn", "Open the Kynto window in OBS", onOpenWindowClicked)
    obs.obs_properties_add_group(properties, "group_room", "Room", obs.OBS_GROUP_NORMAL, roomProperties)
end

local function addLayoutGroup(properties)
    local layoutProperties = obs.obs_properties_create()
    obs.obs_properties_add_int(layoutProperties, "width", "Width", 320, 7680, 1)
    obs.obs_properties_add_int(layoutProperties, "height", "Height", 240, 4320, 1)
    obs.obs_properties_add_button(layoutProperties, "match_canvas_btn", "Match canvas size", onMatchCanvasClicked)
    local fitProperty = obs.obs_properties_add_bool(layoutProperties, "fit_to_canvas", "Fit to canvas")
    obs.obs_property_set_long_description(fitProperty, "Stretches the source over the whole canvas at 0,0 so the overlay cannot drift.")
    obs.obs_properties_add_bool(layoutProperties, "keep_on_top", "Keep on top of other sources")
    obs.obs_properties_add_bool(layoutProperties, "lock_item", "Lock position and size")
    obs.obs_properties_add_group(properties, "group_layout", "Layout", obs.OBS_GROUP_NORMAL, layoutProperties)
end

local function addAudioGroup(properties)
    local audioProperties = obs.obs_properties_create()
    obs.obs_properties_add_bool(audioProperties, "reroute_audio", "Route audio through OBS")
    local monitoringProperty = obs.obs_properties_add_list(audioProperties, "monitoring_type", "Monitoring", obs.OBS_COMBO_TYPE_LIST, obs.OBS_COMBO_FORMAT_INT)
    obs.obs_property_list_add_int(monitoringProperty, "Off", 0)
    obs.obs_property_list_add_int(monitoringProperty, "Monitor only (you hear it, viewers do not)", 1)
    obs.obs_property_list_add_int(monitoringProperty, "Monitor and output (you and viewers hear it)", 2)
    obs.obs_property_set_long_description(monitoringProperty, "Monitor and output lets you hear TTS in your headphones while it also reaches the stream.")
    for trackIndex = 1, AUDIO_TRACK_COUNT do
        obs.obs_properties_add_bool(audioProperties, "track_" .. trackIndex, "Audio track " .. trackIndex)
    end
    obs.obs_properties_add_group(properties, "group_audio", "Audio", obs.OBS_GROUP_NORMAL, audioProperties)
end

local function addAccessGroup(properties)
    local accessProperties = obs.obs_properties_create()
    local levelProperty = obs.obs_properties_add_list(accessProperties, "control_level", "Page permission", obs.OBS_COMBO_TYPE_LIST, obs.OBS_COMBO_FORMAT_INT)
    obs.obs_property_list_add_int(levelProperty, "No access to OBS", CONTROL_LEVEL_NONE)
    obs.obs_property_list_add_int(levelProperty, "Read status (Kynto hears stream start and stop)", CONTROL_LEVEL_READ_STATUS)
    obs.obs_property_list_add_int(levelProperty, "Advanced (Kynto may switch scenes)", CONTROL_LEVEL_ADVANCED)
    obs.obs_property_set_long_description(levelProperty, "Read status is enough for the room to follow your stream. Pick advanced only if you want Kynto to change scenes.")
    obs.obs_properties_add_group(properties, "group_access", "OBS access", obs.OBS_GROUP_NORMAL, accessProperties)
end

local function addPerformanceGroup(properties)
    local performanceProperties = obs.obs_properties_create()
    local fpsCustomProperty = obs.obs_properties_add_bool(performanceProperties, "fps_custom", "Custom FPS")
    obs.obs_property_set_modified_callback(fpsCustomProperty, onFpsCustomModified)
    obs.obs_properties_add_int(performanceProperties, "fps", "FPS", 1, 120, 1)
    local shutdownProperty = obs.obs_properties_add_bool(performanceProperties, "shutdown_when_hidden", "Shutdown source when not visible")
    obs.obs_property_set_long_description(shutdownProperty, "Leave off: on, the room reloads and rejoins every time you switch scenes.")
    local refreshProperty = obs.obs_properties_add_bool(performanceProperties, "refresh_when_active", "Refresh browser when scene becomes active")
    obs.obs_property_set_long_description(refreshProperty, "Leave off for the same reason.")
    obs.obs_properties_add_group(properties, "group_performance", "Performance", obs.OBS_GROUP_NORMAL, performanceProperties)
end

local function addScenesGroup(properties)
    local sceneProperties = obs.obs_properties_create()
    obs.obs_properties_add_button(sceneProperties, "create_btn", "Create or update in current scene", onCreateOrUpdateClicked)
    obs.obs_properties_add_button(sceneProperties, "all_scenes_btn", "Add to every scene", onAddToEveryScene)
    obs.obs_properties_add_button(sceneProperties, "soft_reload_btn", "Reload page", onSoftReloadClicked)
    obs.obs_properties_add_button(sceneProperties, "hard_reload_btn", "Hard reload (recreate browser)", onHardReloadClicked)
    obs.obs_properties_add_group(properties, "group_scenes", "Scenes", obs.OBS_GROUP_NORMAL, sceneProperties)
end

local function addHotkeyGroup(properties)
    local hotkeyProperties = obs.obs_properties_create()
    local hintProperty = obs.obs_properties_add_text(hotkeyProperties, "hotkey_hint", "", obs.OBS_TEXT_INFO)
    obs.obs_property_set_description(hintProperty, "Bind the keys under Settings, Hotkeys. Each action slot names a Kynto deck action id, for example climate.storm, view.fp or sfx.airhorn.")
    for slotIndex = 1, ACTION_HOTKEY_SLOT_COUNT do
        obs.obs_properties_add_text(hotkeyProperties, "action_" .. slotIndex, "Action " .. slotIndex .. " deck id", obs.OBS_TEXT_DEFAULT)
    end
    obs.obs_properties_add_group(properties, "group_hotkeys", "Hotkeys", obs.OBS_GROUP_NORMAL, hotkeyProperties)
end

local function addAccountGroup(properties)
    local accountProperties = obs.obs_properties_create()
    connectStatusProperty = obs.obs_properties_add_text(accountProperties, "connect_status", "", obs.OBS_TEXT_INFO)
    obs.obs_property_set_description(connectStatusProperty, connectStatus)
    local connectButton = obs.obs_properties_add_button(accountProperties, "connect_btn", "Connect Kynto account", onConnectAccountClicked)
    obs.obs_property_set_long_description(connectButton, "Opens kynto.io in your browser with a six digit code. Confirm it there and this OBS receives a room link that carries your access.")
    obs.obs_properties_add_group(properties, "group_account", "Account", obs.OBS_GROUP_NORMAL, accountProperties)
end

function script_properties()
    local properties = obs.obs_properties_create()
    local statusProperty = obs.obs_properties_add_text(properties, "status", "", obs.OBS_TEXT_INFO)
    obs.obs_property_set_description(statusProperty, describeStatus())
    addRoomGroup(properties)
    addLayoutGroup(properties)
    addAudioGroup(properties)
    addAccessGroup(properties)
    addPerformanceGroup(properties)
    addScenesGroup(properties)
    addHotkeyGroup(properties)
    addAccountGroup(properties)
    return properties
end

function script_load(settings)
    scriptSettings = settings
    for _, definition in ipairs(hotkeyDefinitions()) do
        hotkeyIds[definition.id] = obs.obs_hotkey_register_frontend(definition.id, definition.label, definition.callback)
        local savedBinding = obs.obs_data_get_array(settings, definition.id .. "_hotkey")
        obs.obs_hotkey_load(hotkeyIds[definition.id], savedBinding)
        obs.obs_data_array_release(savedBinding)
    end
    obs.obs_frontend_add_event_callback(onFrontendEvent)
    script_update(settings)
    logResolvedConfig("script loaded")
end

function script_unload()
    stopPairing()
end

function script_save(settings)
    for hotkeyName, hotkeyId in pairs(hotkeyIds) do
        local bindingArray = obs.obs_hotkey_save(hotkeyId)
        obs.obs_data_set_array(settings, hotkeyName .. "_hotkey", bindingArray)
        obs.obs_data_array_release(bindingArray)
    end
end
