// Stargate Gate Controller Skeleton v1
//
// Purpose:
// - Provide a compact Atlantis-style dialing flow.
// - Consume DHD v2 dial requests.
// - Emit SG_GATE_EVENT updates for lights, sounds, and status display.
// - Request ZPM power before opening a wormhole.
//
// Works with:
// - stargate_dhd_controller_v2.lsl
// - stargate_gate_event_dispatcher_v1.lsl
// - stargate_zpm_power_controller_v2.lsl

integer apiChannel = -708004;     // optional bridge inbound
integer apiChannelRecv = -708005; // DHD outbound -> gate inbound
integer powerAPIchan = -809005;
integer localDialCmdChannel = -779904; // owner local chat dial shortcut
integer gateAdminChannel = -779905; // owner gate naming and directory management
integer gateRegistryChannel = -708007; // native same-region gate registry

integer listenApi;
integer listenDhd;
integer listenPower;
integer listenLocalDial;
integer listenGateAdmin;
integer listenGateRegistry;
integer listenMenu;

integer menuChannel = 0;
string pendingMenuInput = "";
integer scriptStandby = FALSE;
list controllerScriptMatches = [];
string controllerPreferredScript = "";

string SCRIPT_CANON_NAME = "stargate_gate_controller_skeleton_v1.lsl";
string SCRIPT_FALLBACK_NAME = "stargate_gate_controller_skeleton_v1";

integer STATE_IDLE = 0;
integer STATE_DIALING = 1;
integer STATE_AUTH_PENDING = 2;
integer STATE_ACTIVE = 3;
integer STATE_COOLDOWN = 4;
integer STATE_API_PENDING = 5;

integer SENSOR_MODE_NONE = 0;
integer SENSOR_MODE_IRIS_CLEANUP = 1;
integer sensorMode = 0;
float irisCleanupRangeMeters = 25.0;
float irisCleanupScanIntervalSeconds = 0.20;
integer irisCleanupWatchActive = FALSE;
integer irisCleanupWatchUntilUnix = 0;

integer gateState = 0;

integer apiOffloadRouting = FALSE;
integer apiOffloadSecurity = TRUE;
integer allowLocalFallback = TRUE;
integer allowExternalSecurityCommands = FALSE;
integer allowApiIrisCommands = FALSE;
integer irisBootMuteSeconds = 20;
integer irisBootMuteUntilUnix = 0;
float apiAuthTimeoutSeconds = 4.0;
integer apiAuthWaiting = FALSE;
integer authPendingUntilUnix = 0;

string EH_OBJECT_NAME = "_eh";
vector EH_REZ_OFFSET = <0.0, 0.0, 0.0>;
integer ehChannel = 0;
integer ehActive = FALSE;

string IRIS_OBJECT_NAME = "_iris";
string IRIS_OBJECT_FALLBACK = "_lris";
vector IRIS_REZ_OFFSET = <0.0, 0.0, 0.0>;
integer irisChannel = 0;
integer irisActive = FALSE;
integer irisMissingWarned = FALSE;

string SHIELD_OBJECT_NAME = "_shield";
vector SHIELD_REZ_OFFSET = <0.0, 0.0, 0.0>;
integer shieldChannel = 0;
integer shieldActive = FALSE;
float ehDiameter = 5.15;
float shieldDiameter = 5.0;

string pendingMode = "manual";
string pendingAddress = "-";
string pendingSim = "-";
string pendingGrid = "-";
string pendingGridId = "";
string pendingDialType = "LOCAL";
string pendingDestGateName = "";
string pendingDestGateShort = "";
vector pendingDestPos = ZERO_VECTOR;
integer pendingDestPosValid = FALSE;
rotation pendingDestRot = ZERO_ROTATION;
integer pendingDestRotValid = FALSE;
integer activeSessionIncoming = FALSE;

string gateDisplayName = "Stargate";
string gateShortName = "SG";
string gateDialRoute = "1,2,3,4,5,6,0";
string gateDialMode = "local";


float sameRegionMinDistanceMeters = 100.0;
integer knownGateTtlSeconds = 600;
integer registryResponseThrottleSeconds = 10;
integer lastRegistryAnnounceUnix = 0;

// gridProfiles stride: [grid_id, endpoint]
list gridProfiles = [];
// dialDirectory stride: [short_name, full_name, route_csv, mode, dest_sim, grid_id]
list dialDirectory = [];
// knownRegionGates stride: [short_name, full_name, pos_vector_string, rot_string, last_seen_unix]
list knownRegionGates = [];

list pendingSymbols = [];   // symbols without final 0 engage
integer pendingChevron = 0; // 1..N
integer dialPhase = 0;      // 0 charge, 1 lock

float chargeStepSeconds = 0.35;
float lockStepSeconds = 0.75;
float activeSeconds = 60.0;
float failCooldownSeconds = 3.0;

float localDialPowerCost = 50.0;
float hypergridPowerCost = 500.0;
float dhdCommandMaxDistanceMeters = 30.0;

// Set TRUE if your gate should keep shield rezzed and up on boot.
integer START_WITH_SHIELD_UP = FALSE;

integer debug = FALSE;

say_debug(string m)
{
    if (debug) llOwnerSay("[GATEv1] " + m);
}

float sender_distance_meters(key senderId)
{
    list details = llGetObjectDetails(senderId, [OBJECT_POS]);
    if (llGetListLength(details) == 0) return 999999.0;
    return llVecDist(llGetPos(), llList2Vector(details, 0));
}

integer accept_nearby_dhd_sender(key senderId, string verb)
{
    float dist = sender_distance_meters(senderId);
    if (dist > dhdCommandMaxDistanceMeters)
    {
        say_debug("Ignoring DHD|" + verb + " from far sender " + (string)senderId + " dist=" + (string)dist + "m");
        return FALSE;
    }
    return TRUE;
}

send_address_book_response(key requesterId)
{
    // Generate a unique gate ID for this gate
    string gateId = llGetObjectName() + "_" + llGetSubString((string)llGetKey(), 0, 7);
    
    // For now, return empty entries. Gate admins should configure their local address books
    // via the API bridge or by maintaining a local address book list.
    // Format: [{"target_short":"LABEL","name":"Full Name","symbols_csv":"1,2,3,4,5,6,0"}]
    
    string entries = "";
    // Example: entries = "{\"target_short\":\"CAST\",\"name\":\"Castle\",\"symbols_csv\":\"1,3,5,8,13,21,34,0\"}";
    
    string jsonResponse = "{\"event\":\"address_book\",\"data\":{\"gate_id\":\"" + gateId + "\",\"entries\":[" + entries + "]}}";
    
    llRegionSayTo(requesterId, -708004, jsonResponse);
    
    say_debug("Sent ADDRESS_BOOK response to " + (string)requesterId);
}

integer is_targeting_this_gate(string targetShort, string targetName)
{
    string ts = llToLower(llStringTrim(targetShort, STRING_TRIM));
    string tn = llToLower(llStringTrim(targetName, STRING_TRIM));

    if (ts != "" && ts == llToLower(gateShortName)) return TRUE;
    if (tn != "" && (tn == llToLower(gateDisplayName) || tn == llToLower(llGetObjectName()))) return TRUE;
    return FALSE;
}

integer known_gate_index_by_fullname(string fullName)
{
    integer i = 0;
    integer n = llGetListLength(knownRegionGates);
    string want = llToLower(llStringTrim(fullName, STRING_TRIM));
    for (; i < n; i += 5)
    {
        if (llToLower(llList2String(knownRegionGates, i + 1)) == want) return i;
    }
    return -1;
}

