-- this is the first file executed when the application starts
-- we have to load the first modules form here

-- Global helper to check if running in local development environment
-- Checks RELENCIA_ENV environment variable or /relencia.env file
local function isLocalEnv()
    local function readFileTrim(path)
        if g_resources.fileExists(path) then
            local s = g_resources.readFileContents(path)
            if type(s) == 'string' then
                return (s:gsub('^%s+', ''):gsub('%s+$', ''))
            end
        end
        return nil
    end

    local env = os.getenv("RELENCIA_ENV") or ""
    if env == "" then
        env = readFileTrim('/relencia.env') or ""
    end
    return env == "local"
end
_G.isLocalEnv = isLocalEnv -- Make globally accessible to all modules

-- Services holds addresses derived from a single source of truth: the server
-- host name. We keep everything else derived from it.
Services = {
    -- Canonical server hostname (no scheme, no path). Example: "relencia.online".
    server = nil,
    -- Derived API base (scheme + host). Example: "https://relencia.online".
    apiBase = nil,
    -- Derived updater endpoint. Example: "https://relencia.online/api/updater.php".
    updater = nil,
    --status = "http://localhost/login.php", --./client_entergame | ./client_topmenu
    --websites = "http://localhost/?subtopic=accountmanagement", --./client_entergame "Forgot password and/or email"
    --createAccount = "http://localhost/clientcreateaccount.php", --./client_entergame -- createAccount.lua
}

local function deriveFromServer(server)
    if not server or server == '' then return end
    Services.server = server
    -- For simplicity we default to https for API; operators can override later if
    -- they need http in development.
    Services.apiBase = 'https://' .. server
    Services.updater = Services.apiBase .. '/index.php/api/updater.php'
end

-- Helper to read deployment environment from /relencia.env file
local function getDeploymentEnv()
    local function readFileTrim(path)
        if g_resources.fileExists(path) then
            local s = g_resources.readFileContents(path)
            if type(s) == 'string' then
                return (s:gsub('^%s+', ''):gsub('%s+$', ''))
            end
        end
        return nil
    end

    return readFileTrim('/relencia.env') or "prod"
end

do
    local isWasm = (g_app.getBuildArch and g_app.getBuildArch() == "WASM32")
    if not isWasm then
        -- Single source of truth: choose the server host and port based on environment
        local server
        local server2  -- secondary proxy for failover
        local port
        local tfsPort

        if isLocalEnv() then
            -- Local development environment
            server = '127.0.0.1'
            port = 7171
            tfsPort = 7171
            g_logger.info(string.format('Env=local: using %s:%d', server, port))
        else
            -- Determine based on deployment environment from /relencia.env
            -- Note: `port` is the HAProxy external port (what client connects to)
            --       `tfsPort` is the TFS internal port (what alpha-proxy connects to)
            local deployEnv = getDeploymentEnv()
            if deployEnv == "stage" then
                server = 'game-test.relencia.online'
                server2 = 'game2-test.relencia.online'
                port = 17171      -- HAProxy external port
                tfsPort = 7171    -- TFS internal port (sent via g_proxy)
                g_logger.info(string.format('Stage environment: %s + %s:%d (tfs:%d)', server, server2, port, tfsPort))
            else
                server = 'game.relencia.online'
                server2 = 'game2.relencia.online'
                port = 7171
                tfsPort = 7171
                g_logger.info(string.format('Prod environment: %s + %s:%d', server, server2, port))
            end
        end

        -- API/website uses relencia.online, game server uses game.relencia.online
        if isLocalEnv() then
            deriveFromServer(server)
            Services.apiBase = 'http://127.0.0.1:8082'
            Services.updater = nil -- no updater locally
        else
            -- Website API on main domain
            local websiteDomain = (getDeploymentEnv() == "stage") and 'test.relencia.online' or 'new.relencia.online'
            deriveFromServer(websiteDomain)
        end

        -- For non-local environments, ALL connections must go through g_proxy
        -- because alpha-proxy only speaks the g_proxy multiplexing protocol.
        -- Using "proxy" as host triggers g_proxy mode in Protocol::connect()
        if not isLocalEnv() and g_proxy then
            -- Register both proxy endpoints (HAProxy external addresses)
            -- Priority 1 = primary, priority 2 = secondary failover
            g_proxy.addProxy(server, port, 1)
            g_proxy.addProxy(server2, port, 2)
            g_logger.info(string.format('Registered g_proxy: %s:%d (pri=1), %s:%d (pri=2)', server, port, server2, port))

            -- Servers_init port = what gets sent to alpha-proxy in the session packet
            -- Alpha-proxy uses this to connect to TFS, so use tfsPort
            Servers_init = {
                ["proxy"] = { port = tfsPort, protocol = 860, httpLogin = false }
            }
            g_logger.info(string.format('Servers_init["proxy"].port = %d (for alpha-proxy -> TFS)', tfsPort))
        else
            -- Local dev: direct connection to TFS
            Servers_init = {
                [server] = { port = port, protocol = 860, httpLogin = false }
            }
        end

        -- Log final configuration for verification
        g_logger.info('=== Client Connection Configuration ===')
        g_logger.info(string.format('  Game Server:  %s:%d', server, port))
        g_logger.info(string.format('  API Base:     %s', tostring(Services.apiBase)))
        g_logger.info(string.format('  Updater:      %s', tostring(Services.updater)))
        g_logger.info('========================================')
    end
