-- name: ArchiveChatByID :many
WITH updated_chats AS (
    UPDATE chats
    SET archived = true, pin_order = 0, updated_at = NOW()
    WHERE id = @id::uuid OR root_chat_id = @id::uuid
    RETURNING *
),
chats_expanded AS (
    SELECT
        updated_chats.id,
        updated_chats.owner_id,
        updated_chats.workspace_id,
        updated_chats.title,
        updated_chats.status,
        updated_chats.worker_id,
        updated_chats.started_at,
        updated_chats.heartbeat_at,
        updated_chats.created_at,
        updated_chats.updated_at,
        updated_chats.parent_chat_id,
        updated_chats.root_chat_id,
        updated_chats.last_model_config_id,
        updated_chats.archived,
        updated_chats.last_error,
        updated_chats.mode,
        updated_chats.mcp_server_ids,
        updated_chats.labels,
        updated_chats.build_id,
        updated_chats.agent_id,
        updated_chats.pin_order,
        updated_chats.last_read_message_id,
        updated_chats.last_injected_context,
        updated_chats.dynamic_tools,
        updated_chats.organization_id,
        updated_chats.plan_mode,
        updated_chats.client_type,
        updated_chats.last_turn_summary,
        COALESCE(root.user_acl, updated_chats.user_acl) AS user_acl,
        COALESCE(root.group_acl, updated_chats.group_acl) AS group_acl,
        owner.username AS owner_username,
        owner.name AS owner_name
    FROM
        updated_chats
    LEFT JOIN chats root ON root.id = COALESCE(updated_chats.root_chat_id, updated_chats.parent_chat_id)
    JOIN visible_users owner ON owner.id = updated_chats.owner_id
)
SELECT *
FROM chats_expanded
ORDER BY (chats_expanded.id = @id::uuid) DESC, chats_expanded.created_at ASC, chats_expanded.id ASC;

-- name: UnarchiveChatByID :many
-- Unarchives a chat (and its children). Stale file references are
-- handled automatically by FK cascades on chat_file_links: when
-- dbpurge deletes a chat_files row, the corresponding
-- chat_file_links rows are cascade-deleted by PostgreSQL.
WITH updated_chats AS (
    UPDATE chats SET
        archived = false,
        updated_at = NOW()
    WHERE id = @id::uuid OR root_chat_id = @id::uuid
    RETURNING *
),
chats_expanded AS (
    SELECT
        updated_chats.id,
        updated_chats.owner_id,
        updated_chats.workspace_id,
        updated_chats.title,
        updated_chats.status,
        updated_chats.worker_id,
        updated_chats.started_at,
        updated_chats.heartbeat_at,
        updated_chats.created_at,
        updated_chats.updated_at,
        updated_chats.parent_chat_id,
        updated_chats.root_chat_id,
        updated_chats.last_model_config_id,
        updated_chats.archived,
        updated_chats.last_error,
        updated_chats.mode,
        updated_chats.mcp_server_ids,
        updated_chats.labels,
        updated_chats.build_id,
        updated_chats.agent_id,
        updated_chats.pin_order,
        updated_chats.last_read_message_id,
        updated_chats.last_injected_context,
        updated_chats.dynamic_tools,
        updated_chats.organization_id,
        updated_chats.plan_mode,
        updated_chats.client_type,
        updated_chats.last_turn_summary,
        COALESCE(root.user_acl, updated_chats.user_acl) AS user_acl,
        COALESCE(root.group_acl, updated_chats.group_acl) AS group_acl,
        owner.username AS owner_username,
        owner.name AS owner_name
    FROM
        updated_chats
    LEFT JOIN chats root ON root.id = COALESCE(updated_chats.root_chat_id, updated_chats.parent_chat_id)
    JOIN visible_users owner ON owner.id = updated_chats.owner_id
)
SELECT *
FROM chats_expanded
ORDER BY (chats_expanded.id = @id::uuid) DESC, chats_expanded.created_at ASC, chats_expanded.id ASC;

-- name: PinChatByID :exec
WITH target_chat AS (
    SELECT
        id,
        owner_id
    FROM
        chats
    WHERE
        id = @id::uuid
),
-- Under READ COMMITTED, concurrent pin operations for the same
-- owner may momentarily produce duplicate pin_order values because
-- each CTE snapshot does not see the other's writes. The next
-- pin/unpin/reorder operation's ROW_NUMBER() self-heals the
-- sequence, so this is acceptable.
ranked AS (
    SELECT
        c.id,
        ROW_NUMBER() OVER (ORDER BY c.pin_order ASC, c.id ASC) :: integer AS next_pin_order
    FROM
        chats c
    JOIN
        target_chat ON c.owner_id = target_chat.owner_id
    WHERE
        c.pin_order > 0
        AND c.archived = FALSE
        AND c.id <> target_chat.id
),
updates AS (
    SELECT
        ranked.id,
        ranked.next_pin_order AS pin_order
    FROM
        ranked
    UNION ALL
    SELECT
        target_chat.id,
        COALESCE((
            SELECT
                MAX(ranked.next_pin_order)
            FROM
                ranked
        ), 0) + 1 AS pin_order
    FROM
        target_chat
)
UPDATE
    chats c
SET
    pin_order = updates.pin_order
FROM
    updates
WHERE
    c.id = updates.id;

-- name: UnpinChatByID :exec
WITH target_chat AS (
    SELECT
        id,
        owner_id
    FROM
        chats
    WHERE
        id = @id::uuid
),
ranked AS (
    SELECT
        c.id,
        ROW_NUMBER() OVER (ORDER BY c.pin_order ASC, c.id ASC) :: integer AS current_position
    FROM
        chats c
    JOIN
        target_chat ON c.owner_id = target_chat.owner_id
    WHERE
        c.pin_order > 0
        AND c.archived = FALSE
),
target AS (
    SELECT
        ranked.id,
        ranked.current_position
    FROM
        ranked
    WHERE
        ranked.id = @id::uuid
),
updates AS (
    SELECT
        ranked.id,
        CASE
            WHEN ranked.id = target.id THEN 0
            WHEN ranked.current_position > target.current_position THEN ranked.current_position - 1
            ELSE ranked.current_position
        END AS pin_order
    FROM
        ranked
    CROSS JOIN
        target
)
UPDATE
    chats c
SET
    pin_order = updates.pin_order
FROM
    updates
WHERE
    c.id = updates.id;

-- name: UpdateChatPinOrder :exec
WITH target_chat AS (
    SELECT
        id,
        owner_id
    FROM
        chats
    WHERE
        id = @id::uuid
),
ranked AS (
    SELECT
        c.id,
        ROW_NUMBER() OVER (ORDER BY c.pin_order ASC, c.id ASC) :: integer AS current_position,
        COUNT(*) OVER () :: integer AS pinned_count
    FROM
        chats c
    JOIN
        target_chat ON c.owner_id = target_chat.owner_id
    WHERE
        c.pin_order > 0
        AND c.archived = FALSE
),
target AS (
    SELECT
        ranked.id,
        ranked.current_position,
        LEAST(GREATEST(@pin_order::integer, 1), ranked.pinned_count) AS desired_position
    FROM
        ranked
    WHERE
        ranked.id = @id::uuid
),
updates AS (
    SELECT
        ranked.id,
        CASE
            WHEN ranked.id = target.id THEN target.desired_position
            WHEN target.desired_position < target.current_position
                AND ranked.current_position >= target.desired_position
                AND ranked.current_position < target.current_position THEN ranked.current_position + 1
            WHEN target.desired_position > target.current_position
                AND ranked.current_position > target.current_position
                AND ranked.current_position <= target.desired_position THEN ranked.current_position - 1
            ELSE ranked.current_position
        END AS pin_order
    FROM
        ranked
    CROSS JOIN
        target
)
UPDATE
    chats c
SET
    pin_order = updates.pin_order
FROM
    updates
WHERE
    c.id = updates.id;

-- name: SoftDeleteChatMessagesAfterID :exec
UPDATE
    chat_messages
SET
    deleted = true
WHERE
    chat_id = @chat_id::uuid
    AND id > @after_id::bigint;

-- name: SoftDeleteChatMessageByID :exec
UPDATE
    chat_messages
SET
    deleted = true
WHERE
    id = @id::bigint;

-- name: GetChatByID :one
SELECT *
FROM chats_expanded
WHERE id = @id::uuid;

-- name: GetChatACLByID :one
SELECT
    user_acl AS users,
    group_acl AS groups
FROM
    chats
WHERE
    id = @id::uuid;

-- name: UpdateChatACLByID :exec
UPDATE
    chats
SET
    user_acl = @user_acl,
    group_acl = @group_acl
WHERE
    id = @id::uuid;

-- name: GetChatMessageByID :one
SELECT
    *
FROM
    chat_messages
WHERE
    id = @id::bigint
    AND deleted = false;

-- name: GetChatMessagesByChatID :many
SELECT
    *
FROM
    chat_messages
WHERE
    chat_id = @chat_id::uuid
    AND id > @after_id::bigint
    AND visibility IN ('user', 'both')
    AND deleted = false
ORDER BY
    created_at ASC;

-- name: GetChatMessagesByChatIDAscPaginated :many
SELECT
    *
FROM
    chat_messages
WHERE
    chat_id = @chat_id::uuid
    AND id > @after_id::bigint
    AND visibility IN ('user', 'both')
    AND deleted = false
ORDER BY
    id ASC
LIMIT
    COALESCE(NULLIF(@limit_val::int, 0), 50);

-- name: GetChatMessagesByChatIDDescPaginated :many
SELECT
    *
FROM
    chat_messages
WHERE
    chat_id = @chat_id::uuid
    AND CASE
        WHEN @before_id::bigint > 0 THEN id < @before_id::bigint
        ELSE true
    END
    AND CASE
        WHEN @after_id::bigint > 0 THEN id > @after_id::bigint
        ELSE true
    END
    AND visibility IN ('user', 'both')
    AND deleted = false
ORDER BY
    id DESC
LIMIT
    COALESCE(NULLIF(@limit_val::int, 0), 50);