resolve_pending_destination_position()
{
    pendingDestPosValid = FALSE;
    pendingDestPos = ZERO_VECTOR;
    pendingDestRotValid = FALSE;
    pendingDestRot = ZERO_ROTATION;

    prune_stale_known_gates();

    integer idx = -1;
    if (llStringTrim(pendingDestGateShort, STRING_TRIM) != "")
    {
        idx = known_gate_index(pendingDestGateShort);
    }

    if (idx == -1 && llStringTrim(pendingDestGateName, STRING_TRIM) != "")
    {
        idx = known_gate_index_by_fullname(pendingDestGateName);
    }

    if (idx != -1)
    {
        pendingDestPos = (vector)llList2String(knownRegionGates, idx + 2);
        pendingDestPosValid = TRUE;

        string rotRaw = llStringTrim(llList2String(knownRegionGates, idx + 3), STRING_TRIM);
        if (rotRaw != "")
        {
            pendingDestRot = (rotation)rotRaw;
            pendingDestRotValid = TRUE;
        }
    }
}

emit_event(string payload)
{
    // Keep event payload in the string channel; non-UUID key payloads can be dropped.
    llMessageLinked(LINK_SET, 0, "SG_GATE_EVENT|" + payload, NULL_KEY);
}

request_api_route_auth()
{
    // API bridge contract (region channel relay):
    // API|ROUTE_AUTH|REQ|source_short|mode|route|dest_sim|grid_endpoint|grid_id|target_short
    llSay(apiChannel,
        "API|ROUTE_AUTH|REQ|" +
        gateShortName + "|" +
        pendingDialType + "|" +
        pendingAddress + "|" +
        pendingSim + "|" +
        pendingGrid + "|" +
        pendingGridId + "|" +
        pendingDestGateShort
    );
}

integer next_private_channel()
{
    return -700000 - (integer)llFrand(2000000.0);
}

string normalize_script_key(string s)
{
    string n = llToLower(llStringTrim(s, STRING_TRIM));
    integer dot = llSubStringIndex(n, ".lsl");
    if (dot > 0) n = llGetSubString(n, 0, dot - 1);

    string out = "";
    integer i = 0;
    integer len = llStringLength(n);
    for (; i < len; ++i)
    {
        string ch = llGetSubString(n, i, i);
        if ((ch >= "a" && ch <= "z") || (ch >= "0" && ch <= "9")) out += ch;
    }
    return out;
}

integer script_is_gate_controller_family(string scriptName)
{
    string k = normalize_script_key(scriptName);
    if (llSubStringIndex(k, "stargategatecontroller") != -1) return TRUE;
    if (llSubStringIndex(k, "gatecontroller") != -1) return TRUE;
    return FALSE;
}

integer should_run_this_script()
{
    string preferred = "";
    integer i = 0;
    integer n = llGetInventoryNumber(INVENTORY_SCRIPT);
    integer matchCount = 0;
    controllerScriptMatches = [];
    controllerPreferredScript = "";

    for (; i < n; ++i)
    {
        string sn = llGetInventoryName(INVENTORY_SCRIPT, i);
        if (!script_is_gate_controller_family(sn)) jump continue_scan;

        matchCount += 1;
        controllerScriptMatches += [sn];
        if (llToLower(sn) == llToLower(SCRIPT_CANON_NAME)) preferred = sn;

@continue_scan;
    }

    if (matchCount <= 1) return TRUE;

    if (preferred == "" && llGetInventoryType(SCRIPT_FALLBACK_NAME) == INVENTORY_SCRIPT)
    {
        preferred = SCRIPT_FALLBACK_NAME;
    }

    if (preferred == "")
    {
        i = 0;
        for (; i < n; ++i)
        {
            string sn2 = llGetInventoryName(INVENTORY_SCRIPT, i);
            if (!script_is_gate_controller_family(sn2)) jump continue_scan2;
            if (preferred == "" || llToLower(sn2) < llToLower(preferred)) preferred = sn2;
@continue_scan2;
        }
    }

    controllerPreferredScript = preferred;

    return (llToLower(llGetScriptName()) == llToLower(preferred));
}

show_owner_menu()
{
    if (menuChannel == 0)
    {
        menuChannel = next_private_channel();
    }

    llDialog(
        llGetOwner(),
        "Gate admin menu\nShort: " + gateShortName + "\nName: " + gateDisplayName,
        ["Set Alias", "Set Name", "Show", "Cancel"],
        menuChannel
    );
}

show_gate_status()
{
    prune_stale_known_gates();

    llRegionSayTo(
        llGetOwner(),
        0,
        "[GATEv1] Status: state=" + (string)gateState +
        " short=" + gateShortName +
        " name=" + gateDisplayName +
        " key=" + (string)llGetKey() +
        " known_region_gates=" + (string)(llGetListLength(knownRegionGates) / 5)
    );
}

dump_script_inventory()
{
    integer n = llGetInventoryNumber(INVENTORY_SCRIPT);
    integer i = 0;
    llRegionSayTo(llGetOwner(), 0, "[GATEv1] Scripts in object: " + (string)n);
    for (; i < n; ++i)
    {
        llRegionSayTo(llGetOwner(), 0, "[GATEv1] script: " + llGetInventoryName(INVENTORY_SCRIPT, i));
    }
}

start_iris_cleanup_watch()
{
    sensorMode = SENSOR_MODE_IRIS_CLEANUP;
    irisCleanupWatchActive = TRUE;
    irisCleanupWatchUntilUnix = irisBootMuteUntilUnix + 5;
    llSensorRepeat("", NULL_KEY, PASSIVE | ACTIVE, irisCleanupRangeMeters, PI, irisCleanupScanIntervalSeconds);
}

stop_iris_cleanup_watch()
{
    if (!irisCleanupWatchActive) return;
    llSensorRemove();
    irisCleanupWatchActive = FALSE;
    sensorMode = SENSOR_MODE_NONE;
}

string eh_build_outgoing_payload()
{
    vector targetPos = llGetPos();
    if (pendingDestPosValid)
    {
        targetPos = pendingDestPos;
    }

    // For LOCAL and SAME_REGION dials, always use the actual region name regardless
    // of whatever pendingSim contains, as a defense against stale or unexpected state.
    string simForPayload = pendingSim;
    if (pendingDialType == "LOCAL" || pendingDialType == "SAME_REGION")
    {
        simForPayload = llGetRegionName();
    }

    vector targetRot = <0.0, 0.0, 0.0>;
    if (pendingDestRotValid)
    {
        targetRot = llRot2Euler(pendingDestRot);
    }

    list data = [
        "command", "outgoing",
        "address", pendingAddress,
        "target_name", pendingDestGateName,
        "target_short", pendingDestGateShort,
        "destination", simForPayload,
        "grid", pendingGrid,
        "grid_id", pendingGridId,
        "cords", (string)targetPos,
        "rot", (string)targetRot,
        "size", (string)ehDiameter,
        "unstable", "0",
        "iris", "0",
        "remote_iris", "0"
    ];
    return llList2Json(JSON_OBJECT, data);
}

string eh_build_incoming_payload(string addr, string sourceSim, string grid)
{
    list data = [
        "command", "incoming",
        "address", addr,
        "destination", sourceSim,
        "grid", grid,
        "size", (string)ehDiameter,
        "unstable", "0",
        "iris", "0",
        "remote_iris", "0"
    ];
    return llList2Json(JSON_OBJECT, data);
}

string eh_build_close_payload()
{
    return llList2Json(JSON_OBJECT, ["command", "close"]);
}

rez_eh_and_send(string payload)
{
    ehChannel = next_private_channel();
    ehActive = TRUE;

    // Rotate 180 degrees around Z axis to correct kawoosh direction
    rotation baseRot = llGetRot();
    rotation zRot180 = llAxisAngle2Rot(<0.0, 0.0, 1.0>, PI);
    rotation finalRot = baseRot * zRot180;

    llRezObject(EH_OBJECT_NAME, llGetPos() + EH_REZ_OFFSET, ZERO_VECTOR, finalRot, ehChannel);
    llSleep(0.25);
    llRegionSay(ehChannel, payload);
}