end

-- Note: For WASM we only set WebBridge/Servers_init after loading runtime
-- config. We do not prefill localhost defaults here to avoid accidental
-- fallbacks in production. Local dev can still supply relencia.config.json.

g_app.setName("Relencia Online");
g_app.setCompactName("relencia");
-- Use Relencia as the organization name so OS-specific
-- preference/app-data folders are created under Relencia
-- instead of the previous otcr directory.
g_app.setOrganizationName("Relencia");

g_app.hasUpdater = function()
    -- Disable updater in WebAssembly builds for local dev to avoid DNS/CORS issues.
    -- Also disable updater on macOS desktop builds (server manifest is legacy for now).
    if g_app.getBuildArch and g_app.getBuildArch() == "WASM32" then
        return false
    end
    if g_app.getOs and g_app.getOs() == "mac" then
        return false
    end
    return (Services.updater and Services.updater ~= "" and g_modules.getModule("updater"))
end

-- Log startup timestamp
g_logger.info(os.date('== application started at %b %d %Y %X'))

-- Setup write directory and logging as early as possible so all logs go to file
-- Note: PHYSFS_getPrefDir already creates an app-specific directory,
-- so we pass empty string to avoid double-nesting the app name
g_resources.setupUserWriteDir('')

-- Multi-instance logging: per-instance log files and session markers in WriteDir/logs/
-- Both logs and markers use PHYSFS WriteDir (AppData) for consistent behavior
local currentLogPath = nil  -- Full filesystem path to current log file
local currentLogFileName = nil  -- Just the filename for marker reference
local sessionUUID = nil  -- UUID for this session's marker
local sessionMarkerPath = nil  -- PHYSFS path to current session's marker

local function setupLogging()
    -- Store logs in WriteDir (AppData) - same location as session markers
    -- Uses PHYSFS for directory creation and file cleanup
    local logsDir = "logs"
    local mkdirResult = g_resources.makeDir(logsDir)
    local dirExists = g_resources.directoryExists(logsDir)
    g_logger.info(string.format("setupLogging: makeDir=%s, dirExists=%s, writeDir=%s",
        tostring(mkdirResult), tostring(dirExists), g_resources.getWriteDir()))

    -- Clean up old logs (older than 7 days)
    local files = g_resources.listDirectoryFiles(logsDir)
    local now = os.time()
    local maxAge = 7 * 24 * 60 * 60  -- 7 days
    for _, file in ipairs(files) do
        if file:match("%.log$") then
            local y, mo, d, h, mi, s = file:match("^(%d+)-(%d+)-(%d+)--(%d+)-(%d+)-(%d+)")
            if y then
                local fileTime = os.time({
                    year = tonumber(y), month = tonumber(mo), day = tonumber(d),
                    hour = tonumber(h), min = tonumber(mi), sec = tonumber(s)
                })
                if now - fileTime > maxAge then
                    g_resources.deleteFile(logsDir .. "/" .. file)
                    g_logger.info("Deleted old log: " .. file)
                end
            end
        end
    end

    -- Create new log file with date-time-PID naming
    local pid = g_platform.getProcessId()
    local timestamp = os.date("%Y-%m-%d--%H-%M-%S")
    currentLogFileName = timestamp .. "-" .. pid .. ".log"
    currentLogPath = g_resources.getWriteDir() .. logsDir .. "/" .. currentLogFileName

    g_logger.setLogFile(currentLogPath)
    g_logger.info("Log file: " .. currentLogPath)
