Bridge.Database.AfterMigration("sky_phone", function()
local password_pepper = tostring(Config.Server.CrewLinkPasswordPepper or "")
AddEventHandler("sky_phone:configurator:serverUpdated", function()
    password_pepper = tostring(Config.Server.CrewLinkPasswordPepper or "")
end)

local role_levels = {
    guest = 1,
    member = 2,
    moderator = 3,
    coordinator = 4,
    owner = 5,
}
local member_roles = {
    guest = true,
    member = true,
    moderator = true,
    coordinator = true,
}
local group_colours = {
    cyan = true,
    blue = true,
    violet = true,
    orange = true,
    green = true,
    rose = true,
}
local ping_types = {
    meeting = true,
    danger = true,
    help = true,
    target = true,
    info = true,
}
local live_sources_cache = {
    expires_at = 0,
    sources = {},
}

if password_pepper == "" then
    Bridge.Debug(
        "warn",
        "[sky_phone] Config.Server.CrewLinkPasswordPepper is empty. CrewLink passwords still work, but their hashes lack the required server-side secret. Set a stable random value in config/config.lua before production; changing it later invalidates existing CrewLink passwords.",
        { always = true }
    )
end

local function affected_rows(result)
    if type(result) == "number" then
        return result
    end
    return type(result) == "table" and tonumber(result.affectedRows) or 0
end

local function trim(value)
    if type(value) ~= "string" then
        return nil
    end
    return value:match("^%s*(.-)%s*$")
end

local function valid_text(value, minimum, maximum)
    local length = type(value) == "string" and utf8.len(value) or nil
    return length and length >= minimum and length <= maximum
end

local function new_id()
    local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {})
    if not rows[1] or type(rows[1].id) ~= "string" then
        error("[sky_phone] Database did not generate a CrewLink id.")
    end
    return rows[1].id
end

local function new_invite_code()
    for _ = 1, 5 do
        local rows = Bridge.Database.Query([[
            SELECT UPPER(SUBSTRING(REPLACE(UUID(), '-', ''), 1, ?)) AS `code`
        ]], { Config.CrewLink.InviteCodeLength })
        local code = rows[1] and rows[1].code
        if type(code) == "string" then
            local existing = Bridge.Database.Query(
                "SELECT 1 FROM `sky_phone_crewlink_groups` WHERE `invite_code` = ? LIMIT 1",
                { code }
            )
            if not existing[1] then
                return code
            end
        end
    end
    error("[sky_phone] Could not generate a unique CrewLink invite code.")
end

local function profile_dto(row)
    return {
        id = row.id,
        username = row.username,
        avatarMediaId = row.avatar_media_id and tonumber(row.avatar_media_id) or nil,
        avatarUrl = row.avatar_url,
        activeGroupId = row.active_group_id,
        mapVisible = tonumber(row.map_visible) == 1,
        overheadVisible = tonumber(row.overhead_visible) == 1,
    }
end

local function profile_for_session(source)
    local session, error_response = SkyPhone.RequireSession(source)
    if not session then
        return nil, error_response
    end
    local rows = Bridge.Database.Query([[
        SELECT p.`id`, p.`account_id`, p.`username`, p.`avatar_media_id`, p.`active_group_id`,
            p.`map_visible`, p.`overhead_visible`, avatar.`url` AS `avatar_url`
        FROM `sky_phone_crewlink_sessions` crew_session
        JOIN `sky_phone_crewlink_profiles` p ON p.`id` = crew_session.`profile_id`
        LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = p.`avatar_media_id`
        WHERE crew_session.`device_imei` = ?
        LIMIT 1
    ]], { session.imei })
    if not rows[1] then
        return nil, { success = false, error = "not_authenticated" }
    end
    rows[1].account_id = tonumber(rows[1].account_id)
    return rows[1]
end

local function require_profile(source)
    return profile_for_session(source)
end

local function membership(profile_id, group_id)
    local rows = Bridge.Database.Query([[
        SELECT m.`group_id`, m.`profile_id`, m.`role`, m.`joined_at`,
            g.`name` AS `group_name`, g.`colour`, g.`allow_member_pings`,
            g.`overhead_allowed`, g.`invite_code`, g.`owner_profile_id`
        FROM `sky_phone_crewlink_memberships` m
        JOIN `sky_phone_crewlink_groups` g ON g.`id` = m.`group_id`
        WHERE m.`profile_id` = ? AND m.`group_id` = ?
        LIMIT 1
    ]], { profile_id, group_id })
    return rows[1]
end

local function group_count(group_id)
    local rows = Bridge.Database.Query([[
        SELECT COUNT(*) AS `count`
        FROM `sky_phone_crewlink_memberships`
        WHERE `group_id` = ?
    ]], { group_id })
    return tonumber(rows[1] and rows[1].count) or 0
end

local function member_dtos(group_id)
    local rows = Bridge.Database.Query([[
        SELECT p.`id`, p.`account_id`, p.`username`, p.`map_visible`, p.`overhead_visible`,
            avatar.`url` AS `avatar_url`, m.`role`, UNIX_TIMESTAMP(m.`joined_at`) AS `joined_at`
        FROM `sky_phone_crewlink_memberships` m
        JOIN `sky_phone_crewlink_profiles` p ON p.`id` = m.`profile_id`
        LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = p.`avatar_media_id`
        WHERE m.`group_id` = ?
        ORDER BY FIELD(m.`role`, 'owner', 'coordinator', 'moderator', 'member', 'guest'), m.`joined_at`
    ]], { group_id })
    for _, row in ipairs(rows) do
        row.account_id = tonumber(row.account_id)
        row.mapVisible = tonumber(row.map_visible) == 1
        row.overheadVisible = tonumber(row.overhead_visible) == 1
        row.avatarUrl = row.avatar_url
        row.map_visible = nil
        row.overhead_visible = nil
        row.avatar_url = nil
        row.joinedAt = (tonumber(row.joined_at) or 0) * 1000
        row.joined_at = nil
    end
    return rows
end

local function group_dto(row, profile_id)
    local role = row.role
    return {
        id = row.id or row.group_id,
        name = row.name or row.group_name,
        colour = row.colour,
        role = role,
        inviteCode = role_levels[role] >= role_levels.coordinator and row.invite_code or nil,
        allowMemberPings = tonumber(row.allow_member_pings) == 1,
        overheadAllowed = tonumber(row.overhead_allowed) == 1,
        memberCount = tonumber(row.member_count) or group_count(row.id or row.group_id),
        isOwner = row.owner_profile_id == profile_id,
    }