close_eh_if_active()
{
    if (!ehActive || ehChannel == 0) return;

    llRegionSay(ehChannel, eh_build_close_payload());
    ehActive = FALSE;
    ehChannel = 0;
}

string iris_build_cmd_payload(string command)
{
    return llList2Json(JSON_OBJECT, ["command", command]);
}

string resolve_iris_object_name()
{
    if (llGetInventoryType(IRIS_OBJECT_NAME) == INVENTORY_OBJECT)
    {
        return IRIS_OBJECT_NAME;
    }

    if (llGetInventoryType(IRIS_OBJECT_FALLBACK) == INVENTORY_OBJECT)
    {
        return IRIS_OBJECT_FALLBACK;
    }

    return "";
}

rez_iris_if_needed()
{
    if (irisActive) return;

    string irisObjectName = resolve_iris_object_name();
    if (irisObjectName == "")
    {
        if (!irisMissingWarned)
        {
            llRegionSayTo(llGetOwner(), 0,
                "[GATEv1] Missing iris helper object. Add '_iris' or '_lris' to gate contents.");
            irisMissingWarned = TRUE;
        }
        return;
    }

    irisChannel = next_private_channel();
    irisActive = TRUE;
    llRezObject(irisObjectName, llGetPos() + IRIS_REZ_OFFSET, ZERO_VECTOR, llGetRot(), irisChannel);
    llSleep(0.2);
}

send_iris_cmd(string command)
{
    string c = llToLower(llStringTrim(command, STRING_TRIM));

    // Only OPEN is allowed to rez the iris helper if it is not already active.
    // CLOSE/HIT should operate only on an existing helper and must not trigger rez.
    if (c == "open")
    {
        if (llGetUnixTime() < irisBootMuteUntilUnix)
        {
            say_debug("Ignoring iris open during boot mute window.");
            return;
        }
        rez_iris_if_needed();
    }

    if (!irisActive || irisChannel == 0) return;
    llRegionSay(irisChannel, iris_build_cmd_payload(command));
}

remove_iris_if_active()
{
    if (!irisActive) return;
    llRegionSay(irisChannel, iris_build_cmd_payload("die"));
    irisActive = FALSE;
    irisChannel = 0;
}

string shield_build_cmd_payload(string command, float diameter)
{
    if (diameter > 0.0)
    {
        return llList2Json(JSON_OBJECT, ["command", command, "diameter", (string)diameter]);
    }
    return llList2Json(JSON_OBJECT, ["command", command]);
}

rez_shield_if_needed()
{
    if (shieldActive) return;

    shieldChannel = next_private_channel();
    shieldActive = TRUE;
    llRezObject(SHIELD_OBJECT_NAME, llGetPos() + SHIELD_REZ_OFFSET, ZERO_VECTOR, llGetRot(), shieldChannel);
    llSleep(0.2);
}

send_shield_cmd(string command)
{
    rez_shield_if_needed();
    if (!shieldActive || shieldChannel == 0) return;
    llRegionSay(shieldChannel, shield_build_cmd_payload(command, 0.0));
}

send_shield_size(float diameter)
{
    rez_shield_if_needed();
    if (!shieldActive || shieldChannel == 0) return;
    llRegionSay(shieldChannel, shield_build_cmd_payload("size", diameter));
}

send_shield_up()
{
    rez_shield_if_needed();
    llRegionSay(shieldChannel, shield_build_cmd_payload("size", shieldDiameter));
}

send_shield_down()
{
    if (!shieldActive) return;
    llRegionSay(shieldChannel, shield_build_cmd_payload("close", 0.0));
    llSleep(0.45);
    llRegionSay(shieldChannel, shield_build_cmd_payload("die", 0.0));
    llSleep(0.10);
    llRegionSay(shieldChannel, shield_build_cmd_payload("die", 0.0));
    shieldActive = FALSE;
    shieldChannel = 0;
}

remove_shield_if_active()
{
    if (!shieldActive) return;
    llRegionSay(shieldChannel, shield_build_cmd_payload("die", 0.0));
    shieldActive = FALSE;
    shieldChannel = 0;
}

integer is_hypergrid_mode(string mode)
{
    string m = llToUpper(llStringTrim(mode, STRING_TRIM));
    return (m == "HYPERGRID" || m == "HG" || m == "HYPER");
}

integer is_same_region_mode(string mode)
{
    string m = llToUpper(llStringTrim(mode, STRING_TRIM));
    return (m == "SAME_REGION" || m == "REGION" || m == "SAME" || m == "INTRA");
}

string nvl(string v, string fallback)
{
    if (llStringTrim(v, STRING_TRIM) == "") return fallback;
    return v;
}

string normalized_short_from_name(string src)
{
    string t = llToUpper(llStringTrim(src, STRING_TRIM));
    string out = "";
    integer i = 0;
    integer n = llStringLength(t);

    for (; i < n; ++i)
    {
        string ch = llGetSubString(t, i, i);
        if ((ch >= "A" && ch <= "Z") || (ch >= "0" && ch <= "9"))
        {
            out += ch;
        }
    }

    if (out == "") out = "SG";
    if (llStringLength(out) > 24) out = llGetSubString(out, 0, 23);
    return out;
}

integer known_gate_index(string shortName)
{
    integer i = 0;
    integer n = llGetListLength(knownRegionGates);
    for (; i < n; i += 5)
    {
        if (llToLower(llList2String(knownRegionGates, i)) == llToLower(shortName)) return i;
    }
    return -1;
}

upsert_known_gate(string shortName, string fullName, vector pos, rotation rot)
{
    integer now = llGetUnixTime();
    list row = [shortName, fullName, (string)pos, (string)rot, (string)now];
    integer idx = known_gate_index(shortName);
    if (idx == -1) knownRegionGates += row;
    else knownRegionGates = llListReplaceList(knownRegionGates, row, idx, idx + 4);
}

prune_stale_known_gates()
{
    integer now = llGetUnixTime();
    integer i = 0;
    integer n = llGetListLength(knownRegionGates);
    list fresh = [];

    for (; i < n; i += 5)
    {
        integer seen = (integer)llList2String(knownRegionGates, i + 4);
        if ((now - seen) <= knownGateTtlSeconds)
        {
            fresh += llList2List(knownRegionGates, i, i + 4);
        }
    }

    knownRegionGates = fresh;
}

announce_gate_presence()
{
    integer now = llGetUnixTime();
    if ((now - lastRegistryAnnounceUnix) < registryResponseThrottleSeconds)
    {
        return;
    }
    lastRegistryAnnounceUnix = now;

    llRegionSay(gateRegistryChannel,
        "GATE_REG|" +
        gateShortName + "|" +
        gateDisplayName + "|" +
        (string)llGetPos() + "|" +
        (string)llGetRot() + "|" +
        llGetRegionName()
    );
}

announce_gate_presence_immediate()
{
    // Reply path for explicit registry requests: do not throttle.
    llRegionSay(gateRegistryChannel,
        "GATE_REG|" +
        gateShortName + "|" +
        gateDisplayName + "|" +
        (string)llGetPos() + "|" +
        (string)llGetRot() + "|" +
        llGetRegionName()
    );
}

request_gate_presence()
{
    llRegionSay(gateRegistryChannel, "GATE_REG_REQ|" + gateShortName);
}