end

-- Call setupLogging immediately so all subsequent logs go to file
setupLogging()

-- Session marker functions (called after corelib is loaded for json/HTTP access)
-- Markers track running sessions to detect crashes on next startup

local function getISOTimestamp()
    return os.date("!%Y-%m-%dT%H:%M:%SZ")
end

local function parseISOTimestamp(isoStr)
    -- Parse ISO8601 UTC timestamp like "2025-12-21T14:30:52Z"
    -- Returns epoch seconds (UTC-based)
    if not isoStr then return nil end
    local y, mo, d, h, mi, s = isoStr:match("^(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)Z?")
    if not y then return nil end

    -- os.time() interprets the table as LOCAL time, but our input is UTC.
    -- Calculate timezone offset to correct this.
    local now = os.time()
    local utcNow = os.time(os.date("!*t", now))
    local tzOffset = now - utcNow  -- seconds ahead of UTC (positive = east of UTC)

    local localInterpretation = os.time({
        year = tonumber(y), month = tonumber(mo), day = tonumber(d),
        hour = tonumber(h), min = tonumber(mi), sec = tonumber(s)
    })

    -- Adjust: localInterpretation treated UTC values as local, so it's off by tzOffset
    return localInterpretation + tzOffset
end

local function isMarkerStale(marker)
    -- A marker is stale if its heartbeat is older than 5 minutes
    -- This indicates the session crashed or was force-killed
    if not marker or not marker.heartbeat then return true end
    local heartbeatTime = parseISOTimestamp(marker.heartbeat)
    if not heartbeatTime then return true end
    local now = os.time()
    local staleThreshold = 5 * 60  -- 5 minutes
    return (now - heartbeatTime) > staleThreshold
end

local function getLogTail(logFileName, maxBytes)
    -- Read the last N bytes of a log file for crash reports
    -- Log files are stored in WriteDir/logs/ (AppData)
    maxBytes = maxBytes or 50000  -- 50KB

    local logPath = g_resources.getWriteDir() .. "logs/" .. logFileName
    local file = io.open(logPath, "r")
    if not file then return "" end

    local content = file:read("*a")
    file:close()
    if not content then return "" end

    if #content > maxBytes then
        -- Keep the END of the log (most recent entries)
        content = content:sub(-maxBytes)
        -- Find first newline to avoid partial line
        local firstNewline = content:find("\n")
        if firstNewline then
            content = content:sub(firstNewline + 1)
        end
    end
    return content
end

local function writeSessionMarker()
    -- Create a new session marker for this instance
    sessionUUID = g_crypt.genUUID()
    local logsDir = "logs"
    local markerFileName = "session-" .. sessionUUID .. ".marker"
    local writeDir = g_resources.getWriteDir()
    sessionMarkerPath = logsDir .. "/" .. markerFileName

    local marker = {
        uuid = sessionUUID,
        pid = g_platform.getProcessId(),
        log_file = currentLogFileName,
        started = getISOTimestamp(),
        heartbeat = getISOTimestamp(),
        client_version = g_app.getVersion() .. "-" .. g_app.getBuildRevision(),
        build_tag = (g_app.getBuildTag and g_app.getBuildTag()) or "unknown",
        os = g_app.getOs(),
        arch = g_app.getBuildArch()
    }

    local markerJson = json.encode(marker)
    g_resources.writeFileContents(sessionMarkerPath, markerJson)
    g_logger.info("Session marker: " .. sessionMarkerPath)

    -- Expose marker path to C++ for cleanup on exit
    if g_app.setSessionMarkerPath then
        g_app.setSessionMarkerPath(sessionMarkerPath)
    end
end

local function updateHeartbeat()
    -- Update the heartbeat timestamp in our session marker
    if not sessionMarkerPath then return end

    local ok, content = pcall(g_resources.readFileContents, sessionMarkerPath)
    if not ok or not content then return end

    local ok, marker = pcall(json.decode, content)
    if not ok or not marker then return end

    marker.heartbeat = getISOTimestamp()
    local markerJson = json.encode(marker)
    g_resources.writeFileContents(sessionMarkerPath, markerJson)
end

local crashReportsToSend = {}  -- Queue of crash reports to send (with marker paths)

local function queueCrashReport(marker, markerFilePath)
    -- Queue a crash report for sending (will be sent after modules load)
    -- Store the marker file path so we can delete it after successful delivery
    table.insert(crashReportsToSend, {
        marker = marker,
        markerFilePath = markerFilePath
    })
