local SCRIPT_ARGS = { ... }
local IS_BOOTSTRAPPED = false
for _, a in ipairs(SCRIPT_ARGS) do
    if a == "--bootstrapped" then IS_BOOTSTRAPPED = true end
end

local GITHUB_USER   = "Fanolcie"
local GITHUB_REPO   = "MekFusionSCADA"
local GITHUB_BRANCH = "main"
local VERSION       = "1.0.0"

local REPO_BASE = "https://raw.githubusercontent.com/" .. GITHUB_USER .. "/" .. GITHUB_REPO .. "/" .. GITHUB_BRANCH


local THEME_PALETTE = {
    [colors.black]     = 0x0a0e14,
    [colors.gray]      = 0x1a2030,
    [colors.lightGray] = 0x3b4a66,
    [colors.white]     = 0xcbccc6,
    [colors.brown]     = 0x5c6773,
    [colors.cyan]      = 0x5ccfe6,
    [colors.lime]      = 0xbae67e,
    [colors.yellow]    = 0xffcc66,
    [colors.orange]    = 0xff8f40,
    [colors.red]       = 0xf07178,
    [colors.green]     = 0x4a8f3e,
    [colors.blue]      = 0x73d0ff,
    [colors.lightBlue] = 0x8fd0ff,
    [colors.magenta]   = 0xd4bfff,
    [colors.purple]    = 0x6e5fa6,
    [colors.pink]      = 0xff9eb1,
}

local SAVED_PALETTE = {}

local function apply_theme()
    if not term.isColor or not term.isColor() then return end
    for c, hex in pairs(THEME_PALETTE) do
        local ok, r, g, b = pcall(term.getPaletteColor, c)
        if ok then SAVED_PALETTE[c] = { r = r, g = g, b = b } end
        pcall(term.setPaletteColor, c, hex)
    end
end

local function restore_theme()
    for c, rgb in pairs(SAVED_PALETTE) do
        pcall(term.setPaletteColor, c, rgb.r, rgb.g, rgb.b)
    end
end


local ROLES = {
    { id = "plc",         name = "Reactor PLC",
      desc  = "Controls one fusion reactor",
      reqs  = "Logic Adapter + Modem",
      color = colors.lightBlue },
    { id = "rtu",         name = "RTU",
      desc  = "Bridges Matrix and Tanks",
      reqs  = "Ender Modem + wired modem",
      color = colors.magenta },
    { id = "supervisor",  name = "Supervisor",
      desc  = "Facility + energy manager",
      reqs  = "Ender Modem only",
      color = colors.orange },
    { id = "coordinator", name = "Coordinator",
      desc  = "HMI dashboard on monitor",
      reqs  = "Ender Modem + Monitor",
      color = colors.lime },
}

local FILES = {
    common = {
        "common/types.lua",
        "common/util.lua",
        "common/config.lua",
        "common/log.lua",
        "common/gfx.lua",
        "common/comms.lua",
        "common/alarms.lua",
        "common/audio.lua",
        "lib/mekgfx.lua",
    },
    plc = {
        "reactor-plc/plc.lua",
        "reactor-plc/control.lua",
        "reactor-plc/renderer.lua",
    },
    rtu = {
        "rtu/rtu.lua",
    },
    supervisor = {
        "supervisor/supervisor.lua",
    },
    coordinator = {
        "coordinator/coordinator.lua",
        "coordinator/renderer.lua",
    },
}

local STARTUP = {
    plc         = "reactor-plc/startup.lua",
    rtu         = "rtu/startup.lua",
    supervisor  = "supervisor/startup.lua",
    coordinator = "coordinator/startup.lua",
}


local W, H

local function update_size()
    W, H = term.getSize()
end

local function clear_screen()
    term.setBackgroundColor(colors.black)
    term.setTextColor(colors.white)
    term.clear()
    term.setCursorPos(1, 1)
    update_size()
end

local function fill_rect(x, y, w, h, bg)
    term.setBackgroundColor(bg)
    local line = string.rep(" ", w)
    for yy = y, y + h - 1 do
        term.setCursorPos(x, yy)
        term.write(line)
    end
end


local function trunc(s, max_w)
    s = tostring(s or "")
    if max_w <= 0 then return "" end
    if #s <= max_w then return s end
    if max_w <= 1 then return s:sub(1, max_w) end
    return s:sub(1, max_w - 1) .. "\7"
end