-- name: GetChatUserPromptsByChatID :many
-- Returns the concatenated text of each user-visible user prompt in a
-- chat, newest first. Used by the composer to populate the up/down
-- arrow prompt-history cycle. Non-text parts (tool calls, files,
-- attachments, ...) are excluded; messages whose text payload is
-- entirely whitespace are dropped so cycling never lands on a blank
-- entry. The jsonb_typeof guard skips legacy V0 rows whose content is
-- a scalar JSON string (predates migration 000434) so the lateral
-- jsonb_array_elements never raises "cannot extract elements from a
-- scalar". Backed by idx_chat_messages_user_prompts.
SELECT
    cm.id,
    string_agg(part->>'text', '' ORDER BY ordinality)::text AS text
FROM
    chat_messages cm,
    jsonb_array_elements(cm.content) WITH ORDINALITY AS t(part, ordinality)
WHERE
    cm.chat_id = @chat_id::uuid
    AND cm.role = 'user'
    AND cm.deleted = false
    AND cm.visibility IN ('user', 'both')
    AND jsonb_typeof(cm.content) = 'array'
    AND part->>'type' = 'text'
GROUP BY
    cm.id
HAVING
    string_agg(part->>'text', '') ~ '\S'
ORDER BY
    cm.id DESC
LIMIT
    COALESCE(NULLIF(@limit_val::int, 0), 500);

-- name: GetChatMessagesForPromptByChatID :many
WITH latest_compressed_summary AS (
    SELECT
        id
    FROM
        chat_messages
    WHERE
        chat_id = @chat_id::uuid
        AND compressed = TRUE
        AND deleted = false
        AND visibility = 'model'
    ORDER BY
        created_at DESC,
        id DESC
    LIMIT
        1
)
SELECT
    *
FROM
    chat_messages
WHERE
    chat_id = @chat_id::uuid
    AND visibility IN ('model', 'both')
    AND deleted = false
    AND (
        (
            role = 'system'
            AND compressed = FALSE
        )
        OR (
            compressed = FALSE
            AND (
                NOT EXISTS (
                    SELECT
                        1
                    FROM
                        latest_compressed_summary
                )
                OR id > (
                    SELECT
                        id
                    FROM
                        latest_compressed_summary
                )
            )
        )
        OR id = (
            SELECT
                id
            FROM
                latest_compressed_summary
        )
    )
ORDER BY
    created_at ASC,
    id ASC;

-- name: GetChats :many
WITH cursor_chat AS (
    SELECT
        pin_order,
        updated_at,
        id
    FROM chats
    WHERE id = @after_id
)
SELECT
    sqlc.embed(chats_expanded),
    EXISTS (
        SELECT 1 FROM chat_messages cm
        WHERE cm.chat_id = chats_expanded.id
            AND cm.role = 'assistant'
            AND cm.deleted = false
            AND cm.id > COALESCE(chats_expanded.last_read_message_id, 0)
    ) AS has_unread
FROM
    chats_expanded