end

local function list_groups(profile)
    local rows = Bridge.Database.Query([[
        SELECT g.`id`, g.`name`, g.`colour`, g.`invite_code`, g.`owner_profile_id`,
            g.`allow_member_pings`, g.`overhead_allowed`, m.`role`,
            (SELECT COUNT(*) FROM `sky_phone_crewlink_memberships` gm
                WHERE gm.`group_id` = g.`id`) AS `member_count`
        FROM `sky_phone_crewlink_memberships` m
        JOIN `sky_phone_crewlink_groups` g ON g.`id` = m.`group_id`
        WHERE m.`profile_id` = ?
        ORDER BY (g.`id` = ?) DESC, g.`name`
    ]], { profile.id, profile.active_group_id or "" })
    local groups = {}
    for index, row in ipairs(rows) do
        groups[index] = group_dto(row, profile.id)
    end
    return groups
end

local function active_pings(group_id)
    local rows = Bridge.Database.Query([[
        SELECT pg.`id`, pg.`type`, pg.`label`, pg.`position_x`, pg.`position_y`,
            pg.`position_z`, pg.`creator_profile_id`, pg.`source_resource`,
            cp.`username` AS `creator_username`,
            UNIX_TIMESTAMP(pg.`created_at`) AS `created_at`,
            UNIX_TIMESTAMP(pg.`expires_at`) AS `expires_at`
        FROM `sky_phone_crewlink_pings` pg
        LEFT JOIN `sky_phone_crewlink_profiles` cp ON cp.`id` = pg.`creator_profile_id`
        WHERE pg.`group_id` = ? AND pg.`expires_at` > CURRENT_TIMESTAMP
        ORDER BY pg.`created_at` DESC
        LIMIT ?
    ]], { group_id, Config.CrewLink.MaximumActivePings })
    for _, row in ipairs(rows) do
        row.coords = {
            x = tonumber(row.position_x) or 0.0,
            y = tonumber(row.position_y) or 0.0,
            z = tonumber(row.position_z) or 0.0,
        }
        row.position_x = nil
        row.position_y = nil
        row.position_z = nil
        row.creatorProfileId = row.creator_profile_id
        row.creatorUsername = row.creator_username or row.source_resource or "CrewLink"
        row.sourceResource = row.source_resource
        row.creator_profile_id = nil
        row.creator_username = nil
        row.source_resource = nil
        row.createdAt = (tonumber(row.created_at) or 0) * 1000
        row.expiresAt = (tonumber(row.expires_at) or 0) * 1000
        row.created_at = nil
        row.expires_at = nil
    end
    return rows
end

local function pending_invitations(profile_id)
    local rows = Bridge.Database.Query([[
        SELECT i.`id`, i.`group_id`, g.`name` AS `group_name`, g.`colour`,
            p.`username` AS `inviter_username`,
            UNIX_TIMESTAMP(i.`expires_at`) AS `expires_at`
        FROM `sky_phone_crewlink_invitations` i
        JOIN `sky_phone_crewlink_groups` g ON g.`id` = i.`group_id`
        JOIN `sky_phone_crewlink_profiles` p ON p.`id` = i.`inviter_profile_id`
        WHERE i.`invitee_profile_id` = ? AND i.`status` = 'pending'
            AND i.`expires_at` > CURRENT_TIMESTAMP
        ORDER BY i.`created_at` DESC
    ]], { profile_id })
    for _, row in ipairs(rows) do
        row.groupId = row.group_id
        row.groupName = row.group_name
        row.inviterUsername = row.inviter_username
        row.expiresAt = (tonumber(row.expires_at) or 0) * 1000
        row.group_id = nil
        row.group_name = nil
        row.inviter_username = nil
        row.expires_at = nil
    end
    return rows
end

local function live_sources_by_account()
    local now = os.time()
    if live_sources_cache.expires_at >= now then
        return live_sources_cache.sources
    end
    local live = {}
    for _, player_source in ipairs(Bridge.Framework.GetPlayers()) do
        local source = tonumber(player_source) or player_source
        local account = SkyPhone.RequireAccount(source)
        if account and not live[account.id] then
            live[account.id] = source
        end
    end
    live_sources_cache.expires_at = now + 1
    live_sources_cache.sources = live
    return live
end

local function live_group(group_id)
    local members = member_dtos(group_id)
    local live_sources = live_sources_by_account()
    for _, member in ipairs(members) do
        local source = live_sources[member.account_id]
        member.online = source ~= nil
        member.source = source
        if source and member.mapVisible then
            local ped = GetPlayerPed(source)
            local coords = GetEntityCoords(ped)
            member.coords = { x = coords.x, y = coords.y, z = coords.z }
        end
        member.account_id = nil
    end
    return members
end