relay_same_region_incoming_open()
{
    if (llStringTrim(pendingDestGateShort, STRING_TRIM) == "") return;

    llRegionSay(
        gateRegistryChannel,
        "GATE_INCOMING|" +
        pendingDestGateShort + "|" +
        pendingAddress + "|" +
        llGetRegionName() + "|" +
        pendingDialType + "|" +
        pendingGrid + "|" +
        gateShortName
    );
}

relay_same_region_incoming_close()
{
    if (llStringTrim(pendingDestGateShort, STRING_TRIM) == "") return;

    llRegionSay(
        gateRegistryChannel,
        "GATE_INCOMING_CLOSED|" +
        pendingDestGateShort + "|" +
        gateShortName
    );
}

integer validate_same_region_target(string targetShort, string providedName)
{
    prune_stale_known_gates();

    string shortName = llStringTrim(targetShort, STRING_TRIM);
    if (shortName == "") return FALSE;

    if (llToLower(shortName) == llToLower(gateShortName))
    {
        fail_dial("same_region_self_target");
        return FALSE;
    }

    integer idx = known_gate_index(shortName);
    if (idx == -1)
    {
        request_gate_presence();
        fail_dial("same_region_target_unknown");
        return FALSE;
    }

    vector targetPos = (vector)llList2String(knownRegionGates, idx + 2);
    float dist = llVecDist(llGetPos(), targetPos);
    if (dist < sameRegionMinDistanceMeters)
    {
        fail_dial("same_region_too_close_" + (string)llRound(dist) + "m");
        return FALSE;
    }

    pendingDestGateShort = shortName;
    if (providedName != "") pendingDestGateName = providedName;
    else pendingDestGateName = llList2String(knownRegionGates, idx + 1);
    return TRUE;
}

string norm_mode_name(string mode)
{
    string m = llToLower(llStringTrim(mode, STRING_TRIM));
    if (m == "same_region" || m == "region" || m == "same" || m == "intra") return "same_region";
    if (m == "hg" || m == "hyper" || m == "hypergrid") return "hypergrid";
    return "local";
}

integer grid_index_by_id(string gridId)
{
    integer i = 0;
    integer n = llGetListLength(gridProfiles);
    for (; i < n; i += 2)
    {
        if (llToLower(llList2String(gridProfiles, i)) == llToLower(gridId)) return i;
    }
    return -1;
}

set_grid_profile(string gridId, string endpoint)
{
    integer idx = grid_index_by_id(gridId);
    if (idx == -1) gridProfiles += [gridId, endpoint];
    else gridProfiles = llListReplaceList(gridProfiles, [gridId, endpoint], idx, idx + 1);
}

string grid_endpoint_by_id(string gridId)
{
    integer idx = grid_index_by_id(gridId);
    if (idx == -1) return "";
    return llList2String(gridProfiles, idx + 1);
}

integer directory_index_by_short(string shortName)
{
    integer i = 0;
    integer n = llGetListLength(dialDirectory);
    for (; i < n; i += 6)
    {
        if (llToLower(llList2String(dialDirectory, i)) == llToLower(shortName)) return i;
    }
    return -1;
}

set_directory_entry(string shortName, string fullName, string routeCsv, string mode, string destSim, string gridId)
{
    integer idx = directory_index_by_short(shortName);
    list row = [shortName, fullName, routeCsv, mode, destSim, gridId];
    if (idx == -1) dialDirectory += row;
    else dialDirectory = llListReplaceList(dialDirectory, row, idx, idx + 5);
}

integer starts_with_cmd(string txt, string prefix)
{
    string t = llToLower(llStringTrim(txt, STRING_TRIM));
    string p = llToLower(prefix);
    return (llSubStringIndex(t, p) == 0);
}

integer matches_admin_target(string target)
{
    string t = llToLower(llStringTrim(target, STRING_TRIM));
    if (t == "") return FALSE;

    if (t == llToLower(gateShortName)) return TRUE;
    if (t == llToLower(gateDisplayName)) return TRUE;
    if (t == llToLower(llGetObjectName())) return TRUE;
    if (t == llToLower((string)llGetKey())) return TRUE;

    return FALSE;
}

string join_pipe_from(list parts, integer start)
{
    integer i = start;
    integer n = llGetListLength(parts);
    string out = "";

    for (; i < n; ++i)
    {
        if (i > start) out += "|";
        out += llList2String(parts, i);
    }

    return out;
}

list parse_route_symbols(string csv)
{
    list route = llCSV2List(csv);
    list out = [];
    integer i = 0;
    integer n = llGetListLength(route);

    for (; i < n; ++i)
    {
        integer s = (integer)llStringTrim(llList2String(route, i), STRING_TRIM);
        if (s == 0)
        {
            // final engage symbol is not a chevron step
            jump continue_loop;
        }
        out += [s];
@continue_loop;
    }

    return out;
}

start_dial(string csvRoute, string mode, string destinationSim, string destinationGrid, string destinationGridId, string targetName, string targetShort)
{
    if (gateState != STATE_IDLE)
    {
        llSay(apiChannel, "STATUS|BUSY");
        return;
    }

    pendingAddress = csvRoute;
    pendingSim = llStringTrim(destinationSim, STRING_TRIM);
    if (pendingSim == "" || llToLower(pendingSim) == "unknown region")
    {
        pendingSim = nvl(targetName, "Local Target");
    }
    pendingGrid = nvl(destinationGrid, "-");
    pendingGridId = llStringTrim(destinationGridId, STRING_TRIM);
    pendingDestGateName = targetName;
    pendingDestGateShort = targetShort;
    pendingMode = mode;
    pendingDestPosValid = FALSE;
    pendingDestPos = ZERO_VECTOR;
    pendingDestRotValid = FALSE;
    pendingDestRot = ZERO_ROTATION;
    activeSessionIncoming = FALSE;

    if (is_hypergrid_mode(mode))
    {
        pendingDialType = "HYPERGRID";
        if (pendingGrid == "-" && pendingGridId != "")
        {
            string endpoint = grid_endpoint_by_id(pendingGridId);
            if (endpoint != "") pendingGrid = endpoint;
        }
        pendingGrid = nvl(pendingGrid, "unknown-grid");
    }
    else if (is_same_region_mode(mode))
    {
        pendingDialType = "SAME_REGION";
        pendingSim = llGetRegionName();
        pendingGrid = "-";
        pendingGridId = "";

        string sameRegionTarget = nvl(targetShort, destinationSim);
        if (!validate_same_region_target(sameRegionTarget, targetName))
        {
            return;
        }

        resolve_pending_destination_position();
    }
    else
    {
        pendingDialType = "LOCAL";
        // LOCAL routes are always in-region teleports.
        pendingSim = llGetRegionName();

        if (llStringTrim(pendingDestGateShort, STRING_TRIM) != "" || llStringTrim(pendingDestGateName, STRING_TRIM) != "")
        {
            resolve_pending_destination_position();
            if (!pendingDestPosValid)
            {
                // Give registry sync a chance while chevrons are animating.
                request_gate_presence();
            }
        }

        pendingGrid = "-";
        pendingGridId = "";
    }

    pendingSymbols = parse_route_symbols(csvRoute);
    pendingChevron = 1;
    dialPhase = 0;

    if (llGetListLength(pendingSymbols) == 0)
    {
        emit_event("DIAL_FAILED|||||empty_route");
        gateState = STATE_COOLDOWN;
        llSetTimerEvent(failCooldownSeconds);
        return;
    }

    gateState = STATE_DIALING;
    emit_event("DIAL_BEGIN|" + pendingAddress + "|" + pendingSim + "|" + pendingDialType + "|" + pendingGrid + "|");
    llSetTimerEvent(chargeStepSeconds);
}