local function put(x, y, s, fg, bg, max_w)
    s = tostring(s or "")
    local hard_limit = W - x + 1
    local lim = math.min(max_w or hard_limit, hard_limit)
    if lim < #s then s = trunc(s, lim) end
    if #s <= 0 then return end
    term.setCursorPos(x, y)
    term.setBackgroundColor(bg or colors.black)
    term.setTextColor(fg or colors.white)
    term.write(s)
end


local function wrap_text(s, max_w)
    local lines = {}
    if not s or s == "" or not max_w or max_w <= 0 then return lines end
    s = tostring(s)
    local line = ""
    for word in s:gmatch("%S+") do
        local candidate = (line == "") and word or (line .. " " .. word)
        if #candidate <= max_w then
            line = candidate
        else
            if line ~= "" then table.insert(lines, line); line = "" end
            while #word > max_w do
                table.insert(lines, word:sub(1, max_w))
                word = word:sub(max_w + 1)
            end
            line = word
        end
    end
    if line ~= "" then table.insert(lines, line) end
    return lines
end


local function put_wrap(x, y, s, fg, bg, max_w)
    local lim = max_w or (W - x)
    local lines = wrap_text(s, lim)
    for i, line in ipairs(lines) do
        put(x, y + i - 1, line, fg, bg, lim)
    end
    return #lines
end