WHERE
    CASE
        WHEN @owned_only::boolean THEN chats_expanded.owner_id = @viewer_id::uuid
        ELSE true
    END
    AND CASE
        WHEN @shared_only::boolean THEN chats_expanded.owner_id != @viewer_id::uuid
        ELSE true
    END
    AND CASE
        WHEN sqlc.narg('archived') :: boolean IS NULL THEN true
        ELSE chats_expanded.archived = sqlc.narg('archived') :: boolean
    END
    AND CASE
        -- Cursor pagination: the last element on a page acts as the cursor.
        -- The 4-tuple matches the ORDER BY below. All columns sort DESC
        -- (pin_order is negated so lower values sort first in DESC order),
        -- which lets us use a single tuple < comparison.
        WHEN @after_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN (
            (CASE WHEN chats_expanded.pin_order > 0 THEN 1 ELSE 0 END, -chats_expanded.pin_order, chats_expanded.updated_at, chats_expanded.id) < (
                SELECT
                    CASE WHEN cursor_chat.pin_order > 0 THEN 1 ELSE 0 END,
                    -cursor_chat.pin_order,
                    cursor_chat.updated_at,
                    cursor_chat.id
                FROM
                    cursor_chat
            )
        )
        ELSE true
    END
    AND CASE
        WHEN sqlc.narg('label_filter')::jsonb IS NOT NULL THEN chats_expanded.labels @> sqlc.narg('label_filter')::jsonb
        ELSE true
    END
    -- Match chats whose linked diff URL (e.g. a pull request URL)
    -- equals the given value, case-insensitively. The URL may live on
    -- a delegated sub-agent's diff status, so we surface the root chat
    -- when any descendant matches.
    AND CASE
        WHEN sqlc.narg('diff_url')::text IS NOT NULL THEN EXISTS (
            SELECT 1
            FROM chat_diff_statuses cds
            JOIN chats c2 ON c2.id = cds.chat_id
            WHERE cds.url IS NOT NULL
              AND cds.url <> ''
              AND LOWER(cds.url) = LOWER(sqlc.narg('diff_url')::text)
              AND (c2.id = chats_expanded.id OR c2.root_chat_id = chats_expanded.id)
        )
        ELSE true
    END
    -- Filter by title substring (case-insensitive). Applied when the
    -- caller provides a non-empty title_query.
    AND CASE
        WHEN @title_query :: text != '' THEN chats_expanded.title ILIKE '%' || @title_query || '%'
        ELSE true
    END
    AND CASE
        WHEN sqlc.narg('has_unread')::boolean IS NOT NULL THEN (
            EXISTS (
                SELECT 1 FROM chat_messages cm
                WHERE cm.chat_id = chats_expanded.id
                    AND cm.role = 'assistant'
                    AND cm.deleted = false
                    AND cm.id > COALESCE(chats_expanded.last_read_message_id, 0)
            )
        ) = sqlc.narg('has_unread')::boolean
        ELSE true
    END
    -- Filter by pull request status. Unlike the diff_url filter above,
    -- this intentionally checks only the root chat's own diff status.
    -- Child chats share the same workspace and git branch as their
    -- parent, so gitsync populates identical PR state on both; traversing
    -- descendants would be redundant.
    AND CASE
        WHEN COALESCE(array_length(@pull_request_statuses::text[], 1), 0) > 0 THEN EXISTS (
            SELECT 1
            FROM chat_diff_statuses cds
            WHERE cds.chat_id = chats_expanded.id
                AND (
                    CASE
                        WHEN cds.pull_request_state = 'open' AND cds.pull_request_draft THEN 'draft'
                        WHEN cds.pull_request_state = 'open' THEN 'open'
                        ELSE cds.pull_request_state
                    END
                ) = ANY(@pull_request_statuses::text[])
        )
        ELSE true
    END
    -- Filter by PR number (exact match on chat's diff status).
    AND CASE
        WHEN @pr_number::int != 0 THEN EXISTS (
            SELECT 1
            FROM chat_diff_statuses cds
            WHERE cds.chat_id = chats_expanded.id
                AND cds.pr_number = @pr_number
        )
        ELSE true
    END
    -- Filter by repository (substring match on remote origin or PR URL).
    AND CASE
        WHEN @repo_query::text != '' THEN EXISTS (
            SELECT 1
            FROM chat_diff_statuses cds
            WHERE cds.chat_id = chats_expanded.id
                AND (
                    cds.git_remote_origin ILIKE '%' || @repo_query || '%'
                    OR cds.url ILIKE '%' || @repo_query || '%'
                )
        )
        ELSE true
    END
    -- Filter by pull request title (case-insensitive substring).
    AND CASE
        WHEN @pr_title_query::text != '' THEN EXISTS (
            SELECT 1
            FROM chat_diff_statuses cds
            WHERE cds.chat_id = chats_expanded.id
                AND cds.pull_request_title ILIKE '%' || @pr_title_query || '%'
        )
        ELSE true
    END
    -- Paginate over root chats only. Children are fetched
    -- separately via GetChildChatsByParentIDs and embedded under
    -- each parent. Other callers that need the full set should
    -- use a narrower query (e.g. GetChatsByWorkspaceIDs).
    AND chats_expanded.parent_chat_id IS NULL
    -- Authorize Filter clause will be injected below in GetAuthorizedChats
    -- @authorize_filter
ORDER BY
    -- Pinned chats (pin_order > 0) sort before unpinned ones. Within
    -- pinned chats, lower pin_order values come first. The negation
    -- trick (-pin_order) keeps all sort columns DESC so the cursor
    -- tuple < comparison works with uniform direction.
    CASE WHEN chats_expanded.pin_order > 0 THEN 1 ELSE 0 END DESC,
    -chats_expanded.pin_order DESC,
    chats_expanded.updated_at DESC,
    chats_expanded.id DESC
OFFSET @offset_opt
LIMIT
    -- The chat list is unbounded and expected to grow large.
    -- Default to 50 to prevent accidental excessively large queries.
    COALESCE(NULLIF(@limit_opt :: int, 0), 50);

-- name: GetChildChatsByParentIDs :many
-- Fetches child chats of the given parents, optionally filtered by
-- archive state (NULL = all, true/false = match). The archive
-- invariant (parent archived implies child archived) is enforced
-- at write time, not here.
SELECT
    sqlc.embed(chats_expanded),
    EXISTS (
        SELECT 1 FROM chat_messages cm
        WHERE cm.chat_id = chats_expanded.id
            AND cm.role = 'assistant'
            AND cm.deleted = false
            AND cm.id > COALESCE(chats_expanded.last_read_message_id, 0)
    ) AS has_unread
FROM
    chats_expanded
WHERE
    chats_expanded.parent_chat_id = ANY(@parent_ids :: uuid[])
    AND CASE
        WHEN sqlc.narg('archived') :: boolean IS NULL THEN true
        ELSE chats_expanded.archived = sqlc.narg('archived') :: boolean
    END
ORDER BY
    chats_expanded.created_at DESC,
    chats_expanded.id DESC;

-- name: InsertChat :one
WITH inserted_chat AS (
INSERT INTO chats (
    organization_id,
    owner_id,
    workspace_id,
    build_id,
    agent_id,
    parent_chat_id,
    root_chat_id,
    last_model_config_id,
    title,
    mode,
    plan_mode,
    status,
    mcp_server_ids,
    labels,
    dynamic_tools,
    client_type
) VALUES (
    @organization_id::uuid,
    @owner_id::uuid,
    sqlc.narg('workspace_id')::uuid,
    sqlc.narg('build_id')::uuid,
    sqlc.narg('agent_id')::uuid,
    sqlc.narg('parent_chat_id')::uuid,
    sqlc.narg('root_chat_id')::uuid,
    @last_model_config_id::uuid,
    @title::text,
    sqlc.narg('mode')::chat_mode,
    sqlc.narg('plan_mode')::chat_plan_mode,
    @status::chat_status,
    COALESCE(@mcp_server_ids::uuid[], '{}'::uuid[]),
    COALESCE(sqlc.narg('labels')::jsonb, '{}'::jsonb),
    sqlc.narg('dynamic_tools')::jsonb,
    @client_type::chat_client_type
)
RETURNING *
),
chats_expanded AS (
    SELECT
        inserted_chat.id,
        inserted_chat.owner_id,
        inserted_chat.workspace_id,
        inserted_chat.title,
        inserted_chat.status,
        inserted_chat.worker_id,
        inserted_chat.started_at,
        inserted_chat.heartbeat_at,
        inserted_chat.created_at,
        inserted_chat.updated_at,
        inserted_chat.parent_chat_id,
        inserted_chat.root_chat_id,
        inserted_chat.last_model_config_id,
        inserted_chat.archived,
        inserted_chat.last_error,
        inserted_chat.mode,
        inserted_chat.mcp_server_ids,
        inserted_chat.labels,
        inserted_chat.build_id,
        inserted_chat.agent_id,
        inserted_chat.pin_order,
        inserted_chat.last_read_message_id,
        inserted_chat.last_injected_context,
        inserted_chat.dynamic_tools,
        inserted_chat.organization_id,
        inserted_chat.plan_mode,
        inserted_chat.client_type,
        inserted_chat.last_turn_summary,
        COALESCE(root.user_acl, inserted_chat.user_acl) AS user_acl,
        COALESCE(root.group_acl, inserted_chat.group_acl) AS group_acl,
        owner.username AS owner_username,
        owner.name AS owner_name
    FROM
        inserted_chat
    LEFT JOIN chats root ON root.id = COALESCE(inserted_chat.root_chat_id, inserted_chat.parent_chat_id)
    JOIN visible_users owner ON owner.id = inserted_chat.owner_id
)
SELECT *
FROM chats_expanded;

-- name: InsertChatMessages :many
WITH updated_chat AS (
    UPDATE
        chats
    SET
        last_model_config_id = (
            SELECT val
            FROM UNNEST(@model_config_id::uuid[])
                WITH ORDINALITY AS t(val, ord)
            WHERE val != '00000000-0000-0000-0000-000000000000'::uuid
            ORDER BY ord DESC
            LIMIT 1
        )
    WHERE
        id = @chat_id::uuid
        AND EXISTS (
            SELECT 1
            FROM UNNEST(@model_config_id::uuid[])
            WHERE unnest != '00000000-0000-0000-0000-000000000000'::uuid
        )
        AND chats.last_model_config_id IS DISTINCT FROM (
            SELECT val
            FROM UNNEST(@model_config_id::uuid[])
                WITH ORDINALITY AS t(val, ord)
            WHERE val != '00000000-0000-0000-0000-000000000000'::uuid
            ORDER BY ord DESC
            LIMIT 1
        )
)
INSERT INTO chat_messages (
    chat_id,
    created_by,
    api_key_id,
    model_config_id,
    role,
    content,
    content_version,
    visibility,
    input_tokens,
    output_tokens,
    total_tokens,
    reasoning_tokens,
    cache_creation_tokens,
    cache_read_tokens,
    context_limit,
    compressed,
    total_cost_micros,
    runtime_ms,
    provider_response_id
)
SELECT
    @chat_id::uuid,
    NULLIF(UNNEST(@created_by::uuid[]), '00000000-0000-0000-0000-000000000000'::uuid),
    NULLIF(UNNEST(@api_key_id::text[]), ''),
    NULLIF(UNNEST(@model_config_id::uuid[]), '00000000-0000-0000-0000-000000000000'::uuid),
    UNNEST(@role::chat_message_role[]),
    UNNEST(@content::text[])::jsonb,
    UNNEST(@content_version::smallint[]),
    UNNEST(@visibility::chat_message_visibility[]),
    NULLIF(UNNEST(@input_tokens::bigint[]), 0),
    NULLIF(UNNEST(@output_tokens::bigint[]), 0),
    NULLIF(UNNEST(@total_tokens::bigint[]), 0),
    NULLIF(UNNEST(@reasoning_tokens::bigint[]), 0),
    NULLIF(UNNEST(@cache_creation_tokens::bigint[]), 0),
    NULLIF(UNNEST(@cache_read_tokens::bigint[]), 0),
    NULLIF(UNNEST(@context_limit::bigint[]), 0),
    UNNEST(@compressed::boolean[]),
    NULLIF(UNNEST(@total_cost_micros::bigint[]), 0),
    NULLIF(UNNEST(@runtime_ms::bigint[]), 0),
    NULLIF(UNNEST(@provider_response_id::text[]), '')
RETURNING
    *;

-- name: UpdateChatMessageByID :one
UPDATE
    chat_messages
SET
    model_config_id = COALESCE(sqlc.narg('model_config_id')::uuid, model_config_id),
    content = sqlc.narg('content')::jsonb
WHERE
    id = @id::bigint
RETURNING
    *;

-- name: UpdateChatByID :one
WITH updated_chat AS (
UPDATE
    chats
SET
    title = @title::text,
    updated_at = NOW()
WHERE
    id = @id::uuid
RETURNING *
),
chats_expanded AS (
    SELECT
        updated_chat.id,
        updated_chat.owner_id,
        updated_chat.workspace_id,
        updated_chat.title,
        updated_chat.status,
        updated_chat.worker_id,
        updated_chat.started_at,
        updated_chat.heartbeat_at,
        updated_chat.created_at,
        updated_chat.updated_at,
        updated_chat.parent_chat_id,
        updated_chat.root_chat_id,
        updated_chat.last_model_config_id,
        updated_chat.archived,
        updated_chat.last_error,
        updated_chat.mode,
        updated_chat.mcp_server_ids,
        updated_chat.labels,
        updated_chat.build_id,
        updated_chat.agent_id,
        updated_chat.pin_order,
        updated_chat.last_read_message_id,
        updated_chat.last_injected_context,
        updated_chat.dynamic_tools,
        updated_chat.organization_id,
        updated_chat.plan_mode,
        updated_chat.client_type,
        updated_chat.last_turn_summary,
        COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
        COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
        owner.username AS owner_username,
        owner.name AS owner_name
    FROM
        updated_chat
    LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
    JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT *
FROM chats_expanded;

-- name: UpdateChatTitleByID :one
WITH updated_chat AS (
UPDATE
    chats
SET
    -- NOTE: updated_at is intentionally NOT touched here to avoid
    -- changing list ordering when a user renames an older chat
    -- out-of-band.
    title = @title::text
WHERE
    id = @id::uuid
RETURNING *
),
chats_expanded AS (
    SELECT
        updated_chat.id,
        updated_chat.owner_id,
        updated_chat.workspace_id,
        updated_chat.title,
        updated_chat.status,
        updated_chat.worker_id,
        updated_chat.started_at,
        updated_chat.heartbeat_at,
        updated_chat.created_at,
        updated_chat.updated_at,
        updated_chat.parent_chat_id,
        updated_chat.root_chat_id,
        updated_chat.last_model_config_id,
        updated_chat.archived,
        updated_chat.last_error,
        updated_chat.mode,
        updated_chat.mcp_server_ids,
        updated_chat.labels,
        updated_chat.build_id,
        updated_chat.agent_id,
        updated_chat.pin_order,
        updated_chat.last_read_message_id,
        updated_chat.last_injected_context,
        updated_chat.dynamic_tools,
        updated_chat.organization_id,
        updated_chat.plan_mode,
        updated_chat.client_type,
        updated_chat.last_turn_summary,
        COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
        COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
        owner.username AS owner_username,
        owner.name AS owner_name
    FROM
        updated_chat
    LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
    JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT *
FROM chats_expanded;

-- name: UpdateChatPlanModeByID :one
WITH updated_chat AS (
UPDATE
    chats
SET
    -- NOTE: updated_at is intentionally NOT touched here to avoid changing list ordering.
    plan_mode = sqlc.narg('plan_mode')::chat_plan_mode
WHERE
    id = @id::uuid
RETURNING *
),
chats_expanded AS (
    SELECT
        updated_chat.id,
        updated_chat.owner_id,
        updated_chat.workspace_id,
        updated_chat.title,
        updated_chat.status,
        updated_chat.worker_id,
        updated_chat.started_at,
        updated_chat.heartbeat_at,
        updated_chat.created_at,
        updated_chat.updated_at,
        updated_chat.parent_chat_id,
        updated_chat.root_chat_id,
        updated_chat.last_model_config_id,
        updated_chat.archived,
        updated_chat.last_error,
        updated_chat.mode,
        updated_chat.mcp_server_ids,
        updated_chat.labels,
        updated_chat.build_id,
        updated_chat.agent_id,
        updated_chat.pin_order,
        updated_chat.last_read_message_id,
        updated_chat.last_injected_context,
        updated_chat.dynamic_tools,
        updated_chat.organization_id,
        updated_chat.plan_mode,
        updated_chat.client_type,
        updated_chat.last_turn_summary,
        COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
        COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
        owner.username AS owner_username,
        owner.name AS owner_name
    FROM
        updated_chat
    LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
    JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT *
FROM chats_expanded;

-- name: UpdateChatLastModelConfigByID :one
WITH updated_chat AS (
UPDATE
    chats
SET
    -- NOTE: updated_at is intentionally NOT touched here to avoid changing list ordering.
    last_model_config_id = @last_model_config_id::uuid
WHERE
    id = @id::uuid
RETURNING *
),
chats_expanded AS (
    SELECT
        updated_chat.id,
        updated_chat.owner_id,
        updated_chat.workspace_id,
        updated_chat.title,
        updated_chat.status,
        updated_chat.worker_id,
        updated_chat.started_at,
        updated_chat.heartbeat_at,
        updated_chat.created_at,
        updated_chat.updated_at,
        updated_chat.parent_chat_id,
        updated_chat.root_chat_id,
        updated_chat.last_model_config_id,
        updated_chat.archived,
        updated_chat.last_error,
        updated_chat.mode,
        updated_chat.mcp_server_ids,
        updated_chat.labels,
        updated_chat.build_id,
        updated_chat.agent_id,
        updated_chat.pin_order,
        updated_chat.last_read_message_id,
        updated_chat.last_injected_context,
        updated_chat.dynamic_tools,
        updated_chat.organization_id,
        updated_chat.plan_mode,
        updated_chat.client_type,
        updated_chat.last_turn_summary,
        COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
        COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
        owner.username AS owner_username,
        owner.name AS owner_name
    FROM
        updated_chat
    LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
    JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT *
FROM chats_expanded;

-- name: UpdateChatLabelsByID :one
WITH updated_chat AS (
UPDATE
    chats
SET
    labels = @labels::jsonb,
    updated_at = NOW()
WHERE
    id = @id::uuid
RETURNING *
),
chats_expanded AS (
    SELECT
        updated_chat.id,
        updated_chat.owner_id,
        updated_chat.workspace_id,
        updated_chat.title,
        updated_chat.status,
        updated_chat.worker_id,
        updated_chat.started_at,
        updated_chat.heartbeat_at,
        updated_chat.created_at,
        updated_chat.updated_at,
        updated_chat.parent_chat_id,
        updated_chat.root_chat_id,
        updated_chat.last_model_config_id,
        updated_chat.archived,
        updated_chat.last_error,
        updated_chat.mode,
        updated_chat.mcp_server_ids,
        updated_chat.labels,
        updated_chat.build_id,
        updated_chat.agent_id,
        updated_chat.pin_order,
        updated_chat.last_read_message_id,
        updated_chat.last_injected_context,
        updated_chat.dynamic_tools,
        updated_chat.organization_id,
        updated_chat.plan_mode,
        updated_chat.client_type,
        updated_chat.last_turn_summary,
        COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
        COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
        owner.username AS owner_username,
        owner.name AS owner_name
    FROM
        updated_chat
    LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
    JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT *
FROM chats_expanded;

-- name: UpdateChatWorkspaceBinding :one
WITH updated_chat AS (
UPDATE chats SET
    workspace_id = sqlc.narg('workspace_id')::uuid,
    build_id = sqlc.narg('build_id')::uuid,
    agent_id = sqlc.narg('agent_id')::uuid,
    updated_at = NOW()
WHERE id = @id::uuid
RETURNING *
),
chats_expanded AS (
    SELECT
        updated_chat.id,
        updated_chat.owner_id,
        updated_chat.workspace_id,
        updated_chat.title,
        updated_chat.status,
        updated_chat.worker_id,
        updated_chat.started_at,
        updated_chat.heartbeat_at,
        updated_chat.created_at,
        updated_chat.updated_at,
        updated_chat.parent_chat_id,
        updated_chat.root_chat_id,
        updated_chat.last_model_config_id,
        updated_chat.archived,
        updated_chat.last_error,
        updated_chat.mode,
        updated_chat.mcp_server_ids,
        updated_chat.labels,
        updated_chat.build_id,
        updated_chat.agent_id,
        updated_chat.pin_order,
        updated_chat.last_read_message_id,
        updated_chat.last_injected_context,
        updated_chat.dynamic_tools,
        updated_chat.organization_id,
        updated_chat.plan_mode,
        updated_chat.client_type,
        updated_chat.last_turn_summary,
        COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
        COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
        owner.username AS owner_username,
        owner.name AS owner_name
    FROM
        updated_chat
    LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
    JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT *
FROM chats_expanded;

-- name: UpdateChatBuildAgentBinding :one
WITH updated_chat AS (
UPDATE chats SET
    build_id = sqlc.narg('build_id')::uuid,
    agent_id = sqlc.narg('agent_id')::uuid,
    updated_at = NOW()
WHERE
    id = @id::uuid
RETURNING *
),
chats_expanded AS (
    SELECT
        updated_chat.id,
        updated_chat.owner_id,
        updated_chat.workspace_id,
        updated_chat.title,
        updated_chat.status,
        updated_chat.worker_id,
        updated_chat.started_at,
        updated_chat.heartbeat_at,
        updated_chat.created_at,
        updated_chat.updated_at,
        updated_chat.parent_chat_id,
        updated_chat.root_chat_id,
        updated_chat.last_model_config_id,
        updated_chat.archived,
        updated_chat.last_error,
        updated_chat.mode,
        updated_chat.mcp_server_ids,
        updated_chat.labels,
        updated_chat.build_id,
        updated_chat.agent_id,
        updated_chat.pin_order,
        updated_chat.last_read_message_id,
        updated_chat.last_injected_context,
        updated_chat.dynamic_tools,
        updated_chat.organization_id,
        updated_chat.plan_mode,
        updated_chat.client_type,
        updated_chat.last_turn_summary,
        COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
        COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
        owner.username AS owner_username,
        owner.name AS owner_name
    FROM
        updated_chat
    LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
    JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT *
FROM chats_expanded;

-- name: UpdateChatLastInjectedContext :one
WITH updated_chat AS (
-- Updates the cached injected context parts (AGENTS.md +
-- skills) on the chat row. Called only when context changes
-- (first workspace attach or agent change). updated_at is
-- intentionally not touched to avoid reordering the chat list.
UPDATE chats SET
    last_injected_context = sqlc.narg('last_injected_context')::jsonb
WHERE
    id = @id::uuid
RETURNING *
),
chats_expanded AS (
    SELECT
        updated_chat.id,
        updated_chat.owner_id,
        updated_chat.workspace_id,
        updated_chat.title,
        updated_chat.status,
        updated_chat.worker_id,
        updated_chat.started_at,
        updated_chat.heartbeat_at,
        updated_chat.created_at,
        updated_chat.updated_at,
        updated_chat.parent_chat_id,
        updated_chat.root_chat_id,
        updated_chat.last_model_config_id,
        updated_chat.archived,
        updated_chat.last_error,
        updated_chat.mode,
        updated_chat.mcp_server_ids,
        updated_chat.labels,
        updated_chat.build_id,
        updated_chat.agent_id,
        updated_chat.pin_order,
        updated_chat.last_read_message_id,
        updated_chat.last_injected_context,
        updated_chat.dynamic_tools,
        updated_chat.organization_id,
        updated_chat.plan_mode,
        updated_chat.client_type,
        updated_chat.last_turn_summary,
        COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
        COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
        owner.username AS owner_username,
        owner.name AS owner_name
    FROM
        updated_chat
    LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
    JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT *
FROM chats_expanded;

-- name: UpdateChatLastTurnSummary :execrows
-- Updates the cached last completed turn summary for sidebar display.
-- Empty or whitespace-only summaries are stored as NULL here so direct
-- query callers cannot accidentally persist blank sidebar text.
-- This intentionally preserves updated_at. The staleness guard relies on
-- every new-turn query, such as UpdateChatStatus and AcquireChats, bumping
-- updated_at. Future chat-field updates that do not bump updated_at can let
-- stale summaries persist. If this query ever bumps updated_at, later
-- goroutine summary writes will be rejected as stale.
-- Two summary workers using the same freshness marker are last-write-wins.
UPDATE chats
SET
    last_turn_summary = NULLIF(REGEXP_REPLACE(
        sqlc.narg('last_turn_summary')::text, '^[[:space:]]+|[[:space:]]+$', '', 'g'
    ), '')
WHERE
    id = @id::uuid
    AND updated_at = @expected_updated_at::timestamptz;

-- name: UpdateChatMCPServerIDs :one
WITH updated_chat AS (
UPDATE
    chats
SET
    mcp_server_ids = @mcp_server_ids::uuid[],
    updated_at = NOW()
WHERE
    id = @id::uuid
RETURNING *
),
chats_expanded AS (
    SELECT
        updated_chat.id,
        updated_chat.owner_id,
        updated_chat.workspace_id,
        updated_chat.title,
        updated_chat.status,
        updated_chat.worker_id,
        updated_chat.started_at,
        updated_chat.heartbeat_at,
        updated_chat.created_at,
        updated_chat.updated_at,
        updated_chat.parent_chat_id,
        updated_chat.root_chat_id,
        updated_chat.last_model_config_id,
        updated_chat.archived,
        updated_chat.last_error,
        updated_chat.mode,
        updated_chat.mcp_server_ids,
        updated_chat.labels,
        updated_chat.build_id,
        updated_chat.agent_id,
        updated_chat.pin_order,
        updated_chat.last_read_message_id,
        updated_chat.last_injected_context,
        updated_chat.dynamic_tools,
        updated_chat.organization_id,
        updated_chat.plan_mode,
        updated_chat.client_type,
        updated_chat.last_turn_summary,
        COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
        COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
        owner.username AS owner_username,
        owner.name AS owner_name
    FROM
        updated_chat
    LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
    JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT *
FROM chats_expanded;

-- name: LinkChatFiles :one
-- LinkChatFiles inserts file associations into the chat_file_links
-- join table with deduplication (ON CONFLICT DO NOTHING). The INSERT
-- is conditional: it only proceeds when the total number of links
-- (existing + genuinely new) does not exceed max_file_links. Returns
-- the number of genuinely new file IDs that were NOT inserted due to
-- the cap. A return value of 0 means all files were linked (or were
-- already linked). A positive value means the cap blocked that many
-- new links.
WITH current AS (
    SELECT COUNT(*) AS cnt
    FROM chat_file_links
    WHERE chat_id = @chat_id::uuid
),
new_links AS (
    SELECT @chat_id::uuid AS chat_id, unnest(@file_ids::uuid[]) AS file_id
),
genuinely_new AS (
    SELECT nl.chat_id, nl.file_id
    FROM new_links nl
    WHERE NOT EXISTS (
        SELECT 1 FROM chat_file_links cfl
        WHERE cfl.chat_id = nl.chat_id AND cfl.file_id = nl.file_id
    )
),
inserted AS (
    INSERT INTO chat_file_links (chat_id, file_id)
    SELECT gn.chat_id, gn.file_id
    FROM genuinely_new gn, current c
    WHERE c.cnt + (SELECT COUNT(*) FROM genuinely_new) <= @max_file_links::int
    ON CONFLICT (chat_id, file_id) DO NOTHING
    RETURNING file_id
)
SELECT
    (SELECT COUNT(*)::int FROM genuinely_new) -
    (SELECT COUNT(*)::int FROM inserted) AS rejected_new_files;

-- name: AcquireChats :many
-- Acquires up to @num_chats pending chats for processing. Uses SKIP LOCKED
-- to prevent multiple replicas from acquiring the same chat.
WITH acquired_chats AS (
UPDATE
    chats
SET
    status = 'running'::chat_status,
    started_at = @started_at::timestamptz,
    heartbeat_at = @started_at::timestamptz,
    updated_at = @started_at::timestamptz,
    worker_id = @worker_id::uuid
WHERE
    id = ANY(
        SELECT
            id
        FROM
            chats
        WHERE
            status = 'pending'::chat_status
            AND archived = false
        ORDER BY
            updated_at ASC
        FOR UPDATE
            SKIP LOCKED
        LIMIT
            @num_chats::int
    )
RETURNING *
),
chats_expanded AS (
    SELECT
        acquired_chats.id,
        acquired_chats.owner_id,
        acquired_chats.workspace_id,
        acquired_chats.title,
        acquired_chats.status,
        acquired_chats.worker_id,
        acquired_chats.started_at,
        acquired_chats.heartbeat_at,
        acquired_chats.created_at,
        acquired_chats.updated_at,
        acquired_chats.parent_chat_id,
        acquired_chats.root_chat_id,
        acquired_chats.last_model_config_id,
        acquired_chats.archived,
        acquired_chats.last_error,
        acquired_chats.mode,
        acquired_chats.mcp_server_ids,
        acquired_chats.labels,
        acquired_chats.build_id,
        acquired_chats.agent_id,
        acquired_chats.pin_order,
        acquired_chats.last_read_message_id,
        acquired_chats.last_injected_context,
        acquired_chats.dynamic_tools,
        acquired_chats.organization_id,
        acquired_chats.plan_mode,
        acquired_chats.client_type,
        acquired_chats.last_turn_summary,
        COALESCE(root.user_acl, acquired_chats.user_acl) AS user_acl,
        COALESCE(root.group_acl, acquired_chats.group_acl) AS group_acl,
        owner.username AS owner_username,
        owner.name AS owner_name
    FROM
        acquired_chats
    LEFT JOIN chats root ON root.id = COALESCE(acquired_chats.root_chat_id, acquired_chats.parent_chat_id)
    JOIN visible_users owner ON owner.id = acquired_chats.owner_id
)
SELECT *
FROM chats_expanded;

-- name: UpdateChatStatus :one
WITH updated_chat AS (
UPDATE
    chats
SET
    status = @status::chat_status,
    worker_id = sqlc.narg('worker_id')::uuid,
    started_at = sqlc.narg('started_at')::timestamptz,
    heartbeat_at = sqlc.narg('heartbeat_at')::timestamptz,
    last_error = sqlc.narg('last_error')::jsonb,
    updated_at = NOW()
WHERE
    id = @id::uuid
RETURNING *
),
chats_expanded AS (
    SELECT
        updated_chat.id,
        updated_chat.owner_id,
        updated_chat.workspace_id,
        updated_chat.title,
        updated_chat.status,
        updated_chat.worker_id,
        updated_chat.started_at,
        updated_chat.heartbeat_at,
        updated_chat.created_at,
        updated_chat.updated_at,
        updated_chat.parent_chat_id,
        updated_chat.root_chat_id,
        updated_chat.last_model_config_id,
        updated_chat.archived,
        updated_chat.last_error,
        updated_chat.mode,
        updated_chat.mcp_server_ids,
        updated_chat.labels,
        updated_chat.build_id,
        updated_chat.agent_id,
        updated_chat.pin_order,
        updated_chat.last_read_message_id,
        updated_chat.last_injected_context,
        updated_chat.dynamic_tools,
        updated_chat.organization_id,
        updated_chat.plan_mode,
        updated_chat.client_type,
        updated_chat.last_turn_summary,
        COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
        COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
        owner.username AS owner_username,
        owner.name AS owner_name
    FROM
        updated_chat
    LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
    JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT *
FROM chats_expanded;

-- name: UpdateChatStatusPreserveUpdatedAt :one
WITH updated_chat AS (
UPDATE
    chats
SET
    status = @status::chat_status,
    worker_id = sqlc.narg('worker_id')::uuid,
    started_at = sqlc.narg('started_at')::timestamptz,
    heartbeat_at = sqlc.narg('heartbeat_at')::timestamptz,
    last_error = sqlc.narg('last_error')::jsonb,
    updated_at = @updated_at::timestamptz
WHERE
    id = @id::uuid
RETURNING *
),
chats_expanded AS (
    SELECT
        updated_chat.id,
        updated_chat.owner_id,
        updated_chat.workspace_id,
        updated_chat.title,
        updated_chat.status,
        updated_chat.worker_id,
        updated_chat.started_at,
        updated_chat.heartbeat_at,
        updated_chat.created_at,
        updated_chat.updated_at,
        updated_chat.parent_chat_id,
        updated_chat.root_chat_id,
        updated_chat.last_model_config_id,
        updated_chat.archived,
        updated_chat.last_error,
        updated_chat.mode,
        updated_chat.mcp_server_ids,
        updated_chat.labels,
        updated_chat.build_id,
        updated_chat.agent_id,
        updated_chat.pin_order,
        updated_chat.last_read_message_id,
        updated_chat.last_injected_context,
        updated_chat.dynamic_tools,
        updated_chat.organization_id,
        updated_chat.plan_mode,
        updated_chat.client_type,
        updated_chat.last_turn_summary,
        COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
        COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
        owner.username AS owner_username,
        owner.name AS owner_name
    FROM
        updated_chat
    LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
    JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT *
FROM chats_expanded;

-- name: GetStaleChats :many
-- Find chats that appear stuck and need recovery:
--   1. Running chats whose heartbeat has expired (worker crash).
--   2. requires_action chats past the timeout threshold (client
--      disappeared).
--   3. Waiting chats with a non-empty queue and stale updated_at
--      (deferred-promote stranding when the worker dies before its
--      post-cancel cleanup runs).
SELECT
    *
FROM
    chats_expanded
WHERE
    (status = 'running'::chat_status
        AND heartbeat_at < @stale_threshold::timestamptz)
    OR (status = 'requires_action'::chat_status
        AND updated_at < @stale_threshold::timestamptz)
    OR (status = 'waiting'::chat_status
        AND updated_at < @stale_threshold::timestamptz
        AND EXISTS (
            SELECT 1 FROM chat_queued_messages cqm
            WHERE cqm.chat_id = chats_expanded.id
        ));

-- name: UpdateChatHeartbeats :many
-- Bumps the heartbeat timestamp for the given set of chat IDs,
-- provided they are still running and owned by the specified
-- worker. Returns the IDs that were actually updated so the
-- caller can detect stolen or completed chats via set-difference.
UPDATE
    chats
SET
    heartbeat_at = @now::timestamptz
WHERE
    id = ANY(@ids::uuid[])
    AND worker_id = @worker_id::uuid
    AND status = 'running'::chat_status
RETURNING id;

-- name: GetChatDiffStatusByChatID :one
SELECT
    *
FROM
    chat_diff_statuses
WHERE
    chat_id = @chat_id::uuid;

-- name: GetChatDiffStatusesByChatIDs :many
SELECT
    *
FROM
    chat_diff_statuses
WHERE
    chat_id = ANY(@chat_ids::uuid[]);

-- name: UpsertChatDiffStatusReference :one
INSERT INTO chat_diff_statuses (
    chat_id,
    url,
    git_branch,
    git_remote_origin,
    stale_at
) VALUES (
    @chat_id::uuid,
    sqlc.narg('url')::text,
    @git_branch::text,
    @git_remote_origin::text,
    @stale_at::timestamptz
)
ON CONFLICT (chat_id) DO UPDATE
SET
    url = CASE
        WHEN EXCLUDED.url IS NOT NULL THEN EXCLUDED.url
        ELSE chat_diff_statuses.url
    END,
    git_branch = CASE
        WHEN EXCLUDED.git_branch != '' THEN EXCLUDED.git_branch
        ELSE chat_diff_statuses.git_branch
    END,
    git_remote_origin = CASE
        WHEN EXCLUDED.git_remote_origin != '' THEN EXCLUDED.git_remote_origin
        ELSE chat_diff_statuses.git_remote_origin
    END,
    stale_at = EXCLUDED.stale_at,
    updated_at = NOW()
RETURNING
    *;

-- name: UpsertChatDiffStatus :one
INSERT INTO chat_diff_statuses (
    chat_id,
    url,
    pull_request_state,
    pull_request_title,
    pull_request_draft,
    changes_requested,
    additions,
    deletions,
    changed_files,
    author_login,
    author_avatar_url,
    base_branch,
    head_branch,
    pr_number,
    commits,
    approved,
    reviewer_count,
    refreshed_at,
    stale_at
) VALUES (
    @chat_id::uuid,
    sqlc.narg('url')::text,
    sqlc.narg('pull_request_state')::text,
    @pull_request_title::text,
    @pull_request_draft::boolean,
    @changes_requested::boolean,
    @additions::integer,
    @deletions::integer,
    @changed_files::integer,
    sqlc.narg('author_login')::text,
    sqlc.narg('author_avatar_url')::text,
    sqlc.narg('base_branch')::text,
    sqlc.narg('head_branch')::text,
    sqlc.narg('pr_number')::integer,
    sqlc.narg('commits')::integer,
    sqlc.narg('approved')::boolean,
    sqlc.narg('reviewer_count')::integer,
    @refreshed_at::timestamptz,
    @stale_at::timestamptz
)
ON CONFLICT (chat_id) DO UPDATE
SET
    url = EXCLUDED.url,
    pull_request_state = EXCLUDED.pull_request_state,
    pull_request_title = EXCLUDED.pull_request_title,
    pull_request_draft = EXCLUDED.pull_request_draft,
    changes_requested = EXCLUDED.changes_requested,
    additions = EXCLUDED.additions,
    deletions = EXCLUDED.deletions,
    changed_files = EXCLUDED.changed_files,
    author_login = EXCLUDED.author_login,
    author_avatar_url = EXCLUDED.author_avatar_url,
    base_branch = EXCLUDED.base_branch,
    head_branch = EXCLUDED.head_branch,
    pr_number = EXCLUDED.pr_number,
    commits = EXCLUDED.commits,
    approved = EXCLUDED.approved,
    reviewer_count = EXCLUDED.reviewer_count,
    refreshed_at = EXCLUDED.refreshed_at,
    stale_at = EXCLUDED.stale_at,
    updated_at = NOW()
RETURNING
    *;

-- name: InsertChatQueuedMessage :one
INSERT INTO chat_queued_messages (chat_id, content, model_config_id, api_key_id)
VALUES (
    @chat_id,
    @content,
    sqlc.narg('model_config_id')::uuid,
    sqlc.narg('api_key_id')::text
)
RETURNING *;

-- name: GetChatQueuedMessages :many
SELECT * FROM chat_queued_messages
WHERE chat_id = @chat_id
ORDER BY created_at ASC, id ASC;

-- name: DeleteChatQueuedMessage :exec
DELETE FROM chat_queued_messages WHERE id = @id AND chat_id = @chat_id;

-- name: DeleteAllChatQueuedMessages :exec
DELETE FROM chat_queued_messages WHERE chat_id = @chat_id;

-- name: PopNextQueuedMessage :one
DELETE FROM chat_queued_messages
WHERE id = (
    SELECT cqm.id FROM chat_queued_messages cqm
    WHERE cqm.chat_id = @chat_id
    ORDER BY cqm.created_at ASC, cqm.id ASC
    LIMIT 1
)
RETURNING *;

-- name: ReorderChatQueuedMessageToFront :execrows
-- Mutates only created_at on the target row; ids are unchanged so
-- consumers can keep tracking queued messages by id.
UPDATE chat_queued_messages AS target
SET created_at = (
    SELECT MIN(inner_cqm.created_at) - INTERVAL '1 microsecond'
    FROM chat_queued_messages AS inner_cqm
    WHERE inner_cqm.chat_id = @chat_id
)
WHERE target.id = @target_id AND target.chat_id = @chat_id;

-- name: GetLastChatMessageByRole :one
SELECT
    *
FROM
    chat_messages
WHERE
    chat_id = @chat_id::uuid
    AND role = @role::chat_message_role
    AND deleted = false
ORDER BY
    created_at DESC, id DESC
LIMIT
    1;

-- name: GetChatByIDForUpdate :one
WITH locked_chat AS (
    SELECT *
    FROM chats
    WHERE id = @id::uuid
    FOR UPDATE
),
chats_expanded AS (
    SELECT
        locked_chat.id,
        locked_chat.owner_id,
        locked_chat.workspace_id,
        locked_chat.title,
        locked_chat.status,
        locked_chat.worker_id,
        locked_chat.started_at,
        locked_chat.heartbeat_at,
        locked_chat.created_at,
        locked_chat.updated_at,
        locked_chat.parent_chat_id,
        locked_chat.root_chat_id,
        locked_chat.last_model_config_id,
        locked_chat.archived,
        locked_chat.last_error,
        locked_chat.mode,
        locked_chat.mcp_server_ids,
        locked_chat.labels,
        locked_chat.build_id,
        locked_chat.agent_id,
        locked_chat.pin_order,
        locked_chat.last_read_message_id,
        locked_chat.last_injected_context,
        locked_chat.dynamic_tools,
        locked_chat.organization_id,
        locked_chat.plan_mode,
        locked_chat.client_type,
        locked_chat.last_turn_summary,
        COALESCE(root.user_acl, locked_chat.user_acl) AS user_acl,
        COALESCE(root.group_acl, locked_chat.group_acl) AS group_acl,
        owner.username AS owner_username,
        owner.name AS owner_name
    FROM
        locked_chat
    LEFT JOIN chats root ON root.id = COALESCE(locked_chat.root_chat_id, locked_chat.parent_chat_id)
    JOIN visible_users owner ON owner.id = locked_chat.owner_id
)
SELECT *
FROM chats_expanded;

-- name: GetChatsByChatFileID :many
SELECT
    *
FROM
    chats_expanded
WHERE
    id IN (
        SELECT chat_id
        FROM chat_file_links
        WHERE file_id = @file_id::uuid
    )
    -- Authorize Filter clause will be injected below in GetAuthorizedChatsByChatFileID.
    -- @authorize_filter
;

-- name: AcquireStaleChatDiffStatuses :many
WITH acquired AS (
    UPDATE
        chat_diff_statuses
    SET
        -- Claim for 5 minutes. The worker sets the real stale_at
        -- after refresh. If the worker crashes, rows become eligible
        -- again after this interval.
        -- NOTE: updated_at is intentionally NOT touched here so
        -- the worker can read it as "when was this row last
        -- externally changed" (by MarkStale or a successful
        -- refresh).
        stale_at = NOW() + INTERVAL '5 minutes'
    WHERE
        chat_id IN (
            SELECT
                cds.chat_id
            FROM
                chat_diff_statuses cds
            INNER JOIN
                chats c ON c.id = cds.chat_id
            WHERE
                cds.stale_at <= NOW()
                AND cds.git_remote_origin != ''
                AND cds.git_branch != ''
                AND c.archived = FALSE
            ORDER BY
                cds.stale_at ASC
            FOR UPDATE OF cds
                SKIP LOCKED
            LIMIT
                @limit_val::int
        )
    RETURNING *
)
SELECT
    acquired.*,
    c.owner_id
FROM
    acquired
INNER JOIN
    chats c ON c.id = acquired.chat_id;

-- name: BackoffChatDiffStatus :exec
UPDATE
    chat_diff_statuses
SET
    -- NOTE: updated_at is intentionally NOT touched here so
    -- the worker can read it as "when was this row last
    -- externally changed" (by MarkStale or a successful
    -- refresh).
    stale_at = @stale_at::timestamptz
WHERE
    chat_id = @chat_id::uuid;

-- name: GetChatDiffStatusSummary :one
-- Returns aggregate PR counts across all agent chats for telemetry.
-- Deduplicates by PR URL so forked chats referencing the same pull
-- request are counted once (using the most recently refreshed state).
-- Total is derived from the three recognized state buckets and
-- always equals open + merged + closed; other non-NULL states are
-- intentionally excluded from these aggregates.
WITH deduped AS (
    SELECT DISTINCT ON (COALESCE(NULLIF(cds.url, ''), c.id::text))
        cds.pull_request_state
    FROM chat_diff_statuses cds
    JOIN chats c ON c.id = cds.chat_id
    WHERE cds.pull_request_state IN ('open', 'merged', 'closed')
    ORDER BY COALESCE(NULLIF(cds.url, ''), c.id::text), cds.updated_at DESC, c.id DESC
)
SELECT
    COUNT(*)::bigint AS total,
    COUNT(*) FILTER (WHERE pull_request_state = 'open')::bigint AS open,
    COUNT(*) FILTER (WHERE pull_request_state = 'merged')::bigint AS merged,
    COUNT(*) FILTER (WHERE pull_request_state = 'closed')::bigint AS closed
FROM deduped;

-- name: GetChatCostSummary :one
-- Aggregate cost summary for a single user within a date range.
-- Only counts assistant-role messages.
SELECT
    COALESCE(SUM(cm.total_cost_micros), 0)::bigint AS total_cost_micros,
    COUNT(*) FILTER (
        WHERE cm.total_cost_micros IS NOT NULL
    )::bigint AS priced_message_count,
    COUNT(*) FILTER (
        WHERE cm.total_cost_micros IS NULL
            AND (
                cm.input_tokens IS NOT NULL
                OR cm.output_tokens IS NOT NULL
                OR cm.reasoning_tokens IS NOT NULL
                OR cm.cache_creation_tokens IS NOT NULL
                OR cm.cache_read_tokens IS NOT NULL
            )
    )::bigint AS unpriced_message_count,
    COALESCE(SUM(cm.input_tokens), 0)::bigint AS total_input_tokens,
    COALESCE(SUM(cm.output_tokens), 0)::bigint AS total_output_tokens,
    COALESCE(SUM(cm.cache_read_tokens), 0)::bigint AS total_cache_read_tokens,
    COALESCE(SUM(cm.cache_creation_tokens), 0)::bigint AS total_cache_creation_tokens,
    COALESCE(SUM(cm.runtime_ms), 0)::bigint AS total_runtime_ms
FROM
    chat_messages cm
JOIN
    chats c ON c.id = cm.chat_id
WHERE
    c.owner_id = @owner_id::uuid
    AND cm.role = 'assistant'
    AND cm.created_at >= @start_date::timestamptz
    AND cm.created_at < @end_date::timestamptz;

-- name: GetChatCostPerModel :many
-- Per-model cost breakdown for a single user within a date range.
-- Only counts assistant-role messages that have a model_config_id.
SELECT
    cmc.id AS model_config_id,
    cmc.display_name,
    cmc.provider,
    cmc.model,
    COALESCE(SUM(cm.total_cost_micros), 0)::bigint AS total_cost_micros,
    COUNT(*) FILTER (
        WHERE cm.input_tokens IS NOT NULL
            OR cm.output_tokens IS NOT NULL
            OR cm.reasoning_tokens IS NOT NULL
            OR cm.cache_creation_tokens IS NOT NULL
            OR cm.cache_read_tokens IS NOT NULL
    )::bigint AS message_count,
    COALESCE(SUM(cm.input_tokens), 0)::bigint AS total_input_tokens,
    COALESCE(SUM(cm.output_tokens), 0)::bigint AS total_output_tokens,
    COALESCE(SUM(cm.cache_read_tokens), 0)::bigint AS total_cache_read_tokens,
    COALESCE(SUM(cm.cache_creation_tokens), 0)::bigint AS total_cache_creation_tokens,
    COALESCE(SUM(cm.runtime_ms), 0)::bigint AS total_runtime_ms
FROM
    chat_messages cm
JOIN
    chats c ON c.id = cm.chat_id
JOIN
    chat_model_configs cmc ON cmc.id = cm.model_config_id
WHERE
    c.owner_id = @owner_id::uuid
    AND cm.role = 'assistant'
    AND cm.created_at >= @start_date::timestamptz
    AND cm.created_at < @end_date::timestamptz
GROUP BY
    cmc.id, cmc.display_name, cmc.provider, cmc.model
ORDER BY
    total_cost_micros DESC;

-- name: GetChatCostPerChat :many
-- Per-root-chat cost breakdown for a single user within a date range.
-- Groups by root_chat_id so forked chats roll up under their root.
-- Only counts assistant-role messages.
WITH chat_costs AS (
    SELECT
        COALESCE(c.root_chat_id, c.id) AS root_chat_id,
        COALESCE(SUM(cm.total_cost_micros), 0)::bigint AS total_cost_micros,
        COUNT(*) FILTER (
            WHERE cm.input_tokens IS NOT NULL
                OR cm.output_tokens IS NOT NULL
                OR cm.reasoning_tokens IS NOT NULL
                OR cm.cache_creation_tokens IS NOT NULL
                OR cm.cache_read_tokens IS NOT NULL
        )::bigint AS message_count,
        COALESCE(SUM(cm.input_tokens), 0)::bigint AS total_input_tokens,
        COALESCE(SUM(cm.output_tokens), 0)::bigint AS total_output_tokens,
        COALESCE(SUM(cm.cache_read_tokens), 0)::bigint AS total_cache_read_tokens,
        COALESCE(SUM(cm.cache_creation_tokens), 0)::bigint AS total_cache_creation_tokens,
        COALESCE(SUM(cm.runtime_ms), 0)::bigint AS total_runtime_ms
    FROM chat_messages cm
    JOIN chats c ON c.id = cm.chat_id
    WHERE c.owner_id = @owner_id::uuid
      AND cm.role = 'assistant'
      AND cm.created_at >= @start_date::timestamptz
      AND cm.created_at < @end_date::timestamptz
    GROUP BY COALESCE(c.root_chat_id, c.id)
)
SELECT
    cc.root_chat_id,
    COALESCE(rc.title, '') AS chat_title,
    cc.total_cost_micros,
    cc.message_count,
    cc.total_input_tokens,
    cc.total_output_tokens,
    cc.total_cache_read_tokens,
    cc.total_cache_creation_tokens,
    cc.total_runtime_ms
FROM chat_costs cc
LEFT JOIN chats rc ON rc.id = cc.root_chat_id
ORDER BY cc.total_cost_micros DESC;

-- name: GetChatCostPerUser :many
-- Deployment-wide per-user cost rollup within a date range.
-- Only counts assistant-role messages.
WITH chat_cost_users AS (
    SELECT
        c.owner_id AS user_id,
        u.username,
        u.name,
        u.avatar_url,
        COALESCE(SUM(cm.total_cost_micros), 0)::bigint AS total_cost_micros,
        COUNT(*) FILTER (
            WHERE cm.input_tokens IS NOT NULL
                OR cm.output_tokens IS NOT NULL
                OR cm.reasoning_tokens IS NOT NULL
                OR cm.cache_creation_tokens IS NOT NULL
                OR cm.cache_read_tokens IS NOT NULL
        )::bigint AS message_count,
        COUNT(DISTINCT COALESCE(c.root_chat_id, c.id))::bigint AS chat_count,
        COALESCE(SUM(cm.input_tokens), 0)::bigint AS total_input_tokens,
        COALESCE(SUM(cm.output_tokens), 0)::bigint AS total_output_tokens,
        COALESCE(SUM(cm.cache_read_tokens), 0)::bigint AS total_cache_read_tokens,
        COALESCE(SUM(cm.cache_creation_tokens), 0)::bigint AS total_cache_creation_tokens,
        COALESCE(SUM(cm.runtime_ms), 0)::bigint AS total_runtime_ms
    FROM
        chat_messages cm
    JOIN
        chats c ON c.id = cm.chat_id
    JOIN
        users u ON u.id = c.owner_id
    WHERE
        cm.role = 'assistant'
        AND cm.created_at >= @start_date::timestamptz
        AND cm.created_at < @end_date::timestamptz
        AND (
            @username::text = ''
            OR u.username ILIKE '%' || @username::text || '%'
            OR u.name ILIKE '%' || @username::text || '%'
        )
    GROUP BY
        c.owner_id,
        u.username,
        u.name,
        u.avatar_url
)
SELECT
    user_id,
    username,
    name,
    avatar_url,
    total_cost_micros,
    message_count,
    chat_count,
    total_input_tokens,
    total_output_tokens,
    total_cache_read_tokens,
    total_cache_creation_tokens,
    total_runtime_ms,
    COUNT(*) OVER()::bigint AS total_count
FROM
    chat_cost_users
ORDER BY
    total_cost_micros DESC,
    username ASC
LIMIT
    sqlc.arg('page_limit')::int
OFFSET
    sqlc.arg('page_offset')::int;

-- name: GetChatUsageLimitConfig :one
SELECT * FROM chat_usage_limit_config WHERE singleton = TRUE LIMIT 1;

-- name: UpsertChatUsageLimitConfig :one
INSERT INTO chat_usage_limit_config (singleton, enabled, default_limit_micros, period, updated_at)
VALUES (TRUE, @enabled::boolean, @default_limit_micros::bigint, @period::text, NOW())
ON CONFLICT (singleton) DO UPDATE SET
    enabled = EXCLUDED.enabled,
    default_limit_micros = EXCLUDED.default_limit_micros,
    period = EXCLUDED.period,
    updated_at = NOW()
RETURNING *;

-- name: ListChatUsageLimitOverrides :many
SELECT u.id AS user_id, u.username, u.name, u.avatar_url,
       u.chat_spend_limit_micros AS spend_limit_micros
FROM users u
WHERE u.chat_spend_limit_micros IS NOT NULL
ORDER BY u.username ASC;

-- name: UpsertChatUsageLimitUserOverride :one
UPDATE users
SET chat_spend_limit_micros = @spend_limit_micros::bigint
WHERE id = @user_id::uuid
RETURNING id AS user_id, username, name, avatar_url, chat_spend_limit_micros AS spend_limit_micros;

-- name: DeleteChatUsageLimitUserOverride :exec
UPDATE users SET chat_spend_limit_micros = NULL WHERE id = @user_id::uuid;

-- name: GetChatUsageLimitUserOverride :one
SELECT id AS user_id, chat_spend_limit_micros AS spend_limit_micros
FROM users
WHERE id = @user_id::uuid AND chat_spend_limit_micros IS NOT NULL;

-- name: GetUserChatSpendInPeriod :one
-- Returns the total spend for a user in the given period.
-- When organization_id is NULL, spend across all organizations is
-- returned (global behavior). Otherwise only spend within the
-- specified organization is included.
SELECT COALESCE(SUM(cm.total_cost_micros), 0)::bigint AS total_spend_micros
FROM chat_messages cm
JOIN chats c ON c.id = cm.chat_id
WHERE c.owner_id = @user_id::uuid
  AND (sqlc.narg('organization_id')::uuid IS NULL
       OR c.organization_id = sqlc.narg('organization_id')::uuid)
  AND cm.created_at >= @start_time::timestamptz
  AND cm.created_at < @end_time::timestamptz
  AND cm.total_cost_micros IS NOT NULL;

-- name: CountEnabledModelsWithoutPricing :one
-- Counts enabled, non-deleted model configs that lack both input and
-- output pricing in their JSONB options.cost configuration.
SELECT COUNT(*)::bigint AS count
FROM chat_model_configs
WHERE enabled = TRUE
  AND deleted = FALSE
  AND (
    options->'cost' IS NULL
    OR options->'cost' = 'null'::jsonb
    OR (
      (options->'cost'->>'input_price_per_million_tokens' IS NULL)
      AND (options->'cost'->>'output_price_per_million_tokens' IS NULL)
    )
  );

-- name: ListChatUsageLimitGroupOverrides :many
SELECT
    g.id AS group_id,
    g.name AS group_name,
    g.display_name AS group_display_name,
    g.avatar_url AS group_avatar_url,
    g.chat_spend_limit_micros AS spend_limit_micros,
    (SELECT COUNT(*)
        FROM group_members_expanded gme
        WHERE gme.group_id = g.id
          AND gme.user_is_system = FALSE) AS member_count
FROM groups g
WHERE g.chat_spend_limit_micros IS NOT NULL
ORDER BY g.name ASC;

-- name: UpsertChatUsageLimitGroupOverride :one
UPDATE groups
SET chat_spend_limit_micros = @spend_limit_micros::bigint
WHERE id = @group_id::uuid
RETURNING id AS group_id, name, display_name, avatar_url, chat_spend_limit_micros AS spend_limit_micros;

-- name: DeleteChatUsageLimitGroupOverride :exec
UPDATE groups SET chat_spend_limit_micros = NULL WHERE id = @group_id::uuid;

-- name: GetChatUsageLimitGroupOverride :one
SELECT id AS group_id, chat_spend_limit_micros AS spend_limit_micros
FROM groups
WHERE id = @group_id::uuid AND chat_spend_limit_micros IS NOT NULL;

-- name: GetUserGroupSpendLimit :one
-- Returns the minimum (most restrictive) group limit for a user.
-- Returns -1 if no group limits match the specified scope.
-- When organization_id is NULL, groups across all organizations are
-- considered (global behavior). Otherwise only groups within the
-- specified organization are considered.
SELECT COALESCE(MIN(g.chat_spend_limit_micros), -1)::bigint AS limit_micros
FROM groups g
JOIN group_members_expanded gme ON gme.group_id = g.id
WHERE gme.user_id = @user_id::uuid
  AND (sqlc.narg('organization_id')::uuid IS NULL
       OR g.organization_id = sqlc.narg('organization_id')::uuid)
  AND g.chat_spend_limit_micros IS NOT NULL;

-- name: GetChatsByWorkspaceIDs :many
SELECT *
FROM chats_expanded
WHERE archived = false
  AND workspace_id = ANY(@ids::uuid[])
ORDER BY workspace_id, updated_at DESC;

-- name: ResolveUserChatSpendLimit :one
-- Resolves the effective spend limit for a user using the hierarchy:
-- 1. Individual user override (highest priority, applies globally across
--    all organizations since it lives on the users table)
-- 2. Minimum group limit across the user's groups
-- 3. Global default from config
-- Returns -1 if limits are not enabled.
-- When organization_id is NULL, groups across all organizations are
-- considered (global behavior). Otherwise only groups within the
-- specified organization are considered.
-- limit_source indicates which tier won: 'user', 'group', 'default',
-- or 'disabled'.
SELECT CASE
    WHEN NOT cfg.enabled THEN -1
    WHEN u.chat_spend_limit_micros IS NOT NULL THEN u.chat_spend_limit_micros
    WHEN gl.limit_micros IS NOT NULL THEN gl.limit_micros
    ELSE cfg.default_limit_micros
END::bigint AS effective_limit_micros,
CASE
    WHEN NOT cfg.enabled THEN 'disabled'
    WHEN u.chat_spend_limit_micros IS NOT NULL THEN 'user'
    WHEN gl.limit_micros IS NOT NULL THEN 'group'
    ELSE 'default'
END AS limit_source
FROM chat_usage_limit_config cfg
CROSS JOIN users u
LEFT JOIN LATERAL (
    SELECT MIN(g.chat_spend_limit_micros) AS limit_micros
    FROM groups g
    JOIN group_members_expanded gme ON gme.group_id = g.id
    WHERE gme.user_id = @user_id::uuid
      AND (sqlc.narg('organization_id')::uuid IS NULL
           OR g.organization_id = sqlc.narg('organization_id')::uuid)
      AND g.chat_spend_limit_micros IS NOT NULL
) gl ON TRUE
WHERE u.id = @user_id::uuid
LIMIT 1;

-- name: UpdateChatLastReadMessageID :exec
-- Updates the last read message ID for a chat. This is used to track
-- which messages the owner has seen, enabling unread indicators.
UPDATE chats
SET last_read_message_id = @last_read_message_id::bigint
WHERE id = @id::uuid;

-- name: DeleteOldChats :execrows
-- Deletes chats that have been archived for longer than the given
-- threshold. Active (non-archived) chats are never deleted.
-- Related chat_messages, chat_diff_statuses, and
-- chat_queued_messages are removed via ON DELETE CASCADE.
-- Parent/root references on child chats are SET NULL.
WITH deletable AS (
    SELECT id
    FROM chats
    WHERE archived = true
      AND updated_at < @before_time::timestamptz
    ORDER BY updated_at ASC
    LIMIT @limit_count
)
DELETE FROM chats
USING deletable
WHERE chats.id = deletable.id
  AND chats.archived = true;

-- name: GetChatsUpdatedAfter :many
-- Retrieves chats updated after the given timestamp for telemetry
-- snapshot collection. Uses updated_at so that long-running chats
-- still appear in each snapshot window while they are active.
SELECT
    c.id, c.owner_id, c.created_at, c.updated_at, c.status,
    (c.parent_chat_id IS NOT NULL)::bool AS has_parent,
    c.root_chat_id, c.workspace_id,
    c.mode, c.archived, c.last_model_config_id, c.client_type,
    cds.pull_request_state
FROM chats c
LEFT JOIN chat_diff_statuses cds ON cds.chat_id = c.id
WHERE c.updated_at > @updated_after;

-- name: GetChatMessageSummariesPerChat :many
-- Aggregates message-level metrics per chat for messages created
-- after the given timestamp. Uses message created_at so that
-- ongoing activity in long-running chats is captured each window.
SELECT
    cm.chat_id,
    COUNT(*)::bigint AS message_count,
    COUNT(*) FILTER (WHERE cm.role = 'user')::bigint AS user_message_count,
    COUNT(*) FILTER (WHERE cm.role = 'assistant')::bigint AS assistant_message_count,
    COUNT(*) FILTER (WHERE cm.role = 'tool')::bigint AS tool_message_count,
    COUNT(*) FILTER (WHERE cm.role = 'system')::bigint AS system_message_count,
    COALESCE(SUM(cm.input_tokens), 0)::bigint AS total_input_tokens,
    COALESCE(SUM(cm.output_tokens), 0)::bigint AS total_output_tokens,
    COALESCE(SUM(cm.reasoning_tokens), 0)::bigint AS total_reasoning_tokens,
    COALESCE(SUM(cm.cache_creation_tokens), 0)::bigint AS total_cache_creation_tokens,
    COALESCE(SUM(cm.cache_read_tokens), 0)::bigint AS total_cache_read_tokens,
    COALESCE(SUM(cm.total_cost_micros), 0)::bigint AS total_cost_micros,
    COALESCE(SUM(cm.runtime_ms), 0)::bigint AS total_runtime_ms,
    COUNT(DISTINCT cm.model_config_id)::bigint AS distinct_model_count,
    COUNT(*) FILTER (WHERE cm.compressed)::bigint AS compressed_message_count
FROM chat_messages cm
WHERE cm.created_at > @created_after
  AND cm.deleted = false
GROUP BY cm.chat_id;

-- name: GetChatModelConfigsForTelemetry :many
-- Returns all model configurations for telemetry snapshot collection.
SELECT id, provider, model, context_limit, enabled, is_default
FROM chat_model_configs
WHERE deleted = false;
-- name: GetActiveChatsByAgentID :many
SELECT *
FROM chats_expanded
WHERE agent_id = @agent_id::uuid
    AND archived = false
    -- Active statuses only: waiting, pending, running, paused,
    -- requires_action.
    -- Excludes completed and error (terminal states).
    AND status IN ('waiting', 'running', 'paused', 'pending', 'requires_action')
ORDER BY updated_at DESC;

-- name: ClearChatMessageProviderResponseIDsByChatID :exec
UPDATE chat_messages
SET provider_response_id = NULL
WHERE chat_id = @chat_id::uuid
    AND deleted = false
    AND provider_response_id IS NOT NULL;

-- name: SoftDeleteContextFileMessages :exec
UPDATE chat_messages SET deleted = true
WHERE chat_id = @chat_id::uuid
    AND deleted = false
    AND content::jsonb @> '[{"type": "context-file"}]';

-- name: AutoArchiveInactiveChats :many
-- Archives inactive root chats (pinned and already-archived chats skipped),
-- cascading to children via root_chat_id. Limits apply to roots, not total
-- rows. The Go caller passes @archive_cutoff as UTC midnight so that all
-- chats sharing the same last-activity date are archived together.
-- Used by dbpurge.
WITH to_archive AS (
    SELECT
        c.id,
        -- Activity = MAX(cm.created_at) across the family, or c.created_at
        -- when the family has no non-deleted messages.
        COALESCE(activity.last_activity_at, c.created_at) AS last_activity_at
    FROM chats c
    LEFT JOIN LATERAL (
        SELECT MAX(cm.created_at) AS last_activity_at
        FROM chat_messages cm
        JOIN chats fc ON fc.id = cm.chat_id
        WHERE (fc.id = c.id OR fc.root_chat_id = c.id)
          AND cm.deleted = false
    ) activity ON TRUE
    WHERE c.archived = false
      AND c.pin_order = 0
      AND c.parent_chat_id IS NULL -- roots only
      -- Redundant filter helps the planner use the partial index on created_at.
      AND c.created_at < @archive_cutoff::timestamptz
      -- New active statuses must be added here to prevent archiving.
      AND c.status NOT IN ('running', 'pending', 'paused', 'requires_action')
      AND COALESCE(activity.last_activity_at, c.created_at) < @archive_cutoff::timestamptz
    -- Sorting by created_at lets Postgres drive the scan from the
    -- partial index instead of evaluating every LATERAL subquery
    -- before sorting. All candidates are past the cutoff, so the
    -- archive order is immaterial once the backlog drains.
    ORDER BY c.created_at ASC
    LIMIT @limit_count
),
archived AS (
    UPDATE chats c
    SET archived = true, pin_order = 0, updated_at = NOW()
    FROM to_archive t
    WHERE (c.id = t.id OR c.root_chat_id = t.id) -- cascade to children
      AND c.archived = false
    RETURNING c.*
)
SELECT
    a.*,
    -- Children inherit their root's activity so last_activity_at is never null.
    COALESCE(
        t.last_activity_at,
        (SELECT tr.last_activity_at FROM to_archive tr WHERE tr.id = a.root_chat_id),
        a.created_at
    )::timestamptz AS last_activity_at
FROM archived a
LEFT JOIN to_archive t ON t.id = a.id
-- created_at ASC flows through to dbpurge's digest truncation; see
-- buildDigestData in dbpurge.go for the tradeoff rationale.
ORDER BY (a.root_chat_id IS NULL) DESC, a.owner_id ASC, a.created_at ASC, a.id ASC;