dial_by_short_name(string shortName)
{
    integer idx = directory_index_by_short(shortName);
    if (idx == -1)
    {
        llRegionSayTo(llGetOwner(), 0, "[GATEv1] Unknown short name: " + shortName);
        return;
    }

    string sShort = llList2String(dialDirectory, idx + 0);
    string sFull = llList2String(dialDirectory, idx + 1);
    string sRoute = llList2String(dialDirectory, idx + 2);
    string sMode = llList2String(dialDirectory, idx + 3);
    string sSim = llList2String(dialDirectory, idx + 4);
    string sGridId = llList2String(dialDirectory, idx + 5);
    string sGridEndpoint = grid_endpoint_by_id(sGridId);

    start_dial(sRoute, sMode, sSim, sGridEndpoint, sGridId, sFull, sShort);
}

handle_gate_admin_command(string msg)
{
    // Optional targeting syntax for region-wide owner channel commands:
    // to|<target>|<command>
    // target may be gate short, gate display name, object name, or gate object key.
    if (starts_with_cmd(msg, "to|"))
    {
        list pTarget = llParseStringKeepNulls(msg, ["|"], []);
        string target = llStringTrim(llList2String(pTarget, 1), STRING_TRIM);
        if (!matches_admin_target(target))
        {
            return;
        }
        msg = join_pipe_from(pTarget, 2);
    }

    string low = llToLower(llStringTrim(msg, STRING_TRIM));

    // gate name Atlantis Central
    if (starts_with_cmd(msg, "gate name "))
    {
        gateDisplayName = llStringTrim(llGetSubString(msg, 10, -1), STRING_TRIM);
        llRegionSayTo(llGetOwner(), 0, "[GATEv1] Gate name set to " + gateDisplayName);
        return;
    }

    // gate short ATL
    if (starts_with_cmd(msg, "gate short "))
    {
        gateShortName = llStringTrim(llGetSubString(msg, 11, -1), STRING_TRIM);
        llRegionSayTo(llGetOwner(), 0, "[GATEv1] Gate short set to " + gateShortName);
        return;
    }

    // gate status
    if (low == "gate status")
    {
        show_gate_status();
        return;
    }

    if (low == "iris api on")
    {
        allowApiIrisCommands = TRUE;
        llRegionSayTo(llGetOwner(), 0, "[GATEv1] API IRIS commands enabled.");
        return;
    }

    if (low == "iris api off")
    {
        allowApiIrisCommands = FALSE;
        llRegionSayTo(llGetOwner(), 0, "[GATEv1] API IRIS commands disabled.");
        return;
    }

    // grid add|pegasus|hg.example.org:8002
    if (starts_with_cmd(msg, "grid add|"))
    {
        list p = llParseStringKeepNulls(llGetSubString(msg, 9, -1), ["|"], []);
        string id = llStringTrim(llList2String(p, 0), STRING_TRIM);
        string endpoint = llStringTrim(llList2String(p, 1), STRING_TRIM);
        if (id != "" && endpoint != "")
        {
            set_grid_profile(id, endpoint);
            llRegionSayTo(llGetOwner(), 0, "[GATEv1] Grid profile set: " + id + " -> " + endpoint);
        }
        return;
    }

    // grid list
    if (low == "grid list")
    {
        integer i = 0;
        integer n = llGetListLength(gridProfiles);
        llRegionSayTo(llGetOwner(), 0, "[GATEv1] Grid profiles: " + (string)(n / 2));
        for (; i < n; i += 2)
        {
            llRegionSayTo(llGetOwner(), 0, " - " + llList2String(gridProfiles, i) + " => " + llList2String(gridProfiles, i + 1));
        }
        return;
    }

    // route add|ATL|Atlantis Control|1,2,3,4,5,6,0|local|Atlantis Control|
    // route add|MID|Midway Relay|1,2,3,4,5,6,7,8,0|hypergrid|Midway Relay|pegasus
    if (starts_with_cmd(msg, "route add|"))
    {
        list p2 = llParseStringKeepNulls(llGetSubString(msg, 10, -1), ["|"], []);
        string sh = llStringTrim(llList2String(p2, 0), STRING_TRIM);
        string fn = llStringTrim(llList2String(p2, 1), STRING_TRIM);
        string rt = llStringTrim(llList2String(p2, 2), STRING_TRIM);
        string md = norm_mode_name(llList2String(p2, 3));
        string ds = llStringTrim(llList2String(p2, 4), STRING_TRIM);
        string gid = llStringTrim(llList2String(p2, 5), STRING_TRIM);
        if (sh != "" && rt != "")
        {
            set_directory_entry(sh, fn, rt, md, ds, gid);
            llRegionSayTo(llGetOwner(), 0, "[GATEv1] Route saved for " + sh);
        }
        return;
    }

    // route list
    if (low == "route list")
    {
        integer j = 0;
        integer m = llGetListLength(dialDirectory);
        llRegionSayTo(llGetOwner(), 0, "[GATEv1] Dial routes: " + (string)(m / 6));
        for (; j < m; j += 6)
        {
            llRegionSayTo(llGetOwner(), 0,
                " - " + llList2String(dialDirectory, j + 0) +
                " (" + llList2String(dialDirectory, j + 1) + ")" +
                " mode=" + llList2String(dialDirectory, j + 3) +
                " grid_id=" + llList2String(dialDirectory, j + 5)
            );
        }
        return;
    }

    // region list
    if (low == "region list")
    {
        prune_stale_known_gates();

        integer r = 0;
        integer rn = llGetListLength(knownRegionGates);
        llRegionSayTo(llGetOwner(), 0, "[GATEv1] Region gates known: " + (string)(rn / 5));
        for (; r < rn; r += 5)
        {
            llRegionSayTo(llGetOwner(), 0,
                " - " + llList2String(knownRegionGates, r + 0) +
                " (" + llList2String(knownRegionGates, r + 1) + ")" +
                " pos=" + llList2String(knownRegionGates, r + 2) +
                " rot=" + llList2String(knownRegionGates, r + 3)
            );
        }
        return;
    }

    // dial short|ATL
    if (starts_with_cmd(msg, "dial short|"))
    {
        string shortName = llStringTrim(llGetSubString(msg, 11, -1), STRING_TRIM);
        if (shortName != "") dial_by_short_name(shortName);
        return;
    }
}

fail_dial(string reason)
{
    emit_event("DIAL_FAILED|||||" + reason);
    llSay(apiChannel, "DIAL_RESULT|FAIL|" + reason);
    gateState = STATE_COOLDOWN;
    llSetTimerEvent(failCooldownSeconds);
}

begin_route_auth_stage()
{
    if (pendingDialType == "LOCAL" &&
        (llStringTrim(pendingDestGateShort, STRING_TRIM) != "" || llStringTrim(pendingDestGateName, STRING_TRIM) != ""))
    {
        resolve_pending_destination_position();
        if (!pendingDestPosValid)
        {
            request_gate_presence();
            fail_dial("local_target_unknown");
            return;
        }
    }

    emit_event("ROUTE_AUTHORIZING|" + pendingAddress + "|" + pendingSim + "|" + pendingDialType + "|" + pendingGrid + "|");

    // Keep local/same-region dials fully in-world so they do not stall if API auth relay is absent.
    if (!apiOffloadRouting || pendingDialType == "LOCAL" || pendingDialType == "SAME_REGION")
    {
        request_power_for_route();
        return;
    }

    apiAuthWaiting = TRUE;
    gateState = STATE_API_PENDING;
    request_api_route_auth();
    llSetTimerEvent(apiAuthTimeoutSeconds);
}

request_power_for_route()
{
    float cost = localDialPowerCost;
    string reason = "local_dial";

    if (pendingDialType == "HYPERGRID")
    {
        cost = hypergridPowerCost;
        reason = "hypergrid";
    }

    gateState = STATE_AUTH_PENDING;
    apiAuthWaiting = FALSE;
    authPendingUntilUnix = llGetUnixTime() + 5;
    llSay(powerAPIchan, "draw power|" + (string)cost + "|" + reason);
}