local function center(y, s, fg, bg, w)
    w = w or W
    local sx = math.max(1, math.floor((w - #s) / 2) + 1)
    put(sx, y, s, fg, bg, w)
end


local function panel(x, y, w, h, title, header_color, right_text)
    fill_rect(x, y, w, h, colors.black)
    fill_rect(x, y, w, 1, header_color)
    if title then
        put(x + 1, y, " " .. title .. " ", colors.black, header_color)
    end
    if right_text then
        local rx = x + w - #right_text - 1
        put(rx, y, right_text .. " ", colors.black, header_color)
    end
end


local function banner()
    clear_screen()
    fill_rect(1, 1, W, 1, colors.lightGray)
    local left  = " MEKFUSION SCADA INSTALLER"
    local right = "v" .. VERSION .. " "
    put(1, 1, left, colors.black, colors.lightGray)
    put(W - #right + 1, 1, right, colors.black, colors.lightGray)
end


local function footer_hint(text)
    if H >= 3 then
        fill_rect(1, H, W, 1, colors.lightGray)
        put(2, H, text, colors.black, colors.lightGray)
    end
end


local SYMBOL = { ok = " OK ", fail = "FAIL", warn = "WARN", info = "INFO" }
local SYMBOL_COLOR = {
    ok = colors.lime, fail = colors.red,
    warn = colors.yellow, info = colors.cyan,
}

local function status_line(y, label, sym, value, value_color)
    fill_rect(1, y, W, 1, colors.black)
    if sym then
        local s = SYMBOL[sym] or "?"
        put(2, y, "[" .. s .. "]", SYMBOL_COLOR[sym] or colors.white, colors.black)
        put(2 + #s + 3, y, label, colors.white, colors.black)
    else
        put(2, y, label, colors.white, colors.black)
    end
    if value then
        local vx = W - #tostring(value) - 1
        put(vx, y, tostring(value), value_color or colors.lightGray, colors.black)
    end
end


local function prompt(y, label, hint, hide)
    fill_rect(1, y, W, 3, colors.black)
    put(2, y, label, colors.cyan, colors.black)
    if hint then
        put(2, y + 1, hint, colors.brown, colors.black)
    end
    put(2, y + 2, "> ", colors.lime, colors.black)
    term.setCursorPos(4, y + 2)
    term.setTextColor(colors.white)
    term.setBackgroundColor(colors.black)
    local input
    if hide then input = read("*") else input = read() end
    term.setCursorBlink(false)
    return input
end

local function read_default(y, label, default, hint, validator)
    while true do
        local full_hint = hint and (hint .. "    [default: " .. tostring(default) .. "]")
                                or ("[default: " .. tostring(default) .. "]")
        local input = prompt(y, label, full_hint)
        if not input or #input == 0 then return default end
        if validator then
            local ok, value_or_err = validator(input)
            if ok then return value_or_err end
            put(2, y + 3, "[ERR ] " .. tostring(value_or_err),
                colors.red, colors.black)
            sleep(1.5)
        else
            return input
        end
    end
end

local function read_yn(y, label, default_yes, hint)
    return read_default(y, label, default_yes and "y" or "n", hint, function(s)
        local l = s:lower()
        if l == "y" or l == "yes" then return true, true end
        if l == "n" or l == "no"  then return true, false end
        return false, "answer y or n"
    end)
end

local function check_env()
    local issues = {}
    if not http then table.insert(issues, "HTTP API not enabled in computercraft-server.toml") end
    if not term.isColor or not term.isColor() then
        table.insert(issues, "Computer is not color-capable (needs Advanced Computer)")
    end
    return issues
end

local function download(url, path)
    local dir = fs.getDir(path)
    if dir ~= "" and not fs.exists(dir) then fs.makeDir(dir) end

    local response = http.get(url)
    if not response then return false end

    local code = response.getResponseCode()
    local content = response.readAll()
    response.close()

    if code ~= 200 or not content or #content == 0 then
        return false, code
    end

    local file = fs.open(path, "w")
    if not file then return false end
    file.write(content)
    file.close()
    return true
end

local function test_connection()
    local url = REPO_BASE .. "/common/types.lua"
    local response = http.get(url)
    if not response then return false, 0 end
    local code = response.getResponseCode()
    response.close()
    return code == 200, code
end


local function screen_env_error(issues)
    banner()
    panel(1, 3, W, 3, "ENVIRONMENT ERROR", colors.red, "FATAL")
    local y = 7
    for _, issue in ipairs(issues) do
        status_line(y, issue, "fail")
        y = y + 1
    end
    footer_hint(" Cannot continue. Press any key to exit.")
    os.pullEvent("key")
end


local function screen_connect()
    banner()
    panel(1, 3, W, 3, "CONNECT TO REPOSITORY", colors.cyan,
        "ID " .. os.getComputerID())
    local y = 7
    status_line(y, "Testing public repository access...", "info"); y = y + 2

    local ok, code = test_connection()
    if ok then
        status_line(y, "Repository reachable", "ok", "HTTP 200", colors.lime)
        sleep(0.5)
        return true
    end

    if code == 404 then
        status_line(y, "Repository NOT FOUND (HTTP 404)", "fail"); y = y + 1
        status_line(y, "Check GITHUB_USER/GITHUB_REPO in install.lua", "warn")
    else
        status_line(y, "HTTP " .. tostring(code) .. " - cannot reach repo", "fail"); y = y + 1
        status_line(y, "Check internet / computercraft HTTP whitelist", "warn")
    end
    footer_hint(" Press any key to exit.")
    os.pullEvent("key")
    return false
end


local function detect_existing_role()
    if fs.exists("/reactor-plc/plc.lua") then return "plc"
    elseif fs.exists("/rtu/rtu.lua") then return "rtu"
    elseif fs.exists("/supervisor/supervisor.lua") then return "supervisor"
    elseif fs.exists("/coordinator/coordinator.lua") then return "coordinator"
    end
    return nil
end

local function screen_pick_role()
    local existing = detect_existing_role()

    while true do
        banner()
        panel(1, 3, W, 1, "SELECT ROLE", colors.cyan,
            existing and ("found:" .. existing) or "no install")

        local y = 5
        for i, role in ipairs(ROLES) do
            put(2, y, "[" .. i .. "]", colors.lime, colors.black, 3)
            put(6, y, role.name, role.color, colors.black, W - 7)
            put(6, y + 1, role.desc, colors.lightGray, colors.black, W - 7)
            y = y + 2
        end

        put(2, y, "[5]", colors.yellow, colors.black, 3)
        put(6, y, "Update existing", colors.yellow, colors.black, W - 7)
        if existing then
            put(6, y + 1, "re-download files for " .. existing, colors.lightGray, colors.black, W - 7)
        else
            put(6, y + 1, "(no install detected)", colors.brown, colors.black, W - 7)
        end
        y = y + 3

        put(2, y,     "Choice [1-5]:", colors.cyan, colors.black, W - 2)
        put(2, y + 1, "> ",            colors.lime, colors.black, 2)
        term.setCursorPos(4, y + 1)
        term.setTextColor(colors.white)
        term.setBackgroundColor(colors.black)
        local input = read()

        local choice = tonumber(input)
        if choice and choice >= 1 and choice <= 5 then
            return choice, existing
        end
    end
end

local function screen_confirm_role(role)
    banner()
    panel(1, 3, W, 1, "ROLE: " .. role.name:upper(), role.color)

    local LABEL_X = 2
    local VALUE_X = 14

    local y = 5
    put(LABEL_X, y, "Role",  colors.cyan, colors.black, 11)
    put(VALUE_X, y, role.name, role.color, colors.black, W - VALUE_X)
    y = y + 1
    put(LABEL_X, y, "Job",   colors.cyan, colors.black, 11)
    put(VALUE_X, y, role.desc, colors.white, colors.black, W - VALUE_X)
    y = y + 1
    put(LABEL_X, y, "Needs", colors.cyan, colors.black, 11)
    put(VALUE_X, y, role.reqs, colors.white, colors.black, W - VALUE_X)
    y = y + 2

    local file_count = #FILES.common + (FILES[role.id] and #FILES[role.id] or 0) + 2
    put(LABEL_X, y, "Files",  colors.cyan, colors.black, 11)
    put(VALUE_X, y, tostring(file_count) .. " files to download",
        colors.lightGray, colors.black, W - VALUE_X)

    local proceed = read_yn(H - 4, "Proceed with installation?", true)
    return proceed
end

local function progress_bar(x, y, w, pct, fg, bg)
    pct = math.max(0, math.min(100, pct))
    local f = math.floor(pct / 100 * w + 0.5)
    fill_rect(x, y, w, 1, bg)
    if f > 0 then fill_rect(x, y, f, 1, fg) end
    local s = string.format("%d%%", math.floor(pct + 0.5))
    local sx = x + math.floor((w - #s) / 2)
    for i = 1, #s do
        local cx = sx + i - 1
        if cx >= x and cx < x + w then
            local inside_fill = (cx - x) < f
            term.setCursorPos(cx, y)
            term.setBackgroundColor(inside_fill and fg or bg)
            term.setTextColor(colors.white)
            term.write(s:sub(i, i))
        end
    end
end

local function screen_install(role_id)
    banner()
    panel(1, 3, W, 3, "INSTALLING " .. role_id:upper(), colors.lime)

    local file_list = {}
    for _, f in ipairs(FILES.common) do
        table.insert(file_list, { src = f, dst = "/" .. f })
    end
    if FILES[role_id] then
        for _, f in ipairs(FILES[role_id]) do
            table.insert(file_list, { src = f, dst = "/" .. f })
        end
    end
    local startup_src = STARTUP[role_id]
    if startup_src then
        table.insert(file_list, { src = startup_src, dst = "/startup.lua" })
    end
    table.insert(file_list, { src = "install.lua", dst = "/install.lua" })

    local total = #file_list
    local ok_count = 0
    local fail_count = 0
    local fail_list = {}

    local bar_y       = 7
    local bar_w       = W - 8
    local bar_x       = 4
    local current_y   = bar_y + 2
    local recent_y    = bar_y + 4
    local recent_rows = math.max(3, H - recent_y - 3)

    local recent = {}

    for i, entry in ipairs(file_list) do
        local url = REPO_BASE .. "/" .. entry.src

        local short = entry.src
        if #short > W - 12 then short = "..." .. short:sub(-(W - 15)) end
        fill_rect(1, current_y, W, 1, colors.black)
        put(2, current_y, "> " .. short, colors.lightBlue, colors.black)

        local ok = download(url, entry.dst)
        local sym = ok and "ok" or "fail"
        if ok then ok_count = ok_count + 1
        else fail_count = fail_count + 1; table.insert(fail_list, entry.src) end

        table.insert(recent, 1, { src = short, sym = sym })
        while #recent > recent_rows do table.remove(recent) end
        fill_rect(1, recent_y, W, recent_rows, colors.black)
        for ri, r in ipairs(recent) do
            local ry = recent_y + ri - 1
            local sc = SYMBOL_COLOR[r.sym] or colors.white
            put(2, ry, "[" .. SYMBOL[r.sym] .. "]", sc, colors.black)
            put(9, ry, r.src, colors.lightGray, colors.black)
        end

        local bar_fg = colors.lime
        if fail_count > 0 then bar_fg = colors.orange end
        progress_bar(bar_x, bar_y, bar_w, (i / total) * 100, bar_fg, colors.gray)
        put(bar_x, bar_y + 1,
            string.format("%d / %d files", i, total),
            colors.brown, colors.black)
    end

    local result_y = H - 3
    if fail_count == 0 then
        status_line(result_y, "All " .. ok_count .. " files downloaded successfully", "ok")
    else
        status_line(result_y, fail_count .. " of " .. total .. " files FAILED to download", "fail")
        local fy = result_y + 1
        for _, f in ipairs(fail_list) do
            if fy < H then
                put(4, fy, "- " .. f, colors.red, colors.black)
                fy = fy + 1
            end
        end
    end

    sleep(0.6)
    return fail_count == 0
end


local function config_path(role_id)
    if role_id == "plc" then return "/config/plc.json"
    elseif role_id == "rtu" then return "/config/rtu.json"
    elseif role_id == "supervisor" then return "/config/svr.json"
    elseif role_id == "coordinator" then return "/config/crd.json"
    end
end

local function load_existing_config(role_id)
    local path = config_path(role_id)
    if not path or not fs.exists(path) then return nil end
    local f = fs.open(path, "r")
    if not f then return nil end
    local content = f.readAll()
    f.close()
    local ok, data = pcall(textutils.unserialiseJSON, content)
    if ok and type(data) == "table" then return data end
    return nil
end

local function save_config(role_id, cfg)
    local path = config_path(role_id)
    if not path then return end
    if not fs.exists("/config") then fs.makeDir("/config") end
    local f = fs.open(path, "w")
    if f then f.write(textutils.serialiseJSON(cfg)); f.close() end
end

local function detect_rtu_peripherals()
    local found = { tanks = {}, matrix = {} }
    for _, name in ipairs(peripheral.getNames()) do
        local ptype = (peripheral.getType(name) or ""):lower()
        local kind
        if ptype:find("induction") then
            kind = "matrix"
        elseif ptype:find("chemicaltank") or ptype:find("chemical_tank") then
            kind = "tank"
        elseif ptype ~= "modem" and ptype ~= "monitor"
               and ptype ~= "computer" and ptype ~= "drive" then
            local p = peripheral.wrap(name)
            if p then
                if p.getEnergy and p.getMaxEnergy then
                    kind = "matrix"
                elseif p.getStored and p.getFilledPercentage then
                    local ok, stored = pcall(p.getStored)
                    if ok and type(stored) == "table" and stored.name then
                        kind = "tank"
                    end
                end
            end
        end
        if kind == "tank" then
            local p = peripheral.wrap(name)
            local chem = "?"
            if p and p.getStored then
                local ok, s = pcall(p.getStored)
                if ok and type(s) == "table" then chem = s.name or "?" end
            end
            table.insert(found.tanks, { name = name, chem = chem })
        elseif kind == "matrix" then
            table.insert(found.matrix, { name = name })
        end
    end
    return found
end

local function ask(label, hint, default, validator)
    banner()
    panel(1, 3, W, 3, "CONFIGURE", colors.cyan)
    put(2, 7, label, colors.cyan, colors.black)
    local y = 8
    if hint then
        local lines = put_wrap(2, y, hint, colors.brown, colors.black, W - 3)
        y = y + lines
    end
    put(2, y, "Default: " .. tostring(default), colors.brown, colors.black)
    local prompt_y = y + 2
    local err_y    = prompt_y + 2
    while true do
        put(2, prompt_y, "> ", colors.lime, colors.black)
        fill_rect(4, prompt_y, W - 4, 1, colors.black)
        term.setCursorPos(4, prompt_y)
        term.setTextColor(colors.white)
        term.setBackgroundColor(colors.black)
        local input = read()
        if not input or #input == 0 then

            if validator then
                local ok, val = validator(tostring(default))
                if ok then return val end
            end
            return default
        end
        if validator then
            local ok, value_or_err = validator(input)
            if ok then return value_or_err end
            fill_rect(2, err_y, W - 2, 1, colors.black)
            put(2, err_y, "[ERR ] " .. tostring(value_or_err), colors.red, colors.black)
        else
            return input
        end
    end
end

local function validate_int_range(lo, hi)
    return function(s)
        local n = tonumber(s)
        if not n then return false, "must be a number" end
        if n < lo or n > hi then return false, "range " .. lo .. "-" .. hi end
        return true, math.floor(n)
    end
end

local function validate_yn(s)
    local l = s:lower()
    if l == "y" or l == "yes" then return true, true end
    if l == "n" or l == "no"  then return true, false end
    return false, "y or n"
end

local function validate_string(s)
    return true, s
end

local function validate_even_range(lo, hi)
    return function(s)
        local n = tonumber(s)
        if not n then return false, "must be a number" end
        if n < lo or n > hi then return false, "range " .. lo .. "-" .. hi end
        if n % 2 ~= 0 then return false, "must be even" end
        return true, math.floor(n)
    end
end

local function validate_float_range(lo, hi)
    return function(s)
        local n = tonumber(s)
        if not n then return false, "must be a number" end
        if n < lo or n > hi then return false, "range " .. lo .. "-" .. hi end
        return true, n
    end
end

local REQUIRED_KEYS = {
    plc         = { "unit_id", "standalone", "skip_calibration", "channel_offset", "facility_token" },
    rtu         = { "unit_id", "reactor_unit_id", "channel_offset", "facility_token" },
    supervisor  = { "facility_name", "injection_max", "energy_low", "energy_high", "channel_offset", "facility_token" },
    coordinator = { "monitor_scale", "facility_name", "channel_offset", "facility_token" },
}

local function config_complete(role_id, cfg)
    for _, key in ipairs(REQUIRED_KEYS[role_id] or {}) do
        if cfg[key] == nil then return false end
    end
    return true
end

local function screen_configure(role_id)
    local cfg = load_existing_config(role_id) or {}

    if config_complete(role_id, cfg) then
        banner()
        panel(1, 3, W, 3, "CONFIGURE", colors.cyan)
        put(2, 7, "Existing config is complete.",
            colors.lime, colors.black)
        put_wrap(2, 8,
            "No new fields to ask about. Edit " .. config_path(role_id)
                .. " manually to change settings.",
            colors.brown, colors.black, W - 3)
        sleep(1.8)
        return
    end

    local function ask_missing(key, label, hint, default, validator)
        if cfg[key] ~= nil then return end
        cfg[key] = ask(label, hint, default, validator)
    end

    if role_id == "plc" then
        ask_missing("unit_id", "PLC unit ID",
            "Each PLC needs a unique ID per reactor (1-99).",
            1, validate_int_range(1, 99))
        ask_missing("standalone", "Operate standalone (no network)?",
            "y = single reactor, no supervisor / n = part of facility",
            "n", validate_yn)
        ask_missing("skip_calibration", "Skip calibration on startup?",
            "Recommended yes (skip calibration speeds up boot)",
            "y", validate_yn)

    elseif role_id == "rtu" then

        local det = detect_rtu_peripherals()
        banner()
        panel(1, 3, W, 3, "DETECTED PERIPHERALS", colors.cyan)
        local y = 7
        put(2, y, "Chemical tanks: " .. tostring(#det.tanks),
            (#det.tanks > 0) and colors.lime or colors.brown,
            colors.black); y = y + 1
        for _, t in ipairs(det.tanks) do
            put(4, y, "- " .. t.name .. "  [" .. tostring(t.chem) .. "]",
                colors.lightGray, colors.black); y = y + 1
        end
        y = y + 1
        put(2, y, "Induction matrix: " .. tostring(#det.matrix),
            (#det.matrix > 0) and colors.lime or colors.brown,
            colors.black); y = y + 1
        for _, m in ipairs(det.matrix) do
            put(4, y, "- " .. m.name, colors.lightGray, colors.black); y = y + 1
        end
        y = y + 2
        if #det.tanks == 0 and #det.matrix == 0 then
            put_wrap(2, y,
                "Nothing detected yet. Wire peripherals later, then re-run install.",
                colors.yellow, colors.black, W - 3)
        end
        put(2, H - 3, "Press any key to continue...",
            colors.lightGray, colors.black)
        os.pullEvent("key")

        ask_missing("unit_id", "RTU unit ID",
            "Each RTU needs a unique ID across the network (1-99).",
            1, validate_int_range(1, 99))

        if cfg.reactor_unit_id == nil then
            if #det.tanks == 0 and #det.matrix > 0 then
                cfg.reactor_unit_id = 0
                banner()
                panel(1, 3, W, 3, "CONFIGURE", colors.cyan)
                put(2, 7, "Reactor routing: 0 (facility-wide)",
                    colors.lime, colors.black)
                put_wrap(2, 8,
                    "Matrix-only RTU - no chemical-tank routing needed.",
                    colors.brown, colors.black, W - 3)
                sleep(1.5)
            else
                cfg.reactor_unit_id = ask(
                    "Reactor this RTU's tanks belong to",
                    "Default = this RTU's ID. 0 = no PLC routing.",
                    cfg.unit_id or 1, validate_int_range(0, 99))
            end
        end

    elseif role_id == "supervisor" then
        ask_missing("facility_name", "Facility name",
            "Shown in the coordinator HMI header",
            "Fusion Facility", validate_string)
        ask_missing("injection_max", "Maximum injection rate (mB/t)",
            "Upper limit for automatic energy control (2-500, even)",
            500, validate_even_range(2, 500))
        ask_missing("energy_low",  "Energy LOW threshold (%)",
            "Below this matrix fill, AUTO drives burn rate to MAX. Default 90.",
            90, validate_int_range(0, 100))
        ask_missing("energy_high", "Energy HIGH threshold (%)",
            "Above this matrix fill, AUTO drives burn rate to MIN. Must be > LOW. Default 98.",
            98, validate_int_range(1, 100))

    elseif role_id == "coordinator" then
        ask_missing("monitor_scale", "Monitor text scale",
            "0.5 = small text (more info), 1.0 = large text",
            0.5, validate_float_range(0.5, 5))
        ask_missing("facility_name", "Facility name",
            "Shown in the HMI header",
            "Fusion Facility", validate_string)
    end

    if role_id ~= "plc" or cfg.standalone ~= true then
        ask_missing("channel_offset", "Network channel offset",
            "Pick the same value for every computer in this facility (PLC, RTU, supervisor, coordinator). Use a unique number per facility on a shared server. 0 = default.",
            0, validate_int_range(0, 60000))
        ask_missing("facility_token", "Facility token (shared secret)",
            "Any string; identical on every role in this facility. Receivers DROP packets whose token differs - so even if another facility on the server picks the same channel offset their commands can't reach your reactors. Leave empty to disable auth.",
            "", validate_string)
    else
        cfg.channel_offset = 0
        cfg.facility_token = ""
    end

    save_config(role_id, cfg)

    banner()
    panel(1, 3, W, 3, "CONFIGURATION SAVED", colors.lime)
    local y = 7
    put(2, y, "Config file:", colors.cyan, colors.black)
    put(16, y, config_path(role_id), colors.white, colors.black); y = y + 2
    put(2, y, "Values:", colors.cyan, colors.black); y = y + 1
    for k, v in pairs(cfg) do
        put(4, y, k, colors.brown, colors.black)
        put(22, y, tostring(v), colors.white, colors.black)
        y = y + 1
        if y >= H - 3 then break end
    end
    sleep(1.2)
end

local function screen_ready(role_id)
    banner()
    panel(1, 3, W, 1, "READY", colors.lime, role_id:upper())

    local LABEL_X = 2
    local VALUE_X = 11

    local y = 5
    put(LABEL_X, y, "Status",  colors.cyan, colors.black, 9)
    put(VALUE_X, y, "Installation complete", colors.lime, colors.black, W - VALUE_X)
    y = y + 2

    put(LABEL_X, y, "Startup", colors.cyan, colors.black, 9)
    put(VALUE_X, y, "/startup.lua (auto on boot)", colors.white, colors.black, W - VALUE_X)
    y = y + 1

    local log_name = role_id
    if role_id == "supervisor" then log_name = "svr" end
    if role_id == "coordinator" then log_name = "crd" end
    put(LABEL_X, y, "Log",     colors.cyan, colors.black, 9)
    put(VALUE_X, y, "/log/" .. log_name .. ".log", colors.white, colors.black, W - VALUE_X)
    y = y + 1
    put(LABEL_X, y, "Update",  colors.cyan, colors.black, 9)
    put(VALUE_X, y, "run 'install' then [5]", colors.white, colors.black, W - VALUE_X)
    y = y + 2

    put(LABEL_X, y, "Keys",    colors.cyan, colors.black, 9)
    if role_id == "plc" then
        put(VALUE_X, y, "Q exit  A mode  S/C rescan  D diag",
            colors.lightGray, colors.black, W - VALUE_X)
        y = y + 1
        put(VALUE_X, y, "Up/Dn adjust target injection",
            colors.lightGray, colors.black, W - VALUE_X)
    elseif role_id == "rtu" then
        put(VALUE_X, y, "Q exit  R rescan  D diag",
            colors.lightGray, colors.black, W - VALUE_X)
    elseif role_id == "supervisor" then
        put(VALUE_X, y, "Q exit  D diagnostics",
            colors.lightGray, colors.black, W - VALUE_X)
    elseif role_id == "coordinator" then
        put(VALUE_X, y, "Q exit  O overview  1-9 detail  D diag",
            colors.lightGray, colors.black, W - VALUE_X)
    end
    y = y + 1
    put(LABEL_X, y, "!", colors.yellow, colors.black, 9)
    local lines = put_wrap(VALUE_X, y,
        "REBOOT REQUIRED. New files are on disk but old code is still in memory.",
        colors.yellow, colors.black, W - VALUE_X)
    y = y + math.max(1, lines) + 1

    local reboot = read_yn(y, "Reboot now to launch role?", true)
    if reboot then os.reboot() end
end

local function self_update()
    if IS_BOOTSTRAPPED then return false end

    banner()
    panel(1, 3, W, 3, "SELF-UPDATE  v" .. VERSION, colors.cyan)
    put(2, 7, "Checking GitHub for newer install.lua...", colors.white, colors.black)

    local url = REPO_BASE .. "/install.lua"
    local response = http.get(url)
    if not response then
        put(2, 9, "[ERR] http.get returned nil  (no network?)",
            colors.red, colors.black)
        sleep(2)
        return false
    end

    local code = response.getResponseCode()
    local fresh = response.readAll()
    response.close()
    if code ~= 200 or not fresh or #fresh == 0 then
        put(2, 9, "[ERR] HTTP " .. tostring(code),
            colors.red, colors.black)
        sleep(2)
        return false
    end

    local on_disk = fs.exists("/install.lua")
    local current
    if on_disk then
        local f = fs.open("/install.lua", "r")
        if f then current = f.readAll(); f.close() end
    end

    if not on_disk then
        local f = fs.open("/install.lua", "w")
        if f then f.write(fresh); f.close() end
        put(2, 9, "[OK] Wrote /install.lua  (first run)",
            colors.lime, colors.black)
        sleep(0.6)
        return false
    end

    if current == fresh then
        put(2, 9, "[OK] Already up-to-date (v" .. VERSION .. ")",
            colors.lime, colors.black)
        sleep(0.6)
        return false
    end

    local f = fs.open("/install.lua", "w")
    if not f then return false end
    f.write(fresh)
    f.close()

    banner()
    panel(1, 3, W, 3, "INSTALLER UPDATED", colors.lime)
    put(2, 7, "Pulled fresh install.lua from GitHub.",
        colors.lime, colors.black)
    put(2, 9, "Re-launching new version...",
        colors.white, colors.black)
    sleep(1.2)

    shell.run("/install.lua", "--bootstrapped")
    return true
end


local function main()
    apply_theme()
    update_size()

    local issues = check_env()
    if #issues > 0 then
        screen_env_error(issues)
        return
    end

    if not screen_connect() then return end

    if self_update() then return end

    local choice, existing = screen_pick_role()

    if choice == 5 then
        if not existing then
            banner()
            panel(1, 3, W, 3, "UPDATE FAILED", colors.red)
            status_line(7, "No existing installation on this computer", "fail")
            status_line(8, "Install a role first using options 1-4", "warn")
            footer_hint(" Press any key to exit.")
            os.pullEvent("key")
            return
        end
        if screen_install(existing) then
            screen_configure(existing)
            screen_ready(existing)
        end
        return
    end

    local role = ROLES[choice]
    if not screen_confirm_role(role) then
        banner()
        panel(1, 3, W, 3, "ABORTED", colors.brown)
        status_line(7, "Installation cancelled by user", "info")
        sleep(1)
        return
    end

    if screen_install(role.id) then
        local OTHER_ROLE_DIRS = {
            plc         = "/reactor-plc",
            rtu         = "/rtu",
            supervisor  = "/supervisor",
            coordinator = "/coordinator",
        }
        for rid, dir in pairs(OTHER_ROLE_DIRS) do
            if rid ~= role.id then
                if fs.exists(dir) then pcall(fs.delete, dir) end
                local cp = config_path(rid)
                if cp and fs.exists(cp) then pcall(fs.delete, cp) end
            end
        end
        local cpath = config_path(role.id)
        if cpath and fs.exists(cpath) then pcall(fs.delete, cpath) end
        screen_configure(role.id)
        screen_ready(role.id)
    else
        banner()
        panel(1, 3, W, 3, "INSTALLATION FAILED", colors.red)
        status_line(7, "Some files could not be downloaded.", "fail")
        status_line(8, "Check network/repository and try again.", "warn")
        footer_hint(" Press any key to exit.")
        os.pullEvent("key")
    end
end

local ok, err = pcall(main)
restore_theme()
clear_screen()
if not ok then
    term.setTextColor(colors.red)
    print("INSTALLER CRASHED:")
    print(tostring(err))
else
    term.setTextColor(colors.white)
    print("Installer exited.")
end