local function group_account_ids(group_id)
    local rows = Bridge.Database.Query([[
        SELECT p.`account_id`
        FROM `sky_phone_crewlink_memberships` m
        JOIN `sky_phone_crewlink_profiles` p ON p.`id` = m.`profile_id`
        WHERE m.`group_id` = ?
    ]], { group_id })
    local account_ids = {}
    for _, row in ipairs(rows) do
        account_ids[#account_ids + 1] = tonumber(row.account_id)
    end
    return account_ids
end

local function profile_account_id(profile_id)
    local rows = Bridge.Database.Query(
        "SELECT `account_id` FROM `sky_phone_crewlink_profiles` WHERE `id` = ? LIMIT 1",
        { profile_id }
    )
    return rows[1] and tonumber(rows[1].account_id) or nil
end

local function notify_group(group_id, kind, actor, extra)
    for _, account_id in ipairs(group_account_ids(group_id)) do
        local payload = { kind = kind, actor = actor }
        for key, value in pairs(extra or {}) do
            payload[key] = value
        end
        SkyPhone.NotifyAccountDevices(account_id, "sky_phone:crewlink:notification", payload)
    end
end

local function refresh_group(group_id)
    for _, account_id in ipairs(group_account_ids(group_id)) do
        SkyPhone.NotifyAccount(account_id, "sky_phone:crewlink:changed", { groupId = group_id })
    end
end

local function bootstrap(profile)
    local groups = list_groups(profile)
    local active_group = nil
    if profile.active_group_id then
        local active_membership = membership(profile.id, profile.active_group_id)
        if active_membership then
            active_group = group_dto(active_membership, profile.id)
            active_group.members = live_group(profile.active_group_id)
            active_group.pings = active_pings(profile.active_group_id)
        end
    end
    return {
        profile = profile_dto(profile),
        groups = groups,
        activeGroup = active_group,
        invitations = pending_invitations(profile.id),
        limits = {
            maximumGroups = Config.CrewLink.MaximumGroupsPerProfile,
            maximumMembers = Config.CrewLink.MaximumMembersPerGroup,
            nearbyDistance = Config.CrewLink.NearbyInviteDistance,
            pingLifetimeSeconds = Config.CrewLink.PingLifetimeSeconds,
            overheadDistance = Config.CrewLink.OverheadDistance,
        },
    }
end

local function valid_username(value)
    local username = trim(value)
    if not valid_text(username, Config.CrewLink.UsernameMinLength, Config.CrewLink.UsernameMaxLength)
        or not username:match("^[A-Za-z0-9][A-Za-z0-9._]*[A-Za-z0-9]$")
    then
        return nil
    end
    return username
end

local function valid_password(value)
    local length = type(value) == "string" and utf8.len(value) or nil
    return length
        and length >= Config.CrewLink.PasswordMinLength
        and length <= Config.CrewLink.PasswordMaxLength
end

local function valid_group_name(value)
    local name = trim(value)
    return valid_text(name, Config.CrewLink.GroupNameMinLength, Config.CrewLink.GroupNameMaxLength)
        and name or nil
end

local function validate_coords(value)
    if type(value) ~= "table" then
        return nil
    end
    local x = tonumber(value.x)
    local y = tonumber(value.y)
    local z = tonumber(value.z) or 0.0
    if not x or not y or x ~= x or y ~= y or z ~= z
        or math.abs(x) > 10000.0 or math.abs(y) > 10000.0
        or z < -1000.0 or z > 3000.0
    then
        return nil
    end
    return { x = x, y = y, z = z }
end

local function allow(source, operation)
    return SkyPhone.AllowOperation(
        source,
        "crewlink:" .. operation,
        Config.CrewLink.ActionsPerMinute,
        60
    )
end

Bridge.Callbacks.Register("sky_phone:crewlink:bootstrap", function(source)
    if not SkyPhone.AllowOperation(
        source,
        "crewlink:bootstrap",
        Config.CrewLink.LiveRequestsPerMinute,
        60
    ) then
        return { success = false, error = "rate_limited" }
    end
    local profile, error_response = profile_for_session(source)
    if not profile then
        if error_response and error_response.error ~= "not_authenticated" then
            return error_response
        end
        return {
            success = true,
            data = { authenticated = false, profile = nil, groups = {}, invitations = {} },
        }
    end
    local data = bootstrap(profile)
    data.authenticated = true
    return { success = true, data = data }
end)

Bridge.Callbacks.Register("sky_phone:crewlink:register", function(source, data)
    if not SkyPhone.AllowOperation(source, "crewlink:register", 5, 60) then
        return { success = false, error = "rate_limited" }
    end
    local account, error_response = SkyPhone.RequireAccount(source)
    if not account then
        return error_response
    end
    data = type(data) == "table" and data or {}
    local username = valid_username(data.username)
    if not username then
        return { success = false, error = "invalid_username" }
    end
    if not valid_password(data.password) then
        return { success = false, error = "invalid_password" }
    end
    local avatar_media_id = tonumber(data.avatarMediaId) or 0
    if avatar_media_id < 0 or avatar_media_id ~= math.floor(avatar_media_id) then
        return { success = false, error = "invalid_profile_image" }
    end
    if avatar_media_id > 0 and not SkyPhoneMedia.ResolveOwnedMedia(source, avatar_media_id, "photo") then
        return { success = false, error = "invalid_profile_image" }
    end
    local existing = Bridge.Database.Query([[
        SELECT p.`id`, c.`profile_id` AS `credential_profile_id`
        FROM `sky_phone_crewlink_profiles` p
        LEFT JOIN `sky_phone_crewlink_credentials` c ON c.`profile_id` = p.`id`
        WHERE p.`account_id` = ? LIMIT 1
    ]], { account.id })[1]
    if existing and existing.credential_profile_id then
        return { success = false, error = "profile_exists" }
    end
    local duplicate = Bridge.Database.Query([[
        SELECT `id` FROM `sky_phone_crewlink_profiles`
        WHERE `username` = ? AND `account_id` <> ? LIMIT 1
    ]], { username, account.id })
    if duplicate[1] then
        return { success = false, error = "username_taken" }
    end
    local entropy = Bridge.Database.Query(
        "SELECT UUID() AS `id`, REPLACE(UUID(), '-', '') AS `salt`",
        {}
    )[1]
    if not entropy or type(entropy.id) ~= "string" or type(entropy.salt) ~= "string" then
        error("[sky_phone] Database did not generate CrewLink registration entropy.")
    end
    local profile_id = existing and existing.id or entropy.id
    local queries = {}
    if existing then
        queries[#queries + 1] = {
            query = [[UPDATE `sky_phone_crewlink_profiles`
                SET `username` = ?, `avatar_media_id` = NULLIF(?, 0) WHERE `id` = ?]],
            params = { username, avatar_media_id, profile_id },
        }
    else
        queries[#queries + 1] = {
            query = [[INSERT INTO `sky_phone_crewlink_profiles`
                (`id`, `account_id`, `username`, `avatar_media_id`) VALUES (?, ?, ?, NULLIF(?, 0))]],
            params = { profile_id, account.id, username, avatar_media_id },
        }
    end
    queries[#queries + 1] = {
        query = [[INSERT INTO `sky_phone_crewlink_credentials`
            (`profile_id`, `password_hash`, `password_salt`)
            VALUES (?, UNHEX(SHA2(CONCAT(?, ?, ?), 256)), ?)]],
        params = { profile_id, password_pepper, entropy.salt, data.password, entropy.salt },
    }
    queries[#queries + 1] = {
        query = [[INSERT INTO `sky_phone_crewlink_sessions` (`device_imei`, `profile_id`)
            VALUES (?, ?) ON DUPLICATE KEY UPDATE `profile_id` = VALUES(`profile_id`),
                `updated_at` = CURRENT_TIMESTAMP]],
        params = { account.imei, profile_id },
    }
    if not Bridge.Database.Transaction(queries) then
        return { success = false, error = "request_failed" }
    end
    local profile = require_profile(source)
    local response = bootstrap(profile)
    response.authenticated = true
    return { success = true, data = response }
end)

Bridge.Callbacks.Register("sky_phone:crewlink:login", function(source, data)
    if not SkyPhone.AllowOperation(source, "crewlink:login", 10, 60) then
        return { success = false, error = "rate_limited" }
    end
    local account, error_response = SkyPhone.RequireAccount(source)
    if not account then
        return error_response
    end
    if type(data) ~= "table" or not valid_password(data.password) then
        return { success = false, error = "invalid_credentials" }
    end
    local profiles = Bridge.Database.Query([[
        SELECT p.`id` FROM `sky_phone_crewlink_profiles` p
        JOIN `sky_phone_crewlink_credentials` c ON c.`profile_id` = p.`id`
        WHERE p.`account_id` = ?
            AND c.`password_hash` = UNHEX(SHA2(CONCAT(?, c.`password_salt`, ?), 256))
        LIMIT 1
    ]], { account.id, password_pepper, data.password })
    if not profiles[1] then
        return { success = false, error = "invalid_credentials" }
    end
    Bridge.Database.Query([[
        INSERT INTO `sky_phone_crewlink_sessions` (`device_imei`, `profile_id`)
        VALUES (?, ?) ON DUPLICATE KEY UPDATE `profile_id` = VALUES(`profile_id`),
            `updated_at` = CURRENT_TIMESTAMP
    ]], { account.imei, profiles[1].id })
    local profile = require_profile(source)
    local response = bootstrap(profile)
    response.authenticated = true
    return { success = true, data = response }
end)

Bridge.Callbacks.Register("sky_phone:crewlink:logout", function(source)
    local session, error_response = SkyPhone.RequireSession(source)
    if not session then
        return error_response
    end
    Bridge.Database.Query(
        "DELETE FROM `sky_phone_crewlink_sessions` WHERE `device_imei` = ?",
        { session.imei }
    )
    return { success = true }
end)

Bridge.Callbacks.Register("sky_phone:crewlink:update-profile", function(source, data)
    if not allow(source, "profile") then
        return { success = false, error = "rate_limited" }
    end
    local profile, error_response = require_profile(source)
    if not profile then
        return error_response
    end
    local username = valid_username(data and data.username)
    if not username or type(data.mapVisible) ~= "boolean" or type(data.overheadVisible) ~= "boolean" then
        return { success = false, error = "invalid_profile" }
    end
    local avatar_media_id = profile.avatar_media_id and tonumber(profile.avatar_media_id) or nil
    if data.avatarMediaId ~= nil then
        local submitted_avatar_id = tonumber(data.avatarMediaId)
        if not submitted_avatar_id or submitted_avatar_id < 0
            or submitted_avatar_id ~= math.floor(submitted_avatar_id)
        then
            return { success = false, error = "invalid_profile_image" }
        end
        if submitted_avatar_id > 0
            and not SkyPhoneMedia.ResolveOwnedMedia(source, submitted_avatar_id, "photo")
        then
            return { success = false, error = "invalid_profile_image" }
        end
        avatar_media_id = submitted_avatar_id > 0 and submitted_avatar_id or nil
    end
    local result = Bridge.Database.Query([[
        UPDATE IGNORE `sky_phone_crewlink_profiles`
        SET `username` = ?, `map_visible` = ?, `overhead_visible` = ?, `avatar_media_id` = ?
        WHERE `id` = ?
    ]], { username, data.mapVisible and 1 or 0, data.overheadVisible and 1 or 0, avatar_media_id, profile.id })
    if affected_rows(result) ~= 1 and username:lower() ~= tostring(profile.username):lower() then
        return { success = false, error = "username_taken" }
    end
    if profile.active_group_id then
        refresh_group(profile.active_group_id)
    end
    local updated = require_profile(source)
    return { success = true, data = bootstrap(updated) }
end)

Bridge.Callbacks.Register("sky_phone:crewlink:create-group", function(source, data)
    if not allow(source, "group") then
        return { success = false, error = "rate_limited" }
    end
    local profile, error_response = require_profile(source)
    if not profile then
        return error_response
    end
    local name = valid_group_name(data and data.name)
    local colour = data and data.colour
    if not name or not group_colours[colour] then
        return { success = false, error = "invalid_group" }
    end
    local groups = list_groups(profile)
    if #groups >= Config.CrewLink.MaximumGroupsPerProfile then
        return { success = false, error = "group_limit" }
    end
    local group_id = new_id()
    local success = Bridge.Database.Transaction({
        {
            query = [[INSERT INTO `sky_phone_crewlink_groups`
                (`id`, `name`, `colour`, `owner_profile_id`, `invite_code`)
                VALUES (?, ?, ?, ?, ?)]],
            params = { group_id, name, colour, profile.id, new_invite_code() },
        },
        {
            query = [[INSERT INTO `sky_phone_crewlink_memberships`
                (`group_id`, `profile_id`, `role`) VALUES (?, ?, 'owner')]],
            params = { group_id, profile.id },
        },
        {
            query = "UPDATE `sky_phone_crewlink_profiles` SET `active_group_id` = ? WHERE `id` = ?",
            params = { group_id, profile.id },
        },
    })
    if not success then
        return { success = false, error = "request_failed" }
    end
    TriggerEvent("sky_phone:crewlink:memberJoined", group_id, profile.id)
    TriggerEvent("sky_phone:crewlink:activeChanged", profile.id, group_id)
    local updated = require_profile(source)
    return { success = true, data = bootstrap(updated) }
end)

Bridge.Callbacks.Register("sky_phone:crewlink:update-group", function(source, data)
    if not allow(source, "group") then
        return { success = false, error = "rate_limited" }
    end
    local profile, error_response = require_profile(source)
    if not profile then
        return error_response
    end
    local member = type(data) == "table" and membership(profile.id, data.groupId) or nil
    if not member or role_levels[member.role] < role_levels.coordinator then
        return { success = false, error = "forbidden" }
    end
    local name = valid_group_name(data.name)
    if not name or not group_colours[data.colour]
        or type(data.allowMemberPings) ~= "boolean" or type(data.overheadAllowed) ~= "boolean"
    then
        return { success = false, error = "invalid_group" }
    end
    Bridge.Database.Query([[
        UPDATE `sky_phone_crewlink_groups`
        SET `name` = ?, `colour` = ?, `allow_member_pings` = ?, `overhead_allowed` = ?
        WHERE `id` = ?
    ]], { name, data.colour, data.allowMemberPings and 1 or 0, data.overheadAllowed and 1 or 0, data.groupId })
    refresh_group(data.groupId)
    return { success = true }
end)

Bridge.Callbacks.Register("sky_phone:crewlink:delete-group", function(source, data)
    if not allow(source, "group") then
        return { success = false, error = "rate_limited" }
    end
    local profile, error_response = require_profile(source)
    if not profile then
        return error_response
    end
    local member = type(data) == "table" and membership(profile.id, data.groupId) or nil
    if not member or member.role ~= "owner" then
        return { success = false, error = "forbidden" }
    end
    local affected_accounts = group_account_ids(data.groupId)
    local success = Bridge.Database.Transaction({
        {
            query = "UPDATE `sky_phone_crewlink_profiles` SET `active_group_id` = NULL WHERE `active_group_id` = ?",
            params = { data.groupId },
        },
        {
            query = "DELETE FROM `sky_phone_crewlink_groups` WHERE `id` = ? AND `owner_profile_id` = ?",
            params = { data.groupId, profile.id },
        },
    })
    if not success then
        return { success = false, error = "request_failed" }
    end
    for _, account_id in ipairs(affected_accounts) do
        SkyPhone.NotifyAccount(account_id, "sky_phone:crewlink:changed", { groupId = data.groupId })
    end
    TriggerEvent("sky_phone:crewlink:memberLeft", data.groupId, profile.id)
    return { success = true }
end)

Bridge.Callbacks.Register("sky_phone:crewlink:set-active", function(source, data)
    if not allow(source, "group") then
        return { success = false, error = "rate_limited" }
    end
    local profile, error_response = require_profile(source)
    if not profile then
        return error_response
    end
    if type(data) ~= "table" or type(data.groupId) ~= "string" or not membership(profile.id, data.groupId) then
        return { success = false, error = "group_not_found" }
    end
    Bridge.Database.Query(
        "UPDATE `sky_phone_crewlink_profiles` SET `active_group_id` = ? WHERE `id` = ?",
        { data.groupId, profile.id }
    )
    TriggerEvent("sky_phone:crewlink:activeChanged", profile.id, data.groupId)
    local updated = require_profile(source)
    return { success = true, data = bootstrap(updated) }
end)

Bridge.Callbacks.Register("sky_phone:crewlink:join-code", function(source, data)
    if not allow(source, "invite") then
        return { success = false, error = "rate_limited" }
    end
    local profile, error_response = require_profile(source)
    if not profile then
        return error_response
    end
    local code = type(data) == "table" and trim(data.code) or nil
    if not code or #code ~= Config.CrewLink.InviteCodeLength then
        return { success = false, error = "invalid_code" }
    end
    local rows = Bridge.Database.Query([[
        SELECT `id`, `name` FROM `sky_phone_crewlink_groups`
        WHERE `invite_code` = ? LIMIT 1
    ]], { code:upper() })
    local group = rows[1]
    if not group then
        return { success = false, error = "invalid_code" }
    end
    if membership(profile.id, group.id) then
        return { success = false, error = "already_member" }
    end
    if #list_groups(profile) >= Config.CrewLink.MaximumGroupsPerProfile then
        return { success = false, error = "group_limit" }
    end
    if group_count(group.id) >= Config.CrewLink.MaximumMembersPerGroup then
        return { success = false, error = "member_limit" }
    end
    local success = Bridge.Database.Transaction({
        {
            query = [[INSERT INTO `sky_phone_crewlink_memberships`
                (`group_id`, `profile_id`, `role`) VALUES (?, ?, 'member')]],
            params = { group.id, profile.id },
        },
        {
            query = "UPDATE `sky_phone_crewlink_profiles` SET `active_group_id` = ? WHERE `id` = ?",
            params = { group.id, profile.id },
        },
    })
    if not success then
        return { success = false, error = "request_failed" }
    end
    notify_group(group.id, "member_joined", profile.username, { groupName = group.name })
    refresh_group(group.id)
    TriggerEvent("sky_phone:crewlink:memberJoined", group.id, profile.id)
    local updated = require_profile(source)
    return { success = true, data = bootstrap(updated) }
end)

Bridge.Callbacks.Register("sky_phone:crewlink:rotate-code", function(source, data)
    if not allow(source, "invite") then
        return { success = false, error = "rate_limited" }
    end
    local profile, error_response = require_profile(source)
    if not profile then
        return error_response
    end
    local member = type(data) == "table" and membership(profile.id, data.groupId) or nil
    if not member or role_levels[member.role] < role_levels.coordinator then
        return { success = false, error = "forbidden" }
    end
    local code = new_invite_code()
    Bridge.Database.Query("UPDATE `sky_phone_crewlink_groups` SET `invite_code` = ? WHERE `id` = ?", {
        code,
        data.groupId,
    })
    refresh_group(data.groupId)
    return { success = true, data = { inviteCode = code } }
end)

Bridge.Callbacks.Register("sky_phone:crewlink:nearby", function(source)
    if not allow(source, "nearby") then
        return { success = false, error = "rate_limited" }
    end
    local profile, error_response = require_profile(source)
    if not profile then
        return error_response
    end
    local member = profile.active_group_id and membership(profile.id, profile.active_group_id) or nil
    if not member or role_levels[member.role] < role_levels.moderator then
        return { success = false, error = "forbidden" }
    end
    local player_ped = GetPlayerPed(source)
    local player_coords = GetEntityCoords(player_ped)
    local player_bucket = GetPlayerRoutingBucket(source)
    local candidates = {}
    for _, target_value in ipairs(Bridge.Framework.GetPlayers()) do
        local target = tonumber(target_value) or target_value
        if target ~= source and GetPlayerRoutingBucket(target) == player_bucket then
            local target_coords = GetEntityCoords(GetPlayerPed(target))
            local distance = #(player_coords - target_coords)
            if distance <= Config.CrewLink.NearbyInviteDistance then
                local target_account = SkyPhone.RequireAccount(target)
                if target_account then
                    local rows = Bridge.Database.Query([[
                        SELECT `id`, `username` FROM `sky_phone_crewlink_profiles`
                        WHERE `account_id` = ? LIMIT 1
                    ]], { target_account.id })
                    local target_profile = rows[1]
                    if target_profile and not membership(target_profile.id, profile.active_group_id) then
                        candidates[#candidates + 1] = {
                            source = target,
                            username = target_profile.username,
                            distance = math.floor(distance * 10 + 0.5) / 10,
                        }
                        if #candidates >= Config.CrewLink.NearbyScanLimit then
                            break
                        end
                    end
                end
            end
        end
    end
    return { success = true, data = candidates }
end)

Bridge.Callbacks.Register("sky_phone:crewlink:invite-nearby", function(source, data)
    if not allow(source, "invite") then
        return { success = false, error = "rate_limited" }
    end
    local profile, error_response = require_profile(source)
    if not profile then
        return error_response
    end
    local member = profile.active_group_id and membership(profile.id, profile.active_group_id) or nil
    if not member or role_levels[member.role] < role_levels.moderator then
        return { success = false, error = "forbidden" }
    end
    local target = type(data) == "table" and math.floor(tonumber(data.targetSource) or 0) or 0
    if target <= 0 or GetPlayerRoutingBucket(target) ~= GetPlayerRoutingBucket(source) then
        return { success = false, error = "player_not_nearby" }
    end
    local distance = #(GetEntityCoords(GetPlayerPed(source)) - GetEntityCoords(GetPlayerPed(target)))
    if distance > Config.CrewLink.NearbyInviteDistance then
        return { success = false, error = "player_not_nearby" }
    end
    local target_account = SkyPhone.RequireAccount(target)
    if not target_account then
        return { success = false, error = "player_unavailable" }
    end
    local rows = Bridge.Database.Query([[
        SELECT `id`, `username` FROM `sky_phone_crewlink_profiles`
        WHERE `account_id` = ? LIMIT 1
    ]], { target_account.id })
    local target_profile = rows[1]
    if not target_profile or membership(target_profile.id, profile.active_group_id) then
        return { success = false, error = "player_unavailable" }
    end
    local invitation_id = new_id()
    Bridge.Database.Query([[
        INSERT INTO `sky_phone_crewlink_invitations`
            (`id`, `group_id`, `inviter_profile_id`, `invitee_profile_id`, `expires_at`)
        VALUES (?, ?, ?, ?, DATE_ADD(CURRENT_TIMESTAMP, INTERVAL ? SECOND))
        ON DUPLICATE KEY UPDATE `inviter_profile_id` = VALUES(`inviter_profile_id`),
            `status` = 'pending', `expires_at` = VALUES(`expires_at`), `created_at` = CURRENT_TIMESTAMP
    ]], {
        invitation_id,
        profile.active_group_id,
        profile.id,
        target_profile.id,
        Config.CrewLink.InviteLifetimeSeconds,
    })
    SkyPhone.NotifyAccountDevices(target_account.id, "sky_phone:crewlink:notification", {
        kind = "invite",
        actor = profile.username,
        groupName = member.group_name,
    })
    TriggerClientEvent("sky_phone:crewlink:changed", target, { groupId = profile.active_group_id })
    return { success = true }
end)

Bridge.Callbacks.Register("sky_phone:crewlink:respond-invite", function(source, data)
    if not allow(source, "invite") then
        return { success = false, error = "rate_limited" }
    end
    local profile, error_response = require_profile(source)
    if not profile then
        return error_response
    end
    local accepted = type(data) == "table" and data.accepted
    if type(accepted) ~= "boolean" or type(data.invitationId) ~= "string" then
        return { success = false, error = "invalid_invitation" }
    end
    local rows = Bridge.Database.Query([[
        SELECT i.`id`, i.`group_id`, g.`name` AS `group_name`
        FROM `sky_phone_crewlink_invitations` i
        JOIN `sky_phone_crewlink_groups` g ON g.`id` = i.`group_id`
        WHERE i.`id` = ? AND i.`invitee_profile_id` = ? AND i.`status` = 'pending'
            AND i.`expires_at` > CURRENT_TIMESTAMP
        LIMIT 1
    ]], { data.invitationId, profile.id })
    local invitation = rows[1]
    if not invitation then
        return { success = false, error = "invitation_expired" }
    end
    if not accepted then
        Bridge.Database.Query(
            "UPDATE `sky_phone_crewlink_invitations` SET `status` = 'declined' WHERE `id` = ?",
            { invitation.id }
        )
        return { success = true }
    end
    if #list_groups(profile) >= Config.CrewLink.MaximumGroupsPerProfile then
        return { success = false, error = "group_limit" }
    end
    if group_count(invitation.group_id) >= Config.CrewLink.MaximumMembersPerGroup then
        return { success = false, error = "member_limit" }
    end
    local success = Bridge.Database.Transaction({
        {
            query = [[INSERT INTO `sky_phone_crewlink_memberships`
                (`group_id`, `profile_id`, `role`) VALUES (?, ?, 'member')]],
            params = { invitation.group_id, profile.id },
        },
        {
            query = "UPDATE `sky_phone_crewlink_invitations` SET `status` = 'accepted' WHERE `id` = ? AND `status` = 'pending'",
            params = { invitation.id },
        },
        {
            query = "UPDATE `sky_phone_crewlink_profiles` SET `active_group_id` = ? WHERE `id` = ?",
            params = { invitation.group_id, profile.id },
        },
    })
    if not success then
        return { success = false, error = "request_failed" }
    end
    notify_group(invitation.group_id, "member_joined", profile.username, { groupName = invitation.group_name })
    refresh_group(invitation.group_id)
    TriggerEvent("sky_phone:crewlink:memberJoined", invitation.group_id, profile.id)
    local updated = require_profile(source)
    return { success = true, data = bootstrap(updated) }
end)

Bridge.Callbacks.Register("sky_phone:crewlink:update-member", function(source, data)
    if not allow(source, "group") then
        return { success = false, error = "rate_limited" }
    end
    local profile, error_response = require_profile(source)
    if not profile then
        return error_response
    end
    if type(data) ~= "table" or not member_roles[data.role] or type(data.profileId) ~= "string" then
        return { success = false, error = "invalid_role" }
    end
    local actor = membership(profile.id, data.groupId)
    local target = membership(data.profileId, data.groupId)
    if not actor or not target or role_levels[actor.role] < role_levels.coordinator
        or role_levels[target.role] >= role_levels[actor.role]
        or (actor.role ~= "owner" and role_levels[data.role] >= role_levels[actor.role])
    then
        return { success = false, error = "forbidden" }
    end
    Bridge.Database.Query([[
        UPDATE `sky_phone_crewlink_memberships` SET `role` = ?
        WHERE `group_id` = ? AND `profile_id` = ?
    ]], { data.role, data.groupId, data.profileId })
    local target_account_id = profile_account_id(data.profileId)
    if target_account_id then
        SkyPhone.NotifyAccountDevices(target_account_id, "sky_phone:crewlink:notification", {
            kind = "role",
            actor = profile.username,
            groupName = target.group_name,
        })
    end
    refresh_group(data.groupId)
    return { success = true }
end)

Bridge.Callbacks.Register("sky_phone:crewlink:transfer-owner", function(source, data)
    if not allow(source, "group") then
        return { success = false, error = "rate_limited" }
    end
    local profile, error_response = require_profile(source)
    if not profile then
        return error_response
    end
    local actor = type(data) == "table" and membership(profile.id, data.groupId) or nil
    local target = type(data) == "table" and membership(data.profileId, data.groupId) or nil
    if not actor or actor.role ~= "owner" or not target or target.profile_id == profile.id then
        return { success = false, error = "forbidden" }
    end
    local success = Bridge.Database.Transaction({
        {
            query = "UPDATE `sky_phone_crewlink_groups` SET `owner_profile_id` = ? WHERE `id` = ? AND `owner_profile_id` = ?",
            params = { data.profileId, data.groupId, profile.id },
        },
        {
            query = "UPDATE `sky_phone_crewlink_memberships` SET `role` = 'coordinator' WHERE `group_id` = ? AND `profile_id` = ?",
            params = { data.groupId, profile.id },
        },
        {
            query = "UPDATE `sky_phone_crewlink_memberships` SET `role` = 'owner' WHERE `group_id` = ? AND `profile_id` = ?",
            params = { data.groupId, data.profileId },
        },
    })
    if not success then
        return { success = false, error = "request_failed" }
    end
    refresh_group(data.groupId)
    return { success = true }
end)

Bridge.Callbacks.Register("sky_phone:crewlink:remove-member", function(source, data)
    if not allow(source, "group") then
        return { success = false, error = "rate_limited" }
    end
    local profile, error_response = require_profile(source)
    if not profile then
        return error_response
    end
    local actor = type(data) == "table" and membership(profile.id, data.groupId) or nil
    local target = type(data) == "table" and membership(data.profileId, data.groupId) or nil
    if not actor or not target or role_levels[actor.role] < role_levels.moderator
        or role_levels[target.role] >= role_levels[actor.role]
    then
        return { success = false, error = "forbidden" }
    end
    local target_account_id = profile_account_id(data.profileId)
    local success = Bridge.Database.Transaction({
        {
            query = "DELETE FROM `sky_phone_crewlink_memberships` WHERE `group_id` = ? AND `profile_id` = ?",
            params = { data.groupId, data.profileId },
        },
        {
            query = "UPDATE `sky_phone_crewlink_profiles` SET `active_group_id` = NULL WHERE `id` = ? AND `active_group_id` = ?",
            params = { data.profileId, data.groupId },
        },
    })
    if not success then
        return { success = false, error = "request_failed" }
    end
    if target_account_id then
        SkyPhone.NotifyAccount(target_account_id, "sky_phone:crewlink:changed", { groupId = data.groupId })
        SkyPhone.NotifyAccountDevices(target_account_id, "sky_phone:crewlink:notification", {
            kind = "removed",
            actor = profile.username,
            groupName = target.group_name,
        })
    end
    TriggerEvent("sky_phone:crewlink:memberLeft", data.groupId, data.profileId)
    refresh_group(data.groupId)
    return { success = true }
end)

Bridge.Callbacks.Register("sky_phone:crewlink:leave", function(source, data)
    if not allow(source, "group") then
        return { success = false, error = "rate_limited" }
    end
    local profile, error_response = require_profile(source)
    if not profile then
        return error_response
    end
    local member = type(data) == "table" and membership(profile.id, data.groupId) or nil
    if not member then
        return { success = false, error = "group_not_found" }
    end
    if member.role == "owner" then
        return { success = false, error = "owner_must_transfer" }
    end
    local success = Bridge.Database.Transaction({
        {
            query = "DELETE FROM `sky_phone_crewlink_memberships` WHERE `group_id` = ? AND `profile_id` = ?",
            params = { data.groupId, profile.id },
        },
        {
            query = "UPDATE `sky_phone_crewlink_profiles` SET `active_group_id` = NULL WHERE `id` = ? AND `active_group_id` = ?",
            params = { profile.id, data.groupId },
        },
    })
    if not success then
        return { success = false, error = "request_failed" }
    end
    TriggerEvent("sky_phone:crewlink:memberLeft", data.groupId, profile.id)
    refresh_group(data.groupId)
    local updated = require_profile(source)
    return { success = true, data = bootstrap(updated) }
end)

local function create_ping(group_id, creator_profile_id, source_resource, data)
    local label = trim(data and data.label)
    local coords = validate_coords(data and data.coords)
    if not ping_types[data and data.type] or not coords
        or not valid_text(label, 1, Config.CrewLink.PingLabelMaxLength)
    then
        return nil, "invalid_ping"
    end
    local count_rows = Bridge.Database.Query([[
        SELECT COUNT(*) AS `count` FROM `sky_phone_crewlink_pings`
        WHERE `group_id` = ? AND `expires_at` > CURRENT_TIMESTAMP
    ]], { group_id })
    if (tonumber(count_rows[1] and count_rows[1].count) or 0) >= Config.CrewLink.MaximumActivePings then
        return nil, "ping_limit"
    end
    local lifetime = math.max(15, math.min(
        Config.CrewLink.PingLifetimeSeconds,
        math.floor(tonumber(data.lifetimeSeconds) or Config.CrewLink.PingLifetimeSeconds)
    ))
    local id = new_id()
    Bridge.Database.Query([[
        INSERT INTO `sky_phone_crewlink_pings`
            (`id`, `group_id`, `creator_profile_id`, `source_resource`, `type`, `label`,
                `position_x`, `position_y`, `position_z`, `expires_at`)
        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, DATE_ADD(CURRENT_TIMESTAMP, INTERVAL ? SECOND))
    ]], {
        id,
        group_id,
        creator_profile_id,
        source_resource,
        data.type,
        label,
        coords.x,
        coords.y,
        coords.z,
        lifetime,
    })
    local ping = {
        id = id,
        type = data.type,
        label = label,
        coords = coords,
        creatorProfileId = creator_profile_id,
        sourceResource = source_resource,
        createdAt = os.time() * 1000,
        expiresAt = (os.time() + lifetime) * 1000,
    }
    TriggerEvent("sky_phone:crewlink:pingCreated", group_id, ping)
    return ping