end

local function detectCrashes()
    -- Scan for stale session markers from previous crashed sessions
    local logsDir = "logs"
    local files = g_resources.listDirectoryFiles(logsDir)

    for _, file in ipairs(files) do
        if file:match("%.marker$") then
            local filePath = logsDir .. "/" .. file
            local ok, content = pcall(g_resources.readFileContents, filePath)
            if ok and content then
                local ok, marker = pcall(json.decode, content)
                if ok and marker and isMarkerStale(marker) then
                    g_logger.warning("Detected crashed session: " .. (marker.uuid or "unknown"))
                    -- Read the log tail from the crashed session's log file
                    if marker.log_file then
                        marker.log_tail = getLogTail(marker.log_file, 50000)
                    end
                    -- Don't delete marker yet - will be deleted after successful delivery
                    queueCrashReport(marker, filePath)
                end
            end
        end
    end
end

local heartbeatEvent = nil

local function startHeartbeat()
    -- Update heartbeat every 60 seconds
    if heartbeatEvent then return end  -- Already running
    heartbeatEvent = scheduleEvent(function()
        updateHeartbeat()
        heartbeatEvent = nil
        startHeartbeat()  -- Reschedule
    end, 60000)  -- 60 seconds
end

local function setupSessionMarkers()
    -- Called after corelib is loaded (json available)
    -- 1. Detect crashes from previous sessions
    detectCrashes()
    -- 2. Write our session marker
    writeSessionMarker()
    -- 3. Start heartbeat updates
    startHeartbeat()
end