open_wormhole_outgoing()
{
    if ((pendingDialType == "LOCAL" || pendingDialType == "SAME_REGION") && !pendingDestPosValid)
    {
        fail_dial("target_position_unresolved");
        return;
    }

    gateState = STATE_ACTIVE;
    activeSessionIncoming = FALSE;

    rez_eh_and_send(eh_build_outgoing_payload());

    // For local/same-region routes, directly notify the destination gate in-world.
    if (pendingDialType == "LOCAL" || pendingDialType == "SAME_REGION")
    {
        relay_same_region_incoming_open();
    }

    emit_event("OUTGOING_OPEN|" + pendingAddress + "|" + pendingSim + "|" + pendingDialType + "|" + pendingGrid + "|");
    emit_event("WORMHOLE_ACTIVE|" + pendingAddress + "|" + pendingSim + "|" + pendingDialType + "|" + pendingGrid + "|");
    llSay(apiChannel, "DIAL_RESULT|OK|" + pendingAddress);
    llSetTimerEvent(activeSeconds);
}

close_wormhole_to_idle()
{
    // Only the originating gate emits same-region close relay to avoid close ping-pong.
    if (!activeSessionIncoming && (pendingDialType == "LOCAL" || pendingDialType == "SAME_REGION"))
    {
        relay_same_region_incoming_close();
    }

    close_eh_if_active();
    emit_event("WORMHOLE_CLOSED");
    gateState = STATE_IDLE;
    activeSessionIncoming = FALSE;
    pendingSymbols = [];
    pendingChevron = 0;
    dialPhase = 0;
    llSetTimerEvent(0.0);
    llSay(apiChannel, "STATUS|IDLE");
}