end

Bridge.Callbacks.Register("sky_phone:crewlink:create-ping", function(source, data)
    if not allow(source, "ping") then
        return { success = false, error = "rate_limited" }
    end
    local profile, error_response = require_profile(source)
    if not profile then
        return error_response
    end
    local group_id = profile.active_group_id
    local member = group_id and membership(profile.id, group_id) or nil
    if not member or (role_levels[member.role] < role_levels.moderator and tonumber(member.allow_member_pings) ~= 1) then
        return { success = false, error = "forbidden" }
    end
    local ping_data = data or {}
    if data and data.useCurrent then
        local coords = GetEntityCoords(GetPlayerPed(source))
        ping_data = {
            type = data.type,
            label = data.label,
            coords = { x = coords.x, y = coords.y, z = coords.z },
        }
    end
    local ping, error_code = create_ping(group_id, profile.id, nil, ping_data)
    if not ping then
        return { success = false, error = error_code }
    end
    notify_group(group_id, "ping", profile.username, {
        groupName = member.group_name,
        pingType = ping.type,
        pingLabel = ping.label,
    })
    refresh_group(group_id)
    return { success = true, data = ping }
end)

Bridge.Callbacks.Register("sky_phone:crewlink:remove-ping", function(source, data)
    if not allow(source, "ping") then
        return { success = false, error = "rate_limited" }
    end
    local profile, error_response = require_profile(source)
    if not profile then
        return error_response
    end
    local group_id = profile.active_group_id
    local member = group_id and membership(profile.id, group_id) or nil
    if not member or type(data) ~= "table" or type(data.pingId) ~= "string" then
        return { success = false, error = "ping_not_found" }
    end
    local rows = Bridge.Database.Query([[
        SELECT `creator_profile_id` FROM `sky_phone_crewlink_pings`
        WHERE `id` = ? AND `group_id` = ? LIMIT 1
    ]], { data.pingId, group_id })
    local ping = rows[1]
    if not ping or (ping.creator_profile_id ~= profile.id and role_levels[member.role] < role_levels.moderator) then
        return { success = false, error = "forbidden" }
    end
    Bridge.Database.Query("DELETE FROM `sky_phone_crewlink_pings` WHERE `id` = ? AND `group_id` = ?", {
        data.pingId,
        group_id,
    })
    TriggerEvent("sky_phone:crewlink:pingRemoved", group_id, data.pingId)
    refresh_group(group_id)
    return { success = true }
end)