local function sendCrashReport(markerData, logTail, reportType, markerFilePath)
    -- Send a crash/diagnostic report to the server
    -- Checks sendDiagnostics setting before sending (defaults to true if not set)
    -- markerFilePath: if provided, delete the marker file after successful delivery
    if g_settings and not g_settings.getBoolean('sendDiagnostics', true) then
        g_logger.info("Diagnostics disabled, not sending report")
        -- Still delete the marker to prevent repeated attempts
        if markerFilePath then
            g_resources.deleteFile(markerFilePath)
        end
        return
    end

    reportType = reportType or 'crash'

    -- Use version info from the marker (crashed session) not current app version
    -- This ensures accurate attribution if client was updated after the crash
    local payload = {
        report_type = reportType,
        error_type = markerData.error_type or "abnormal_exit",
        error_code = markerData.error_code,
        error_message = markerData.error_message,
        client_version = markerData.client_version or (g_app.getVersion() .. "-" .. g_app.getBuildRevision()),
        build_tag = markerData.build_tag or (g_app.getBuildTag and g_app.getBuildTag()) or "unknown",
        os = markerData.os or g_app.getOs(),
        os_version = g_platform.getOSVersion(),  -- Current OS version (marker doesn't store this)
        arch = markerData.arch or g_app.getBuildArch(),
        session_started = markerData.started,
        log_tail = logTail or markerData.log_tail or ""
    }

    -- Use Services.apiBase for the API URL
    local apiUrl = (Services.apiBase or "https://relencia.online") .. "/api/client-report"
    g_logger.info("Sending " .. reportType .. " report to " .. apiUrl)

    HTTP.postJSON(apiUrl, payload, function(response, err)
        if err then
            g_logger.warning("Failed to send " .. reportType .. " report: " .. tostring(err))
            -- Don't delete marker on failure - will retry next startup
        elseif type(response) == 'table' and response.success then
            g_logger.info("Successfully sent " .. reportType .. " report")
            -- Only delete marker after confirmed successful delivery
            if markerFilePath then
                g_resources.deleteFile(markerFilePath)
            end
        else
            -- Server returned error response (e.g. rate-limited, invalid payload)
            local errMsg = (type(response) == 'table' and response.error) or "unknown server error"
            g_logger.warning("Server rejected " .. reportType .. " report: " .. tostring(errMsg))
            -- Don't delete marker - will retry next startup
        end
    end)
end

local function sendQueuedCrashReports()
    -- Called after all modules are loaded to send queued crash reports
    if #crashReportsToSend == 0 then
        return
    end

    g_logger.info("Sending " .. #crashReportsToSend .. " queued crash report(s)")
    for _, entry in ipairs(crashReportsToSend) do
        local marker = entry.marker
        sendCrashReport(marker, marker.log_tail, 'crash', entry.markerFilePath)
    end
    crashReportsToSend = {}
end
g_logger.info("== operating system: " .. g_platform.getOSVersion())

-- print first terminal message
g_logger.info(g_app.getName() .. ' ' .. g_app.getVersion() .. ' rev ' .. g_app.getBuildRevision() .. ' (' ..
    g_app.getBuildCommit() .. ') built on ' .. g_app.getBuildDate() .. ' for arch ' ..
    g_app.getBuildArch())

-- setup lua debugger
if os.getenv("LOCAL_LUA_DEBUGGER_VSCODE") == "1" then
    require("lldebugger").start()
    g_logger.debug("Started LUA debugger.")
else
    g_logger.debug("LUA debugger not started (not launched with VSCode local-lua).")
end

-- add data directory to the search path
if not g_resources.addSearchPath(g_resources.getWorkDir() .. 'data', true) then
    g_logger.fatal('Unable to add data directory to the search path.')
end

-- add modules directory to the search path
if not g_resources.addSearchPath(g_resources.getWorkDir() .. 'modules', true) then
    g_logger.fatal('Unable to add modules directory to the search path.')
end

-- try to add mods path too
g_resources.addSearchPath(g_resources.getWorkDir() .. 'mods', true)

-- Add WorkDir as search path so we can list logs/ folder
g_resources.addSearchPath(g_resources.getWorkDir(), true)

-- search all packages
g_resources.searchAndAddPackages('/', '.otpkg', true)

-- load settings
g_configs.loadSettings('/config.otml')

g_modules.discoverModules()

-- libraries modules 0-99
g_modules.autoLoadModules(99)
g_modules.ensureModuleLoaded('corelib')
g_modules.ensureModuleLoaded('gamelib')
g_modules.ensureModuleLoaded('modulelib')
g_modules.ensureModuleLoaded("startup")

-- Setup session markers for crash detection (needs corelib for json)
setupSessionMarkers()

-- REPL server: Lua REPL over TCP for testing/scripting the client
-- Enabled for local development
if isLocalEnv() then
    dofile('/modules/repl/init.lua')
end

-- Headless mode: hide window for automated testing
if os.getenv("RELENCIA_HEADLESS") == "1" then
    -- Clean up the session marker created in the default profile before switching
    -- Otherwise it becomes stale and triggers false crash reports on next normal run
    if sessionMarkerPath then
        g_resources.deleteFile(sessionMarkerPath)
        sessionMarkerPath = nil
    end
    g_resources.setupUserWriteDir('relencia-headless-' .. os.time() .. '/')
    setupLogging()  -- Redirect logs to headless-specific folder
    setupSessionMarkers()  -- Setup markers in headless-specific folder
    connect(g_app, { onRun = function() g_window.hide() end })
end

-- After core libs exist, ensure default client version for fresh browsers
if g_app.getBuildArch and g_app.getBuildArch() == "WASM32" then
    if g_settings and g_settings.getInteger then
        local cv = g_settings.getInteger('client-version')
        if not cv or cv == 0 then
            g_settings.set('client-version', 860)
        end
    end
end

g_modules.autoLoadModules(999)
g_modules.ensureModuleLoaded('game_shaders') -- pre load

local function loadModules()
    -- client modules 100-499
    g_modules.autoLoadModules(499)
    g_modules.ensureModuleLoaded('client')

    -- Ensure default client version is sane on first run (WASM)
    if g_app.getBuildArch and g_app.getBuildArch() == "WASM32" then
        if g_settings and g_settings.getInteger then
            local cv = g_settings.getInteger('client-version')
            if not cv or cv == 0 then
                local def = 860
                g_settings.set('client-version', def)
                if g_game and g_game.setClientVersion then
                    g_game.setClientVersion(def)
                    if g_game.getClientProtocolVersion then
                        g_game.setProtocolVersion(g_game.getClientProtocolVersion(def))
                    end
                end
            end
        end
    end

    -- game modules 500-999
    g_modules.autoLoadModules(999)
    g_modules.ensureModuleLoaded('game_interface')

    -- mods 1000-9999
    g_modules.autoLoadModules(9999)
    g_modules.ensureModuleLoaded('client_mods')

    local script = '/' .. g_app.getCompactName() .. 'rc.lua'

    if g_resources.fileExists(script) then
        dofile(script)
    end

    -- auto-reload modules in dev mode
    if isLocalEnv() then
        g_modules.enableAutoReload()
    end

    -- Send any queued crash reports from previous sessions
    sendQueuedCrashReports()
end

local function startClient()
    -- run updater, must use data.zip
    if g_app.hasUpdater() then
        g_modules.ensureModuleLoaded("updater")
        return Updater.init(loadModules)
    end
    loadModules()
end

-- Optional runtime config for WASM via /relencia.config.json generated by Docker env.
if g_app.getBuildArch and g_app.getBuildArch() == "WASM32" then
    local function applyConfig(cfg)
        if type(cfg) == 'table' then
            WebBridge = WebBridge or {}
            -- Prefer explicit cfg.server if provided; else try to infer from loginHost.
            if type(cfg.server) == 'string' and cfg.server ~= '' then
                deriveFromServer(cfg.server)
            end
            if cfg.loginHost then WebBridge.loginHost = cfg.loginHost end
            if cfg.gameHost then WebBridge.gameHost = cfg.gameHost end
            if cfg.loginPort then WebBridge.loginPort = tonumber(cfg.loginPort) end
            if cfg.gamePort then WebBridge.gamePort = tonumber(cfg.gamePort) end
            if type(cfg.apiBase) == 'string' and cfg.apiBase ~= '' then
                Services.apiBase = cfg.apiBase
                local hostOnly = cfg.apiBase:match('^https?://([^/]+)/')
                if hostOnly and hostOnly ~= '' then
                    deriveFromServer(hostOnly)
                end
            end
            if cfg.updaterUrl and type(cfg.updaterUrl) == 'string' then
                Services.updater = cfg.updaterUrl
                -- Backfill server/apiBase if missing and updaterUrl is set
                if not Services.server or not Services.apiBase then
                    local hostOnly = cfg.updaterUrl:match('^https?://([^/]+)/')
                    if hostOnly then deriveFromServer(hostOnly) end
                end
            end

            -- If server still not set, try to infer from loginHost.
            if (not Services.server or Services.server == '') and type(WebBridge.loginHost) == 'string' then
                local hostOnly = tostring(WebBridge.loginHost):match('^([^/:]+)')
                if hostOnly and hostOnly ~= '' then deriveFromServer(hostOnly) end
            end

            -- If updater still not set, derive from server.
            if not Services.updater or Services.updater == '' then
                if Services.server and Services.server ~= '' then
                    Services.updater = (Services.apiBase or ('https://' .. Services.server)) .. '/index.php/api/updater.php'
                end
            end

            Servers_init = {
                [WebBridge.loginHost] = {
                    port = WebBridge.loginPort,
                    protocol = 860,
                    httpLogin = false
                }
            }
            g_logger.info(string.format("WASM cfg: login %s:%d, game %s:%d, updater %s (server=%s)",
                tostring(WebBridge.loginHost), tonumber(WebBridge.loginPort or 0), tostring(WebBridge.gameHost),
                tonumber(WebBridge.gamePort or 0), tostring(Services.updater), tostring(Services.server)))
        end
    end

    -- Prefer file written by shell.html preRun (same-origin fetch). If not
    -- present, try HTTP.getJSON once; on failure, abort.
    local function startWithConfigTable(tbl)
        local ok, cfg = pcall(function()
            if type(tbl) == 'table' then return tbl end
            return json.decode(tbl)
        end)
        if not ok then
            g_logger.fatal('WASM: Invalid relencia.config.json content; refusing to start.')
            return
        end
        applyConfig(cfg)
        startClient()
    end

    if g_resources.fileExists('/relencia.config.json') then
        startWithConfigTable(g_resources.readFileContents('/relencia.config.json'))
        return
    end

    local ok = pcall(function()
        HTTP.getJSON('/relencia.config.json', function(data, err)
            if err then
                -- Zero-config fallback: derive from location
                local host = js.global.location and js.global.location.host or 'localhost'
                -- Strip any :port; we always go through TLS 443 here
                local colon = host:find(':', 1, true)
                if colon then host = host:sub(1, colon - 1) end
                -- Set server and derive everything else from it
                deriveFromServer(host)
                local cfg = {
                    server     = host,
                    loginHost  = host .. '/ws-login',
                    loginPort  = 443,
                    gameHost   = host .. '/ws-game',
                    gamePort   = 443,
                    updaterUrl = (Services.apiBase or ('https://' .. host)) .. '/api/updater.php'
                }
                startWithConfigTable(cfg)
                return
            end
            startWithConfigTable(data)
        end)
    end)
    if ok then return end
end

startClient()