default
{
    state_entry()
    {
        scriptStandby = !should_run_this_script();
        if (scriptStandby)
        {
            llOwnerSay("[GATEv1] Standby: duplicate gate controller detected. Selected active='" + controllerPreferredScript + "' matches=" + llDumpList2String(controllerScriptMatches, ", "));
            return;
        }

        llOwnerSay("[GATEv1] Active controller script: " + llGetScriptName());
        if (llGetListLength(controllerScriptMatches) > 1)
        {
            llOwnerSay("[GATEv1] Controller-family scripts in object: " + llDumpList2String(controllerScriptMatches, ", "));
        }
        dump_script_inventory();

        gateState = STATE_IDLE;
        ehChannel = 0;
        ehActive = FALSE;
        irisChannel = 0;
        irisActive = FALSE;
        irisMissingWarned = FALSE;
        shieldChannel = 0;
        shieldActive = FALSE;
        listenApi = llListen(apiChannel, "", "", "");
        listenDhd = llListen(apiChannelRecv, "", "", "");
        listenPower = llListen(powerAPIchan, "", "", "");
        listenLocalDial = llListen(localDialCmdChannel, "", llGetOwner(), "");
        listenGateAdmin = llListen(gateAdminChannel, "", llGetOwner(), "");
        listenGateRegistry = llListen(gateRegistryChannel, "", "", "");
        menuChannel = next_private_channel();
        listenMenu = llListen(menuChannel, "", llGetOwner(), "");

        // Do not auto-rez iris on startup. Iris is controlled on demand.
        irisBootMuteUntilUnix = llGetUnixTime() + irisBootMuteSeconds;
        start_iris_cleanup_watch();

        // Optional startup shield posture.
        if (START_WITH_SHIELD_UP)
        {
            send_shield_up();
        }

        emit_event("BOOT");

        // Bootstrap defaults from object name so multiple gates do not all register as SG/Stargate.
        string objName = llStringTrim(llGetObjectName(), STRING_TRIM);
        if (llToLower(gateShortName) == "sg")
        {
            gateShortName = normalized_short_from_name(objName);
        }
        if (llToLower(gateDisplayName) == "stargate" && objName != "")
        {
            gateDisplayName = objName;
        }

        llSay(apiChannel, "STATUS|IDLE");
        llRegionSayTo(llGetOwner(), 0, "[GATEv1] Local dial commands on channel " + (string)localDialCmdChannel + ": dial local|route OR dial region|route OR dial hg|route|sim|grid");
        llRegionSayTo(llGetOwner(), 0, "[GATEv1] Gate admin commands on channel " + (string)gateAdminChannel + ": gate name, gate short, grid add|id|endpoint, route add|short|full|route|mode|sim|grid_id, dial short|name");
        llRegionSayTo(llGetOwner(), 0, "[GATEv1] Region rule: SAME_REGION targets must be at least " + (string)((integer)sameRegionMinDistanceMeters) + "m away.");
        llRegionSayTo(llGetOwner(), 0, "[GATEv1] Perf controls: region cache TTL=" + (string)knownGateTtlSeconds + "s, registry throttle=" + (string)registryResponseThrottleSeconds + "s");
        llRegionSayTo(llGetOwner(), 0, "[GATEv1] API offload: routing=" + (apiOffloadRouting ? "ON" : "OFF") + " security=" + (apiOffloadSecurity ? "ON" : "OFF") + " fallback=" + (allowLocalFallback ? "ON" : "OFF"));
        llRegionSayTo(llGetOwner(), 0, "[GATEv1] Security command source: " + (allowExternalSecurityCommands ? "EXTERNAL_ALLOWED" : "SELF_ONLY"));
        llRegionSayTo(llGetOwner(), 0, "[GATEv1] API IRIS commands: " + (allowApiIrisCommands ? "ON" : "OFF"));
        llRegionSayTo(llGetOwner(), 0, "[GATEv1] IRIS boot mute seconds: " + (string)irisBootMuteSeconds);

        upsert_known_gate(gateShortName, gateDisplayName, llGetPos(), llGetRot());
        announce_gate_presence();
        request_gate_presence();
    }

    timer()
    {
        integer now = llGetUnixTime();

        if (scriptStandby) return;

        if (gateState == STATE_DIALING)
        {
            integer total = llGetListLength(pendingSymbols);

            if (pendingChevron > total)
            {
                begin_route_auth_stage();
                return;
            }

            if (dialPhase == 0)
            {
                emit_event("SYMBOL_CHARGE|" + (string)pendingChevron);
                dialPhase = 1;
                llSetTimerEvent(chargeStepSeconds);
                return;
            }

            emit_event("SYMBOL_LOCK|" + (string)pendingChevron);
            pendingChevron += 1;
            dialPhase = 0;
            llSetTimerEvent(lockStepSeconds);
            return;
        }

        if (gateState == STATE_ACTIVE)
        {
            close_wormhole_to_idle();
            return;
        }

        if (gateState == STATE_AUTH_PENDING)
        {
            if (now >= authPendingUntilUnix)
            {
                if (allowLocalFallback && (pendingDialType == "LOCAL" || pendingDialType == "SAME_REGION"))
                {
                    open_wormhole_outgoing();
                    return;
                }

                fail_dial("power_timeout");
                return;
            }
            return;
        }

        if (gateState == STATE_API_PENDING)
        {
            apiAuthWaiting = FALSE;
            if (allowLocalFallback)
            {
                request_power_for_route();
                return;
            }
            fail_dial("route_auth_timeout");
            return;
        }

        if (gateState == STATE_COOLDOWN)
        {
            gateState = STATE_IDLE;
            emit_event("IDLE");
            llSay(apiChannel, "STATUS|IDLE");
            llSetTimerEvent(0.0);
            return;
        }
    }

    listen(integer channel, string name, key id, string msg)
    {
        if (scriptStandby) return;

        if (channel == menuChannel && id == llGetOwner())
        {
            string menuMsg = llStringTrim(msg, STRING_TRIM);

            if (pendingMenuInput == "")
            {
                string upperMenu = llToUpper(menuMsg);
                if (upperMenu == "SET ALIAS")
                {
                    pendingMenuInput = "SHORT";
                    llTextBox(llGetOwner(), "Enter gate short alias (e.g. CAST or CELESTIS):", menuChannel);
                    return;
                }
                if (upperMenu == "SET NAME")
                {
                    pendingMenuInput = "NAME";
                    llTextBox(llGetOwner(), "Enter gate display name:", menuChannel);
                    return;
                }
                if (upperMenu == "SHOW")
                {
                    show_gate_status();
                    show_owner_menu();
                    return;
                }
                return;
            }

            if (pendingMenuInput == "SHORT")
            {
                string newShort = llToUpper(llStringTrim(menuMsg, STRING_TRIM));
                if (newShort != "")
                {
                    gateShortName = newShort;
                    upsert_known_gate(gateShortName, gateDisplayName, llGetPos(), llGetRot());
                    announce_gate_presence();
                    llRegionSayTo(llGetOwner(), 0, "[GATEv1] Gate short set to " + gateShortName);
                }
                pendingMenuInput = "";
                return;
            }

            if (pendingMenuInput == "NAME")
            {
                string newName = llStringTrim(menuMsg, STRING_TRIM);
                if (newName != "")
                {
                    gateDisplayName = newName;
                    upsert_known_gate(gateShortName, gateDisplayName, llGetPos(), llGetRot());
                    announce_gate_presence();
                    llRegionSayTo(llGetOwner(), 0, "[GATEv1] Gate name set to " + gateDisplayName);
                }
                pendingMenuInput = "";
                return;
            }

            pendingMenuInput = "";
            return;
        }

        if (channel == apiChannelRecv)
        {
            list data = llParseStringKeepNulls(msg, ["|"], []);
            string cmd0 = llToUpper(llStringTrim(llList2String(data, 0), STRING_TRIM));
            string cmd1 = llToUpper(llStringTrim(llList2String(data, 1), STRING_TRIM));

            // DHD|DIAL|manual|1,2,3,4,5,6,0
            if (cmd0 == "DHD" && cmd1 == "DIAL")
            {
                if (!accept_nearby_dhd_sender(id, "DIAL")) return;

                string mode = llStringTrim(llList2String(data, 2), STRING_TRIM);
                string route = llStringTrim(llList2String(data, 3), STRING_TRIM);
                string destSim = llStringTrim(llList2String(data, 4), STRING_TRIM);
                string destGrid = llStringTrim(llList2String(data, 5), STRING_TRIM);
                string destGridId = llStringTrim(llList2String(data, 6), STRING_TRIM);
                string targetShort = llStringTrim(llList2String(data, 7), STRING_TRIM);
                string targetName = llStringTrim(llList2String(data, 8), STRING_TRIM);

                // DHDv3 includes destination identity; destination gate should not start
                // an outgoing dial when it hears the broadcast command.
                if (is_targeting_this_gate(targetShort, targetName))
                {
                    say_debug("Ignoring DHD|DIAL targeted to this gate; awaiting incoming relay.");
                    return;
                }

                start_dial(route, mode, destSim, destGrid, destGridId, targetName, targetShort);
                return;
            }

            if (cmd0 == "DHD" && cmd1 == "RESET")
            {
                if (!accept_nearby_dhd_sender(id, "RESET")) return;

                if (gateState == STATE_ACTIVE || gateState == STATE_DIALING || gateState == STATE_AUTH_PENDING)
                {
                    close_wormhole_to_idle();
                }
                return;
            }

            // Handle ADDRESS_BOOK requests from HUD/DHD
            if (cmd0 == "DHD" && cmd1 == "ADDRESS_BOOK")
            {
                if (!accept_nearby_dhd_sender(id, "ADDRESS_BOOK")) return;

                send_address_book_response(id);
                return;
            }
        }

        if (channel == localDialCmdChannel && id == llGetOwner())
        {
            // dial local|1,2,3,4,5,6,0
            // dial region|1,2,3,4,5,6,0|TARGET_SHORT
            // dial hg|1,2,3,4,5,6,7,8,0|Destination Sim|grid.host:port
            string lower = llToLower(llStringTrim(msg, STRING_TRIM));
            if (llSubStringIndex(lower, "dial ") == 0)
            {
                string rest = llGetSubString(msg, 5, -1);
                list p = llParseStringKeepNulls(rest, ["|"], []);
                string mode2 = llStringTrim(llList2String(p, 0), STRING_TRIM);
                string route2 = llStringTrim(llList2String(p, 1), STRING_TRIM);
                string sim2 = llStringTrim(llList2String(p, 2), STRING_TRIM);
                string grid2 = llStringTrim(llList2String(p, 3), STRING_TRIM);

                if (route2 != "")
                {
                    start_dial(route2, mode2, sim2, grid2, "", "", "");
                }
                return;
            }
        }

        if (channel == gateRegistryChannel)
        {
            list rg = llParseStringKeepNulls(msg, ["|"], []);
            string rc0 = llToUpper(llStringTrim(llList2String(rg, 0), STRING_TRIM));

            if (rc0 == "GATE_INCOMING")
            {
                string targetShort = llStringTrim(llList2String(rg, 1), STRING_TRIM);
                if (!matches_admin_target(targetShort)) return;

                string addr = llStringTrim(llList2String(rg, 2), STRING_TRIM);
                string sourceSim = llStringTrim(llList2String(rg, 3), STRING_TRIM);
                string mode = llStringTrim(llList2String(rg, 4), STRING_TRIM);
                string grid = llStringTrim(llList2String(rg, 5), STRING_TRIM);

                pendingAddress = addr;
                pendingSim = sourceSim;
                pendingDialType = mode;
                pendingGrid = grid;
                pendingGridId = "";
                activeSessionIncoming = TRUE;

                if (gateState != STATE_ACTIVE)
                {
                    gateState = STATE_ACTIVE;
                    llSetTimerEvent(activeSeconds);
                }

                rez_eh_and_send(eh_build_incoming_payload(addr, sourceSim, grid));
                emit_event("INCOMING_OPEN|" + addr + "|" + sourceSim + "|" + mode + "|" + grid + "|");
                return;
            }

            if (rc0 == "GATE_INCOMING_CLOSED")
            {
                string closeTargetShort = llStringTrim(llList2String(rg, 1), STRING_TRIM);
                if (!matches_admin_target(closeTargetShort)) return;

                if (gateState == STATE_ACTIVE && activeSessionIncoming)
                {
                    close_wormhole_to_idle();
                }
                return;
            }

            if (rc0 == "GATE_REG")
            {
                string sh = llStringTrim(llList2String(rg, 1), STRING_TRIM);
                string fn = llStringTrim(llList2String(rg, 2), STRING_TRIM);
                vector pos = (vector)llStringTrim(llList2String(rg, 3), STRING_TRIM);
                rotation rot = ZERO_ROTATION;
                string regionName = "";

                if (llGetListLength(rg) >= 6)
                {
                    rot = (rotation)llStringTrim(llList2String(rg, 4), STRING_TRIM);
                    regionName = llStringTrim(llList2String(rg, 5), STRING_TRIM);
                }
                else
                {
                    // Backward compatibility with older 5-field registry payload.
                    regionName = llStringTrim(llList2String(rg, 4), STRING_TRIM);
                }

                if (regionName == llGetRegionName() && sh != "")
                {
                    upsert_known_gate(sh, fn, pos, rot);
                }
                return;
            }

            if (rc0 == "GATE_REG_REQ")
            {
                string who = llStringTrim(llList2String(rg, 1), STRING_TRIM);
                if (llToLower(who) != llToLower(gateShortName))
                {
                    announce_gate_presence_immediate();
                }
                return;
            }
        }

        if (channel == gateAdminChannel && id == llGetOwner())
        {
            handle_gate_admin_command(msg);
            return;
        }

        if (channel == powerAPIchan)
        {
            list dataP = llParseStringKeepNulls(msg, ["|"], []);
            string p0 = llToUpper(llStringTrim(llList2String(dataP, 0), STRING_TRIM));

            if (gateState != STATE_AUTH_PENDING)
            {
                return;
            }

            if (p0 == "DRAW SUCCESS")
            {
                open_wormhole_outgoing();
                return;
            }

            if (p0 == "DRAW FAIL")
            {
                emit_event("POWER_DENIED");
                fail_dial("insufficient_power");
                return;
            }

            if (p0 == "ZPM")
            {
                string p1 = llToUpper(llStringTrim(llList2String(dataP, 1), STRING_TRIM));
                if (p1 == "DRAW")
                {
                    string result = llToUpper(llStringTrim(llList2String(dataP, 2), STRING_TRIM));
                    if (result == "OK")
                    {
                        open_wormhole_outgoing();
                    }
                    else
                    {
                        emit_event("POWER_DENIED");
                        fail_dial("insufficient_power");
                    }
                    return;
                }
            }
        }

        if (channel == apiChannel)
        {
            list dataA = llParseStringKeepNulls(msg, ["|"], []);
            string c0 = llToUpper(llStringTrim(llList2String(dataA, 0), STRING_TRIM));

            // API offload route auth response contract:
            // API|ROUTE_AUTH|OK|reason
            // API|ROUTE_AUTH|DENY|reason
            if (c0 == "API")
            {
                string c1 = llToUpper(llStringTrim(llList2String(dataA, 1), STRING_TRIM));
                string c2 = llToUpper(llStringTrim(llList2String(dataA, 2), STRING_TRIM));
                string reason = llStringTrim(llList2String(dataA, 3), STRING_TRIM);

                if (c1 == "ROUTE_AUTH" && gateState == STATE_API_PENDING)
                {
                    apiAuthWaiting = FALSE;
                    if (c2 == "OK")
                    {
                        request_power_for_route();
                    }
                    else
                    {
                        fail_dial(nvl(reason, "route_denied"));
                    }
                    return;
                }
            }

            // Optional inbound relay: INCOMING|ADDR|SIM|MODE|GRID
            if (c0 == "INCOMING")
            {
                string addr = llStringTrim(llList2String(dataA, 1), STRING_TRIM);
                string sim = llStringTrim(llList2String(dataA, 2), STRING_TRIM);
                string mode = llStringTrim(llList2String(dataA, 3), STRING_TRIM);
                string grid = llStringTrim(llList2String(dataA, 4), STRING_TRIM);

                pendingAddress = addr;
                pendingSim = sim;
                pendingDialType = mode;
                pendingGrid = grid;
                activeSessionIncoming = TRUE;

                if (gateState != STATE_ACTIVE)
                {
                    gateState = STATE_ACTIVE;
                    llSetTimerEvent(activeSeconds);
                }

                rez_eh_and_send(eh_build_incoming_payload(addr, sim, grid));
                emit_event("INCOMING_OPEN|" + addr + "|" + sim + "|" + mode + "|" + grid + "|");
                return;
            }

            // Optional relay for iris control from backend/operator automation.
            // IRIS|OPEN
            // IRIS|CLOSE
            // IRIS|HIT
            if (c0 == "IRIS")
            {
                if (llGetUnixTime() < irisBootMuteUntilUnix)
                {
                    say_debug("Ignoring IRIS command during boot mute window.");
                    return;
                }

                if (!allowApiIrisCommands)
                {
                    say_debug("Ignoring IRIS command because API iris commands are disabled.");
                    return;
                }

                if (!allowExternalSecurityCommands && id != llGetKey())
                {
                    say_debug("Ignoring external IRIS command from " + (string)id);
                    return;
                }

                string irisCmd = llToUpper(llStringTrim(llList2String(dataA, 1), STRING_TRIM));
                if (irisCmd == "OPEN") send_iris_cmd("open");
                else if (irisCmd == "CLOSE") send_iris_cmd("close");
                else if (irisCmd == "HIT") send_iris_cmd("hit");
                return;
            }

            // Optional relay for shield control from backend/operator automation.
            // SHIELD|UP
            // SHIELD|DOWN
            // SHIELD|OPEN
            // SHIELD|CLOSE
            // SHIELD|HIT
            // SHIELD|SIZE|4.5
            if (c0 == "SHIELD")
            {
                if (!allowExternalSecurityCommands && id != llGetKey())
                {
                    say_debug("Ignoring external SHIELD command from " + (string)id);
                    return;
                }

                string shieldCmd = llToUpper(llStringTrim(llList2String(dataA, 1), STRING_TRIM));
                if (shieldCmd == "UP" || shieldCmd == "OPEN") send_shield_up();
                else if (shieldCmd == "DOWN" || shieldCmd == "CLOSE") send_shield_down();
                else if (shieldCmd == "HIT") send_shield_cmd("hit");
                else if (shieldCmd == "SIZE")
                {
                    float d = (float)llStringTrim(llList2String(dataA, 2), STRING_TRIM);
                    if (d > 0.0) shieldDiameter = d;
                    send_shield_size(d);
                }
                return;
            }

            if (c0 == "INCOMING_CLOSED")
            {
                emit_event("WORMHOLE_CLOSED");
                gateState = STATE_IDLE;
                llSetTimerEvent(0.0);
                return;
            }
        }
    }

    changed(integer change)
    {
        if (change & CHANGED_OWNER)
        {
            stop_iris_cleanup_watch();
            remove_iris_if_active();
            remove_shield_if_active();
            llResetScript();
        }
        else if (change & CHANGED_INVENTORY)
        {
            stop_iris_cleanup_watch();
            remove_iris_if_active();
            remove_shield_if_active();
            llResetScript();
        }
    }

    sensor(integer detected)
    {
        if (sensorMode != SENSOR_MODE_IRIS_CLEANUP) return;

        if (llGetUnixTime() > irisCleanupWatchUntilUnix)
        {
            stop_iris_cleanup_watch();
            return;
        }

        integer i = 0;
        for (; i < detected; ++i)
        {
            string detectedName = llToLower(llDetectedName(i));
            if (detectedName != "_iris" && detectedName != "_lris") jump next_target;

            key target = llDetectedKey(i);
            list d = llGetObjectDetails(target, [OBJECT_OWNER, OBJECT_DESC]);
            if (llGetListLength(d) < 2) jump next_target;

            key objOwner = llList2Key(d, 0);
            if (objOwner != llGetOwner()) jump next_target;

            integer chan = (integer)llStringTrim(llList2String(d, 1), STRING_TRIM);
            if (chan != 0)
            {
                llRegionSayTo(target, chan, iris_build_cmd_payload("die"));
                llRegionSayTo(target, chan, "die");
                say_debug("Requested orphan _Iris cleanup on channel " + (string)chan);
            }
            else
            {
                llRegionSayTo(target, 0, iris_build_cmd_payload("die"));
                llRegionSayTo(target, 0, "die");
                say_debug("Requested orphan _Iris cleanup on channel 0");
            }

@next_target;
        }
    }

    no_sensor()
    {
        if (sensorMode == SENSOR_MODE_IRIS_CLEANUP && llGetUnixTime() > irisCleanupWatchUntilUnix)
        {
            stop_iris_cleanup_watch();
        }
    }

    touch_start(integer count)
    {
        if (scriptStandby) return;

        key who = llDetectedKey(0);
        if (who != llGetOwner()) return;
        pendingMenuInput = "";
        show_owner_menu();
    }
}