Bridge.Callbacks.Register("sky_phone:crewlink:live", function(source)
    if not SkyPhone.AllowOperation(
        source,
        "crewlink:live",
        Config.CrewLink.LiveRequestsPerMinute,
        60
    ) then
        return { success = false, error = "rate_limited" }
    end
    local profile, error_response = require_profile(source)
    if not profile then
        return error_response
    end
    if not profile.active_group_id then
        return { success = true, data = { members = {}, overheadMembers = {}, pings = {} } }
    end
    local active_membership = membership(profile.id, profile.active_group_id)
    if not active_membership then
        return { success = true, data = { members = {}, overheadMembers = {}, pings = {} } }
    end
    local members = live_group(profile.active_group_id)
    local overhead_members = {}
    if tonumber(profile.overhead_visible) == 1
        and tonumber(active_membership.overhead_allowed) == 1
    then
        for _, member in ipairs(members) do
            if member.source and member.source ~= source and member.overheadVisible then
                overhead_members[#overhead_members + 1] = {
                    source = member.source,
                    username = member.username,
                    role = member.role,
                    roleLabel = member.role:sub(1, 1):upper() .. member.role:sub(2),
                }
            end
        end
    end
    return {
        success = true,
        data = {
            members = members,
            overheadMembers = overhead_members,
            pings = active_pings(profile.active_group_id),
        },
    }
end)

exports("GetCrewLinkActiveGroup", function(source)
    local profile = require_profile(source)
    if not profile or not profile.active_group_id then
        return nil
    end
    local member = membership(profile.id, profile.active_group_id)
    return member and group_dto(member, profile.id) or nil
end)

exports("GetCrewLinkGroupMembers", function(group_id)
    if type(group_id) ~= "string" then
        return {}
    end
    local members = {}
    for _, member in ipairs(member_dtos(group_id)) do
        members[#members + 1] = {
            id = member.id,
            username = member.username,
            role = member.role,
            joinedAt = member.joinedAt,
        }
    end
    return members
end)

exports("IsCrewLinkGroupMember", function(source, group_id)
    local profile = require_profile(source)
    return profile and type(group_id) == "string" and membership(profile.id, group_id) ~= nil or false
end)

exports("GetCrewLinkGroupRole", function(source, group_id)
    local profile = require_profile(source)
    local member = profile and type(group_id) == "string" and membership(profile.id, group_id) or nil
    return member and member.role or nil
end)

exports("CreateCrewLinkPing", function(group_id, data)
    local invoking_resource = GetInvokingResource()
    if not invoking_resource or not Config.CrewLink.ExternalPingResources[invoking_resource] then
        return nil, "resource_not_allowed"
    end
    local groups = Bridge.Database.Query(
        "SELECT `id`, `name` FROM `sky_phone_crewlink_groups` WHERE `id` = ? LIMIT 1",
        { group_id }
    )
    if not groups[1] then
        return nil, "group_not_found"
    end
    local ping, error_code = create_ping(group_id, nil, invoking_resource, data)
    if ping then
        notify_group(group_id, "ping", invoking_resource, {
            groupName = groups[1].name,
            pingType = ping.type,
            pingLabel = ping.label,
        })
        refresh_group(group_id)
    end
    return ping, error_code
end)

exports("RemoveCrewLinkPing", function(group_id, ping_id)
    local invoking_resource = GetInvokingResource()
    if not invoking_resource or not Config.CrewLink.ExternalPingResources[invoking_resource] then
        return false
    end
    local result = Bridge.Database.Query([[
        DELETE FROM `sky_phone_crewlink_pings`
        WHERE `id` = ? AND `group_id` = ? AND `source_resource` = ?
    ]], { ping_id, group_id, invoking_resource })
    if affected_rows(result) ~= 1 then
        return false
    end
    TriggerEvent("sky_phone:crewlink:pingRemoved", group_id, ping_id)
    refresh_group(group_id)
    return true
end)
end)
