import { and, asc, desc, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
import { activityLog, agents, assets, companies, companyMemberships, documents, goals, heartbeatRuns, executionWorkspaces, issueAttachments, issueInboxArchives, issueLabels, issueComments, issueDocuments, issueReadStates, issues, labels, projectWorkspaces, projects, } from "@zmeel/db";
import { extractAgentMentionIds, extractProjectMentionIds, isUuidLike } from "@zmeel/shared";
import { conflict, notFound, unprocessable } from "../errors.js";
import { defaultIssueExecutionWorkspaceSettingsForProject, gateProjectExecutionWorkspacePolicy, issueExecutionWorkspaceModeForPersistedWorkspace, parseProjectExecutionWorkspacePolicy, } from "./execution-workspace-policy.js";
import { instanceSettingsService } from "./instance-settings.js";
import { redactCurrentUserText } from "../log-redaction.js";
import { resolveIssueGoalId, resolveNextIssueGoalId } from "./issue-goal-fallback.js";
import { getDefaultCompanyGoal } from "./goals.js";
const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"];
const MAX_ISSUE_COMMENT_PAGE_LIMIT = 500;
function assertTransition(from, to) {
    if (from === to)
        return;
    if (!ALL_ISSUE_STATUSES.includes(to)) {
        throw conflict(`Unknown issue status: ${to}`);
    }
}
function applyStatusSideEffects(status, patch) {
    if (!status)
        return patch;
    if (status === "in_progress" && !patch.startedAt) {
        patch.startedAt = new Date();
    }
    if (status === "done") {
        patch.completedAt = new Date();
    }
    if (status === "cancelled") {
        patch.cancelledAt = new Date();
    }
    return patch;
}
function sameRunLock(checkoutRunId, actorRunId) {
    if (actorRunId)
        return checkoutRunId === actorRunId;
    return checkoutRunId == null;
}
const TERMINAL_HEARTBEAT_RUN_STATUSES = new Set(["succeeded", "failed", "cancelled", "timed_out"]);
function escapeLikePattern(value) {
    return value.replace(/[\\%_]/g, "\\$&");
}
async function getProjectDefaultGoalId(db, companyId, projectId) {
    if (!projectId)
        return null;
    const row = await db
        .select({ goalId: projects.goalId })
        .from(projects)
        .where(and(eq(projects.id, projectId), eq(projects.companyId, companyId)))
        .then((rows) => rows[0] ?? null);
    return row?.goalId ?? null;
}
async function getWorkspaceInheritanceIssue(db, companyId, issueId) {
    const issue = await db
        .select({
        id: issues.id,
        projectId: issues.projectId,
        projectWorkspaceId: issues.projectWorkspaceId,
        executionWorkspaceId: issues.executionWorkspaceId,
        executionWorkspaceSettings: issues.executionWorkspaceSettings,
    })
        .from(issues)
        .where(and(eq(issues.id, issueId), eq(issues.companyId, companyId)))
        .then((rows) => rows[0] ?? null);
    if (!issue) {
        throw notFound("Workspace inheritance issue not found");
    }
    return issue;
}
function touchedByUserCondition(companyId, userId) {
    return sql `
    (
      ${issues.createdByUserId} = ${userId}
      OR ${issues.assigneeUserId} = ${userId}
      OR EXISTS (
        SELECT 1
        FROM ${issueReadStates}
        WHERE ${issueReadStates.issueId} = ${issues.id}
          AND ${issueReadStates.companyId} = ${companyId}
          AND ${issueReadStates.userId} = ${userId}
      )
      OR EXISTS (
        SELECT 1
        FROM ${issueComments}
        WHERE ${issueComments.issueId} = ${issues.id}
          AND ${issueComments.companyId} = ${companyId}
          AND ${issueComments.authorUserId} = ${userId}
      )
    )
  `;
}
function participatedByAgentCondition(companyId, agentId) {
    return sql `
    (
      ${issues.createdByAgentId} = ${agentId}
      OR ${issues.assigneeAgentId} = ${agentId}
      OR EXISTS (
        SELECT 1
        FROM ${issueComments}
        WHERE ${issueComments.issueId} = ${issues.id}
          AND ${issueComments.companyId} = ${companyId}
          AND ${issueComments.authorAgentId} = ${agentId}
      )
      OR EXISTS (
        SELECT 1
        FROM ${activityLog}
        WHERE ${activityLog.companyId} = ${companyId}
          AND ${activityLog.entityType} = 'issue'
          AND ${activityLog.entityId} = ${issues.id}::text
          AND ${activityLog.agentId} = ${agentId}
      )
    )
  `;
}
function myLastCommentAtExpr(companyId, userId) {
    return sql `
    (
      SELECT MAX(${issueComments.createdAt})
      FROM ${issueComments}
      WHERE ${issueComments.issueId} = ${issues.id}
        AND ${issueComments.companyId} = ${companyId}
        AND ${issueComments.authorUserId} = ${userId}
    )
  `;
}
function myLastReadAtExpr(companyId, userId) {
    return sql `
    (
      SELECT MAX(${issueReadStates.lastReadAt})
      FROM ${issueReadStates}
      WHERE ${issueReadStates.issueId} = ${issues.id}
        AND ${issueReadStates.companyId} = ${companyId}
        AND ${issueReadStates.userId} = ${userId}
    )
  `;
}
function myLastTouchAtExpr(companyId, userId) {
    const myLastCommentAt = myLastCommentAtExpr(companyId, userId);
    const myLastReadAt = myLastReadAtExpr(companyId, userId);
    return sql `
    GREATEST(
      COALESCE(${myLastCommentAt}, to_timestamp(0)),
      COALESCE(${myLastReadAt}, to_timestamp(0)),
      COALESCE(CASE WHEN ${issues.createdByUserId} = ${userId} THEN ${issues.createdAt} ELSE NULL END, to_timestamp(0)),
      COALESCE(CASE WHEN ${issues.assigneeUserId} = ${userId} THEN ${issues.updatedAt} ELSE NULL END, to_timestamp(0))
    )
  `;
}
function lastExternalCommentAtExpr(companyId, userId) {
    return sql `
    (
      SELECT MAX(${issueComments.createdAt})
      FROM ${issueComments}
      WHERE ${issueComments.issueId} = ${issues.id}
        AND ${issueComments.companyId} = ${companyId}
        AND (
          ${issueComments.authorUserId} IS NULL
          OR ${issueComments.authorUserId} <> ${userId}
        )
    )
  `;
}
function issueLastActivityAtExpr(companyId, userId) {
    const lastExternalCommentAt = lastExternalCommentAtExpr(companyId, userId);
    const myLastTouchAt = myLastTouchAtExpr(companyId, userId);
    return sql `
    GREATEST(
      COALESCE(${lastExternalCommentAt}, to_timestamp(0)),
      CASE
        WHEN ${issues.updatedAt} > COALESCE(${myLastTouchAt}, to_timestamp(0))
        THEN ${issues.updatedAt}
        ELSE to_timestamp(0)
      END
    )
  `;
}
const ISSUE_LOCAL_INBOX_ACTIVITY_ACTIONS = [
    "issue.read_marked",
    "issue.read_unmarked",
    "issue.inbox_archived",
    "issue.inbox_unarchived",
];
function issueLatestCommentAtExpr(companyId) {
    return sql `
    (
      SELECT MAX(${issueComments.createdAt})
      FROM ${issueComments}
      WHERE ${issueComments.issueId} = ${issues.id}
        AND ${issueComments.companyId} = ${companyId}
    )
  `;
}
function issueLatestLogAtExpr(companyId) {
    return sql `
    (
      SELECT MAX(${activityLog.createdAt})
      FROM ${activityLog}
      WHERE ${activityLog.companyId} = ${companyId}
        AND ${activityLog.entityType} = 'issue'
        AND ${activityLog.entityId} = ${issues.id}::text
        AND ${activityLog.action} NOT IN (${sql.join(ISSUE_LOCAL_INBOX_ACTIVITY_ACTIONS.map((action) => sql `${action}`), sql `, `)})
    )
  `;
}
function issueCanonicalLastActivityAtExpr(companyId) {
    const latestCommentAt = issueLatestCommentAtExpr(companyId);
    const latestLogAt = issueLatestLogAtExpr(companyId);
    return sql `
    GREATEST(
      ${issues.updatedAt},
      COALESCE(${latestCommentAt}, to_timestamp(0)),
      COALESCE(${latestLogAt}, to_timestamp(0))
    )
  `;
}
function unreadForUserCondition(companyId, userId) {
    const touchedCondition = touchedByUserCondition(companyId, userId);
    const myLastTouchAt = myLastTouchAtExpr(companyId, userId);
    return sql `
    (
      ${touchedCondition}
      AND EXISTS (
        SELECT 1
        FROM ${issueComments}
        WHERE ${issueComments.issueId} = ${issues.id}
          AND ${issueComments.companyId} = ${companyId}
          AND (
            ${issueComments.authorUserId} IS NULL
            OR ${issueComments.authorUserId} <> ${userId}
          )
          AND ${issueComments.createdAt} > ${myLastTouchAt}
      )
    )
  `;
}
function inboxVisibleForUserCondition(companyId, userId) {
    const issueLastActivityAt = issueLastActivityAtExpr(companyId, userId);
    return sql `
    NOT EXISTS (
      SELECT 1
      FROM ${issueInboxArchives}
      WHERE ${issueInboxArchives.issueId} = ${issues.id}
        AND ${issueInboxArchives.companyId} = ${companyId}
        AND ${issueInboxArchives.userId} = ${userId}
        AND ${issueInboxArchives.archivedAt} >= ${issueLastActivityAt}
    )
  `;
}
/** Named entities commonly emitted in saved issue bodies; unknown `&name;` sequences are left unchanged. */
const WELL_KNOWN_NAMED_HTML_ENTITIES = {
    amp: "&",
    apos: "'",
    copy: "\u00A9",
    gt: ">",
    lt: "<",
    nbsp: "\u00A0",
    quot: '"',
    ensp: "\u2002",
    emsp: "\u2003",
    thinsp: "\u2009",
};
function decodeNumericHtmlEntity(digits, radix) {
    const n = Number.parseInt(digits, radix);
    if (Number.isNaN(n) || n < 0 || n > 0x10ffff)
        return null;
    try {
        return String.fromCodePoint(n);
    }
    catch {
        return null;
    }
}
/** Decodes HTML character references in a raw @mention capture so UI-encoded bodies match agent names. */
export function normalizeAgentMentionToken(raw) {
    let s = raw.replace(/&#x([0-9a-fA-F]+);/gi, (full, hex) => decodeNumericHtmlEntity(hex, 16) ?? full);
    s = s.replace(/&#([0-9]+);/g, (full, dec) => decodeNumericHtmlEntity(dec, 10) ?? full);
    s = s.replace(/&([a-z][a-z0-9]*);/gi, (full, name) => {
        const decoded = WELL_KNOWN_NAMED_HTML_ENTITIES[name.toLowerCase()];
        return decoded !== undefined ? decoded : full;
    });
    return s.trim();
}
export function deriveIssueUserContext(issue, userId, stats) {
    const normalizeDate = (value) => {
        if (!value)
            return null;
        if (value instanceof Date)
            return Number.isNaN(value.getTime()) ? null : value;
        const parsed = new Date(value);
        return Number.isNaN(parsed.getTime()) ? null : parsed;
    };
    const myLastCommentAt = normalizeDate(stats?.myLastCommentAt);
    const myLastReadAt = normalizeDate(stats?.myLastReadAt);
    const createdTouchAt = issue.createdByUserId === userId ? normalizeDate(issue.createdAt) : null;
    const assignedTouchAt = issue.assigneeUserId === userId ? normalizeDate(issue.updatedAt) : null;
    const myLastTouchAt = [myLastCommentAt, myLastReadAt, createdTouchAt, assignedTouchAt]
        .filter((value) => value instanceof Date)
        .sort((a, b) => b.getTime() - a.getTime())[0] ?? null;
    const lastExternalCommentAt = normalizeDate(stats?.lastExternalCommentAt);
    const isUnreadForMe = Boolean(myLastTouchAt &&
        lastExternalCommentAt &&
        lastExternalCommentAt.getTime() > myLastTouchAt.getTime());
    return {
        myLastTouchAt,
        lastExternalCommentAt,
        isUnreadForMe,
    };
}
function latestIssueActivityAt(...values) {
    const normalized = values
        .map((value) => {
        if (!value)
            return null;
        if (value instanceof Date)
            return Number.isNaN(value.getTime()) ? null : value;
        const parsed = new Date(value);
        return Number.isNaN(parsed.getTime()) ? null : parsed;
    })
        .filter((value) => value instanceof Date)
        .sort((a, b) => b.getTime() - a.getTime());
    return normalized[0] ?? null;
}
async function labelMapForIssues(dbOrTx, issueIds) {
    const map = new Map();
    if (issueIds.length === 0)
        return map;
    const rows = await dbOrTx
        .select({
        issueId: issueLabels.issueId,
        label: labels,
    })
        .from(issueLabels)
        .innerJoin(labels, eq(issueLabels.labelId, labels.id))
        .where(inArray(issueLabels.issueId, issueIds))
        .orderBy(asc(labels.name), asc(labels.id));
    for (const row of rows) {
        const existing = map.get(row.issueId);
        if (existing)
            existing.push(row.label);
        else
            map.set(row.issueId, [row.label]);
    }
    return map;
}
async function withIssueLabels(dbOrTx, rows) {
    if (rows.length === 0)
        return [];
    const labelsByIssueId = await labelMapForIssues(dbOrTx, rows.map((row) => row.id));
    return rows.map((row) => {
        const issueLabels = labelsByIssueId.get(row.id) ?? [];
        return {
            ...row,
            labels: issueLabels,
            labelIds: issueLabels.map((label) => label.id),
        };
    });
}
const ACTIVE_RUN_STATUSES = ["queued", "running"];
async function activeRunMapForIssues(dbOrTx, issueRows) {
    const map = new Map();
    const runIds = issueRows
        .map((row) => row.executionRunId)
        .filter((id) => id != null);
    if (runIds.length === 0)
        return map;
    const rows = await dbOrTx
        .select({
        id: heartbeatRuns.id,
        status: heartbeatRuns.status,
        agentId: heartbeatRuns.agentId,
        invocationSource: heartbeatRuns.invocationSource,
        triggerDetail: heartbeatRuns.triggerDetail,
        startedAt: heartbeatRuns.startedAt,
        finishedAt: heartbeatRuns.finishedAt,
        createdAt: heartbeatRuns.createdAt,
    })
        .from(heartbeatRuns)
        .where(and(inArray(heartbeatRuns.id, runIds), inArray(heartbeatRuns.status, ACTIVE_RUN_STATUSES)));
    for (const row of rows) {
        map.set(row.id, row);
    }
    return map;
}
function withActiveRuns(issueRows, runMap) {
    return issueRows.map((row) => ({
        ...row,
        activeRun: row.executionRunId ? (runMap.get(row.executionRunId) ?? null) : null,
    }));
}
export function issueService(db) {
    const instanceSettings = instanceSettingsService(db);
    async function getIssueByUuid(id) {
        const row = await db
            .select()
            .from(issues)
            .where(eq(issues.id, id))
            .then((rows) => rows[0] ?? null);
        if (!row)
            return null;
        const [enriched] = await withIssueLabels(db, [row]);
        return enriched;
    }
    async function getIssueByIdentifier(identifier) {
        const row = await db
            .select()
            .from(issues)
            .where(eq(issues.identifier, identifier.toUpperCase()))
            .then((rows) => rows[0] ?? null);
        if (!row)
            return null;
        const [enriched] = await withIssueLabels(db, [row]);
        return enriched;
    }
    function redactIssueComment(comment, censorUsernameInLogs) {
        return {
            ...comment,
            body: redactCurrentUserText(comment.body, { enabled: censorUsernameInLogs }),
        };
    }
    async function assertAssignableAgent(companyId, agentId) {
        const assignee = await db
            .select({
            id: agents.id,
            companyId: agents.companyId,
            status: agents.status,
        })
            .from(agents)
            .where(eq(agents.id, agentId))
            .then((rows) => rows[0] ?? null);
        if (!assignee)
            throw notFound("Assignee agent not found");
        if (assignee.companyId !== companyId) {
            throw unprocessable("Assignee must belong to same company");
        }
        if (assignee.status === "pending_approval") {
            throw conflict("Cannot assign work to pending approval agents");
        }
        if (assignee.status === "terminated") {
            throw conflict("Cannot assign work to terminated agents");
        }
    }
    async function assertAssignableUser(companyId, userId) {
        const membership = await db
            .select({ id: companyMemberships.id })
            .from(companyMemberships)
            .where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, userId), eq(companyMemberships.status, "active")))
            .then((rows) => rows[0] ?? null);
        if (!membership) {
            throw notFound("Assignee user not found");
        }
    }
    async function assertValidProjectWorkspace(companyId, projectId, projectWorkspaceId, dbOrTx = db) {
        const workspace = await dbOrTx
            .select({
            id: projectWorkspaces.id,
            companyId: projectWorkspaces.companyId,
            projectId: projectWorkspaces.projectId,
        })
            .from(projectWorkspaces)
            .where(eq(projectWorkspaces.id, projectWorkspaceId))
            .then((rows) => rows[0] ?? null);
        if (!workspace)
            throw notFound("Project workspace not found");
        if (workspace.companyId !== companyId)
            throw unprocessable("Project workspace must belong to same company");
        if (projectId && workspace.projectId !== projectId) {
            throw unprocessable("Project workspace must belong to the selected project");
        }
    }
    async function assertValidExecutionWorkspace(companyId, projectId, executionWorkspaceId, dbOrTx = db) {
        const workspace = await dbOrTx
            .select({
            id: executionWorkspaces.id,
            companyId: executionWorkspaces.companyId,
            projectId: executionWorkspaces.projectId,
        })
            .from(executionWorkspaces)
            .where(eq(executionWorkspaces.id, executionWorkspaceId))
            .then((rows) => rows[0] ?? null);
        if (!workspace)
            throw notFound("Execution workspace not found");
        if (workspace.companyId !== companyId)
            throw unprocessable("Execution workspace must belong to same company");
        if (projectId && workspace.projectId !== projectId) {
            throw unprocessable("Execution workspace must belong to the selected project");
        }
    }
    async function assertValidLabelIds(companyId, labelIds, dbOrTx = db) {
        if (labelIds.length === 0)
            return;
        const existing = await dbOrTx
            .select({ id: labels.id })
            .from(labels)
            .where(and(eq(labels.companyId, companyId), inArray(labels.id, labelIds)));
        if (existing.length !== new Set(labelIds).size) {
            throw unprocessable("One or more labels are invalid for this company");
        }
    }
    async function syncIssueLabels(issueId, companyId, labelIds, dbOrTx = db) {
        const deduped = [...new Set(labelIds)];
        await assertValidLabelIds(companyId, deduped, dbOrTx);
        await dbOrTx.delete(issueLabels).where(eq(issueLabels.issueId, issueId));
        if (deduped.length === 0)
            return;
        await dbOrTx.insert(issueLabels).values(deduped.map((labelId) => ({
            issueId,
            labelId,
            companyId,
        })));
    }
    async function isTerminalOrMissingHeartbeatRun(runId) {
        const run = await db
            .select({ status: heartbeatRuns.status })
            .from(heartbeatRuns)
            .where(eq(heartbeatRuns.id, runId))
            .then((rows) => rows[0] ?? null);
        if (!run)
            return true;
        return TERMINAL_HEARTBEAT_RUN_STATUSES.has(run.status);
    }
    async function adoptStaleCheckoutRun(input) {
        const stale = await isTerminalOrMissingHeartbeatRun(input.expectedCheckoutRunId);
        if (!stale)
            return null;
        const now = new Date();
        const adopted = await db
            .update(issues)
            .set({
            checkoutRunId: input.actorRunId,
            executionRunId: input.actorRunId,
            executionLockedAt: now,
            updatedAt: now,
        })
            .where(and(eq(issues.id, input.issueId), eq(issues.status, "in_progress"), eq(issues.assigneeAgentId, input.actorAgentId), eq(issues.checkoutRunId, input.expectedCheckoutRunId)))
            .returning({
            id: issues.id,
            status: issues.status,
            assigneeAgentId: issues.assigneeAgentId,
            checkoutRunId: issues.checkoutRunId,
            executionRunId: issues.executionRunId,
        })
            .then((rows) => rows[0] ?? null);
        return adopted;
    }
    return {
        list: async (companyId, filters) => {
            const conditions = [eq(issues.companyId, companyId)];
            const touchedByUserId = filters?.touchedByUserId?.trim() || undefined;
            const inboxArchivedByUserId = filters?.inboxArchivedByUserId?.trim() || undefined;
            const unreadForUserId = filters?.unreadForUserId?.trim() || undefined;
            const contextUserId = unreadForUserId ?? touchedByUserId ?? inboxArchivedByUserId;
            const rawSearch = filters?.q?.trim() ?? "";
            const hasSearch = rawSearch.length > 0;
            const escapedSearch = hasSearch ? escapeLikePattern(rawSearch) : "";
            const startsWithPattern = `${escapedSearch}%`;
            const containsPattern = `%${escapedSearch}%`;
            const titleStartsWithMatch = sql `${issues.title} ILIKE ${startsWithPattern} ESCAPE '\\'`;
            const titleContainsMatch = sql `${issues.title} ILIKE ${containsPattern} ESCAPE '\\'`;
            const identifierStartsWithMatch = sql `${issues.identifier} ILIKE ${startsWithPattern} ESCAPE '\\'`;
            const identifierContainsMatch = sql `${issues.identifier} ILIKE ${containsPattern} ESCAPE '\\'`;
            const descriptionContainsMatch = sql `${issues.description} ILIKE ${containsPattern} ESCAPE '\\'`;
            const commentContainsMatch = sql `
        EXISTS (
          SELECT 1
          FROM ${issueComments}
          WHERE ${issueComments.issueId} = ${issues.id}
            AND ${issueComments.companyId} = ${companyId}
            AND ${issueComments.body} ILIKE ${containsPattern} ESCAPE '\\'
        )
      `;
            if (filters?.status) {
                const statuses = filters.status.split(",").map((s) => s.trim());
                conditions.push(statuses.length === 1 ? eq(issues.status, statuses[0]) : inArray(issues.status, statuses));
            }
            if (filters?.assigneeAgentId) {
                conditions.push(eq(issues.assigneeAgentId, filters.assigneeAgentId));
            }
            if (filters?.participantAgentId) {
                conditions.push(participatedByAgentCondition(companyId, filters.participantAgentId));
            }
            if (filters?.assigneeUserId) {
                conditions.push(eq(issues.assigneeUserId, filters.assigneeUserId));
            }
            if (touchedByUserId) {
                conditions.push(touchedByUserCondition(companyId, touchedByUserId));
            }
            if (inboxArchivedByUserId) {
                conditions.push(inboxVisibleForUserCondition(companyId, inboxArchivedByUserId));
            }
            if (unreadForUserId) {
                conditions.push(unreadForUserCondition(companyId, unreadForUserId));
            }
            if (filters?.projectId)
                conditions.push(eq(issues.projectId, filters.projectId));
            if (filters?.executionWorkspaceId) {
                conditions.push(eq(issues.executionWorkspaceId, filters.executionWorkspaceId));
            }
            if (filters?.parentId)
                conditions.push(eq(issues.parentId, filters.parentId));
            if (filters?.originKind)
                conditions.push(eq(issues.originKind, filters.originKind));
            if (filters?.originId)
                conditions.push(eq(issues.originId, filters.originId));
            if (filters?.labelId) {
                const labeledIssueIds = await db
                    .select({ issueId: issueLabels.issueId })
                    .from(issueLabels)
                    .where(and(eq(issueLabels.companyId, companyId), eq(issueLabels.labelId, filters.labelId)));
                if (labeledIssueIds.length === 0)
                    return [];
                conditions.push(inArray(issues.id, labeledIssueIds.map((row) => row.issueId)));
            }
            if (hasSearch) {
                conditions.push(or(titleContainsMatch, identifierContainsMatch, descriptionContainsMatch, commentContainsMatch));
            }
            if (!filters?.includeRoutineExecutions && !filters?.originKind && !filters?.originId) {
                conditions.push(ne(issues.originKind, "routine_execution"));
            }
            conditions.push(isNull(issues.hiddenAt));
            const priorityOrder = sql `CASE ${issues.priority} WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END`;
            const searchOrder = sql `
        CASE
          WHEN ${titleStartsWithMatch} THEN 0
          WHEN ${titleContainsMatch} THEN 1
          WHEN ${identifierStartsWithMatch} THEN 2
          WHEN ${identifierContainsMatch} THEN 3
          WHEN ${descriptionContainsMatch} THEN 4
          WHEN ${commentContainsMatch} THEN 5
          ELSE 6
        END
      `;
            const canonicalLastActivityAt = issueCanonicalLastActivityAtExpr(companyId);
            const rows = await db
                .select()
                .from(issues)
                .where(and(...conditions))
                .orderBy(hasSearch ? asc(searchOrder) : asc(priorityOrder), asc(priorityOrder), desc(canonicalLastActivityAt), desc(issues.updatedAt));
            const withLabels = await withIssueLabels(db, rows);
            const runMap = await activeRunMapForIssues(db, withLabels);
            const withRuns = withActiveRuns(withLabels, runMap);
            if (withRuns.length === 0) {
                return withRuns;
            }
            const issueIds = withRuns.map((row) => row.id);
            const [statsRows, readRows, lastActivityRows] = await Promise.all([
                contextUserId
                    ? db
                        .select({
                        issueId: issueComments.issueId,
                        myLastCommentAt: sql `
                MAX(CASE WHEN ${issueComments.authorUserId} = ${contextUserId} THEN ${issueComments.createdAt} END)
              `,
                        lastExternalCommentAt: sql `
                MAX(
                  CASE
                    WHEN ${issueComments.authorUserId} IS NULL OR ${issueComments.authorUserId} <> ${contextUserId}
                    THEN ${issueComments.createdAt}
                  END
                )
              `,
                    })
                        .from(issueComments)
                        .where(and(eq(issueComments.companyId, companyId), inArray(issueComments.issueId, issueIds)))
                        .groupBy(issueComments.issueId)
                    : Promise.resolve([]),
                contextUserId
                    ? db
                        .select({
                        issueId: issueReadStates.issueId,
                        myLastReadAt: issueReadStates.lastReadAt,
                    })
                        .from(issueReadStates)
                        .where(and(eq(issueReadStates.companyId, companyId), eq(issueReadStates.userId, contextUserId), inArray(issueReadStates.issueId, issueIds)))
                    : Promise.resolve([]),
                Promise.all([
                    db
                        .select({
                        issueId: issueComments.issueId,
                        latestCommentAt: sql `MAX(${issueComments.createdAt})`,
                    })
                        .from(issueComments)
                        .where(and(eq(issueComments.companyId, companyId), inArray(issueComments.issueId, issueIds)))
                        .groupBy(issueComments.issueId),
                    db
                        .select({
                        issueId: activityLog.entityId,
                        latestLogAt: sql `MAX(${activityLog.createdAt})`,
                    })
                        .from(activityLog)
                        .where(and(eq(activityLog.companyId, companyId), eq(activityLog.entityType, "issue"), inArray(activityLog.entityId, issueIds), sql `${activityLog.action} NOT IN (${sql.join(ISSUE_LOCAL_INBOX_ACTIVITY_ACTIONS.map((action) => sql `${action}`), sql `, `)})`))
                        .groupBy(activityLog.entityId),
                ]).then(([commentRows, logRows]) => {
                    const byIssueId = new Map();
                    for (const row of commentRows) {
                        byIssueId.set(row.issueId, {
                            issueId: row.issueId,
                            latestCommentAt: row.latestCommentAt,
                            latestLogAt: null,
                        });
                    }
                    for (const row of logRows) {
                        const existing = byIssueId.get(row.issueId);
                        if (existing)
                            existing.latestLogAt = row.latestLogAt;
                        else {
                            byIssueId.set(row.issueId, {
                                issueId: row.issueId,
                                latestCommentAt: null,
                                latestLogAt: row.latestLogAt,
                            });
                        }
                    }
                    return [...byIssueId.values()];
                }),
            ]);
            const statsByIssueId = new Map(statsRows.map((row) => [row.issueId, row]));
            const lastActivityByIssueId = new Map(lastActivityRows.map((row) => [row.issueId, row]));
            if (!contextUserId) {
                return withRuns.map((row) => {
                    const activity = lastActivityByIssueId.get(row.id);
                    const lastActivityAt = latestIssueActivityAt(row.updatedAt, activity?.latestCommentAt ?? null, activity?.latestLogAt ?? null) ?? row.updatedAt;
                    return {
                        ...row,
                        lastActivityAt,
                    };
                });
            }
            const readByIssueId = new Map(readRows.map((row) => [row.issueId, row.myLastReadAt]));
            return withRuns.map((row) => {
                const activity = lastActivityByIssueId.get(row.id);
                const lastActivityAt = latestIssueActivityAt(row.updatedAt, activity?.latestCommentAt ?? null, activity?.latestLogAt ?? null) ?? row.updatedAt;
                return {
                    ...row,
                    lastActivityAt,
                    ...deriveIssueUserContext(row, contextUserId, {
                        myLastCommentAt: statsByIssueId.get(row.id)?.myLastCommentAt ?? null,
                        myLastReadAt: readByIssueId.get(row.id) ?? null,
                        lastExternalCommentAt: statsByIssueId.get(row.id)?.lastExternalCommentAt ?? null,
                    }),
                };
            });
        },
        countUnreadTouchedByUser: async (companyId, userId, status) => {
            const conditions = [
                eq(issues.companyId, companyId),
                isNull(issues.hiddenAt),
                unreadForUserCondition(companyId, userId),
                ne(issues.originKind, "routine_execution"),
            ];
            if (status) {
                const statuses = status.split(",").map((s) => s.trim()).filter(Boolean);
                if (statuses.length === 1) {
                    conditions.push(eq(issues.status, statuses[0]));
                }
                else if (statuses.length > 1) {
                    conditions.push(inArray(issues.status, statuses));
                }
            }
            const [row] = await db
                .select({ count: sql `count(*)` })
                .from(issues)
                .where(and(...conditions));
            return Number(row?.count ?? 0);
        },
        markRead: async (companyId, issueId, userId, readAt = new Date()) => {
            const now = new Date();
            const [row] = await db
                .insert(issueReadStates)
                .values({
                companyId,
                issueId,
                userId,
                lastReadAt: readAt,
                updatedAt: now,
            })
                .onConflictDoUpdate({
                target: [issueReadStates.companyId, issueReadStates.issueId, issueReadStates.userId],
                set: {
                    lastReadAt: readAt,
                    updatedAt: now,
                },
            })
                .returning();
            return row;
        },
        markUnread: async (companyId, issueId, userId) => {
            const deleted = await db
                .delete(issueReadStates)
                .where(and(eq(issueReadStates.companyId, companyId), eq(issueReadStates.issueId, issueId), eq(issueReadStates.userId, userId)))
                .returning();
            return deleted.length > 0;
        },
        archiveInbox: async (companyId, issueId, userId, archivedAt = new Date()) => {
            const now = new Date();
            const [row] = await db
                .insert(issueInboxArchives)
                .values({
                companyId,
                issueId,
                userId,
                archivedAt,
                updatedAt: now,
            })
                .onConflictDoUpdate({
                target: [issueInboxArchives.companyId, issueInboxArchives.issueId, issueInboxArchives.userId],
                set: {
                    archivedAt,
                    updatedAt: now,
                },
            })
                .returning();
            return row;
        },
        unarchiveInbox: async (companyId, issueId, userId) => {
            const [row] = await db
                .delete(issueInboxArchives)
                .where(and(eq(issueInboxArchives.companyId, companyId), eq(issueInboxArchives.issueId, issueId), eq(issueInboxArchives.userId, userId)))
                .returning();
            return row ?? null;
        },
        getById: async (raw) => {
            const id = raw.trim();
            if (/^[A-Z]+-\d+$/i.test(id)) {
                return getIssueByIdentifier(id);
            }
            if (!isUuidLike(id)) {
                return null;
            }
            return getIssueByUuid(id);
        },
        getByIdentifier: async (identifier) => {
            return getIssueByIdentifier(identifier);
        },
        create: async (companyId, data) => {
            const { labelIds: inputLabelIds, inheritExecutionWorkspaceFromIssueId, ...issueData } = data;
            const isolatedWorkspacesEnabled = (await instanceSettings.getExperimental()).enableIsolatedWorkspaces;
            if (!isolatedWorkspacesEnabled) {
                delete issueData.executionWorkspaceId;
                delete issueData.executionWorkspacePreference;
                delete issueData.executionWorkspaceSettings;
            }
            if (data.assigneeAgentId && data.assigneeUserId) {
                throw unprocessable("Issue can only have one assignee");
            }
            if (data.assigneeAgentId) {
                await assertAssignableAgent(companyId, data.assigneeAgentId);
            }
            if (data.assigneeUserId) {
                await assertAssignableUser(companyId, data.assigneeUserId);
            }
            if (data.status === "in_progress" && !data.assigneeAgentId && !data.assigneeUserId) {
                throw unprocessable("in_progress issues require an assignee");
            }
            return db.transaction(async (tx) => {
                const defaultCompanyGoal = await getDefaultCompanyGoal(tx, companyId);
                const projectGoalId = await getProjectDefaultGoalId(tx, companyId, issueData.projectId);
                let projectWorkspaceId = issueData.projectWorkspaceId ?? null;
                let executionWorkspaceId = issueData.executionWorkspaceId ?? null;
                let executionWorkspacePreference = issueData.executionWorkspacePreference ?? null;
                let executionWorkspaceSettings = issueData.executionWorkspaceSettings ?? null;
                const workspaceInheritanceIssueId = inheritExecutionWorkspaceFromIssueId ?? issueData.parentId ?? null;
                const hasExplicitExecutionWorkspaceOverride = issueData.executionWorkspaceId !== undefined ||
                    issueData.executionWorkspacePreference !== undefined ||
                    issueData.executionWorkspaceSettings !== undefined;
                if (workspaceInheritanceIssueId) {
                    const workspaceSource = await getWorkspaceInheritanceIssue(tx, companyId, workspaceInheritanceIssueId);
                    if (projectWorkspaceId == null && workspaceSource.projectWorkspaceId) {
                        projectWorkspaceId = workspaceSource.projectWorkspaceId;
                    }
                    if (isolatedWorkspacesEnabled &&
                        !hasExplicitExecutionWorkspaceOverride &&
                        workspaceSource.executionWorkspaceId) {
                        const sourceWorkspace = await tx
                            .select({
                            id: executionWorkspaces.id,
                            mode: executionWorkspaces.mode,
                        })
                            .from(executionWorkspaces)
                            .where(eq(executionWorkspaces.id, workspaceSource.executionWorkspaceId))
                            .then((rows) => rows[0] ?? null);
                        if (sourceWorkspace) {
                            executionWorkspaceId = sourceWorkspace.id;
                            executionWorkspacePreference = "reuse_existing";
                            executionWorkspaceSettings = {
                                ...(workspaceSource.executionWorkspaceSettings ?? {}),
                                mode: issueExecutionWorkspaceModeForPersistedWorkspace(sourceWorkspace.mode),
                            };
                        }
                    }
                }
                if (executionWorkspaceSettings == null &&
                    executionWorkspaceId == null &&
                    issueData.projectId) {
                    const project = await tx
                        .select({ executionWorkspacePolicy: projects.executionWorkspacePolicy })
                        .from(projects)
                        .where(and(eq(projects.id, issueData.projectId), eq(projects.companyId, companyId)))
                        .then((rows) => rows[0] ?? null);
                    executionWorkspaceSettings =
                        defaultIssueExecutionWorkspaceSettingsForProject(gateProjectExecutionWorkspacePolicy(parseProjectExecutionWorkspacePolicy(project?.executionWorkspacePolicy), isolatedWorkspacesEnabled));
                }
                if (!projectWorkspaceId && issueData.projectId) {
                    const project = await tx
                        .select({
                        executionWorkspacePolicy: projects.executionWorkspacePolicy,
                    })
                        .from(projects)
                        .where(and(eq(projects.id, issueData.projectId), eq(projects.companyId, companyId)))
                        .then((rows) => rows[0] ?? null);
                    const projectPolicy = parseProjectExecutionWorkspacePolicy(project?.executionWorkspacePolicy);
                    projectWorkspaceId = projectPolicy?.defaultProjectWorkspaceId ?? null;
                    if (!projectWorkspaceId) {
                        projectWorkspaceId = await tx
                            .select({ id: projectWorkspaces.id })
                            .from(projectWorkspaces)
                            .where(and(eq(projectWorkspaces.projectId, issueData.projectId), eq(projectWorkspaces.companyId, companyId)))
                            .orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id))
                            .then((rows) => rows[0]?.id ?? null);
                    }
                }
                if (projectWorkspaceId) {
                    await assertValidProjectWorkspace(companyId, issueData.projectId, projectWorkspaceId, tx);
                }
                if (executionWorkspaceId) {
                    await assertValidExecutionWorkspace(companyId, issueData.projectId, executionWorkspaceId, tx);
                }
                const [company] = await tx
                    .update(companies)
                    .set({ issueCounter: sql `${companies.issueCounter} + 1` })
                    .where(eq(companies.id, companyId))
                    .returning({ issueCounter: companies.issueCounter, issuePrefix: companies.issuePrefix });
                const issueNumber = company.issueCounter;
                const identifier = `${company.issuePrefix}-${issueNumber}`;
                const values = {
                    ...issueData,
                    originKind: issueData.originKind ?? "manual",
                    goalId: resolveIssueGoalId({
                        projectId: issueData.projectId,
                        goalId: issueData.goalId,
                        projectGoalId,
                        defaultGoalId: defaultCompanyGoal?.id ?? null,
                    }),
                    ...(projectWorkspaceId ? { projectWorkspaceId } : {}),
                    ...(executionWorkspaceId ? { executionWorkspaceId } : {}),
                    ...(executionWorkspacePreference ? { executionWorkspacePreference } : {}),
                    ...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}),
                    companyId,
                    issueNumber,
                    identifier,
                };
                if (values.status === "in_progress" && !values.startedAt) {
                    values.startedAt = new Date();
                }
                if (values.status === "done") {
                    values.completedAt = new Date();
                }
                if (values.status === "cancelled") {
                    values.cancelledAt = new Date();
                }
                const [issue] = await tx.insert(issues).values(values).returning();
                if (inputLabelIds) {
                    await syncIssueLabels(issue.id, companyId, inputLabelIds, tx);
                }
                const [enriched] = await withIssueLabels(tx, [issue]);
                return enriched;
            });
        },
        update: async (id, data) => {
            const existing = await db
                .select()
                .from(issues)
                .where(eq(issues.id, id))
                .then((rows) => rows[0] ?? null);
            if (!existing)
                return null;
            const { labelIds: nextLabelIds, ...issueData } = data;
            const isolatedWorkspacesEnabled = (await instanceSettings.getExperimental()).enableIsolatedWorkspaces;
            if (!isolatedWorkspacesEnabled) {
                delete issueData.executionWorkspaceId;
                delete issueData.executionWorkspacePreference;
                delete issueData.executionWorkspaceSettings;
            }
            if (issueData.status) {
                assertTransition(existing.status, issueData.status);
            }
            const patch = {
                ...issueData,
                updatedAt: new Date(),
            };
            const nextAssigneeAgentId = issueData.assigneeAgentId !== undefined ? issueData.assigneeAgentId : existing.assigneeAgentId;
            const nextAssigneeUserId = issueData.assigneeUserId !== undefined ? issueData.assigneeUserId : existing.assigneeUserId;
            if (nextAssigneeAgentId && nextAssigneeUserId) {
                throw unprocessable("Issue can only have one assignee");
            }
            if (patch.status === "in_progress" && !nextAssigneeAgentId && !nextAssigneeUserId) {
                throw unprocessable("in_progress issues require an assignee");
            }
            if (issueData.assigneeAgentId) {
                await assertAssignableAgent(existing.companyId, issueData.assigneeAgentId);
            }
            if (issueData.assigneeUserId) {
                await assertAssignableUser(existing.companyId, issueData.assigneeUserId);
            }
            const nextProjectId = issueData.projectId !== undefined ? issueData.projectId : existing.projectId;
            const nextProjectWorkspaceId = issueData.projectWorkspaceId !== undefined ? issueData.projectWorkspaceId : existing.projectWorkspaceId;
            const nextExecutionWorkspaceId = issueData.executionWorkspaceId !== undefined ? issueData.executionWorkspaceId : existing.executionWorkspaceId;
            if (nextProjectWorkspaceId) {
                await assertValidProjectWorkspace(existing.companyId, nextProjectId, nextProjectWorkspaceId);
            }
            if (nextExecutionWorkspaceId) {
                await assertValidExecutionWorkspace(existing.companyId, nextProjectId, nextExecutionWorkspaceId);
            }
            applyStatusSideEffects(issueData.status, patch);
            if (issueData.status && issueData.status !== "done") {
                patch.completedAt = null;
            }
            if (issueData.status && issueData.status !== "cancelled") {
                patch.cancelledAt = null;
            }
            if (issueData.status && issueData.status !== "in_progress") {
                patch.checkoutRunId = null;
            }
            if ((issueData.assigneeAgentId !== undefined && issueData.assigneeAgentId !== existing.assigneeAgentId) ||
                (issueData.assigneeUserId !== undefined && issueData.assigneeUserId !== existing.assigneeUserId)) {
                patch.checkoutRunId = null;
            }
            return db.transaction(async (tx) => {
                const defaultCompanyGoal = await getDefaultCompanyGoal(tx, existing.companyId);
                const [currentProjectGoalId, nextProjectGoalId] = await Promise.all([
                    getProjectDefaultGoalId(tx, existing.companyId, existing.projectId),
                    getProjectDefaultGoalId(tx, existing.companyId, issueData.projectId !== undefined ? issueData.projectId : existing.projectId),
                ]);
                patch.goalId = resolveNextIssueGoalId({
                    currentProjectId: existing.projectId,
                    currentGoalId: existing.goalId,
                    currentProjectGoalId,
                    projectId: issueData.projectId,
                    goalId: issueData.goalId,
                    projectGoalId: nextProjectGoalId,
                    defaultGoalId: defaultCompanyGoal?.id ?? null,
                });
                const updated = await tx
                    .update(issues)
                    .set(patch)
                    .where(eq(issues.id, id))
                    .returning()
                    .then((rows) => rows[0] ?? null);
                if (!updated)
                    return null;
                if (nextLabelIds !== undefined) {
                    await syncIssueLabels(updated.id, existing.companyId, nextLabelIds, tx);
                }
                const [enriched] = await withIssueLabels(tx, [updated]);
                return enriched;
            });
        },
        remove: (id) => db.transaction(async (tx) => {
            const attachmentAssetIds = await tx
                .select({ assetId: issueAttachments.assetId })
                .from(issueAttachments)
                .where(eq(issueAttachments.issueId, id));
            const issueDocumentIds = await tx
                .select({ documentId: issueDocuments.documentId })
                .from(issueDocuments)
                .where(eq(issueDocuments.issueId, id));
            const removedIssue = await tx
                .delete(issues)
                .where(eq(issues.id, id))
                .returning()
                .then((rows) => rows[0] ?? null);
            if (removedIssue && attachmentAssetIds.length > 0) {
                await tx
                    .delete(assets)
                    .where(inArray(assets.id, attachmentAssetIds.map((row) => row.assetId)));
            }
            if (removedIssue && issueDocumentIds.length > 0) {
                await tx
                    .delete(documents)
                    .where(inArray(documents.id, issueDocumentIds.map((row) => row.documentId)));
            }
            if (!removedIssue)
                return null;
            const [enriched] = await withIssueLabels(tx, [removedIssue]);
            return enriched;
        }),
        checkout: async (id, agentId, expectedStatuses, checkoutRunId) => {
            const issueCompany = await db
                .select({ companyId: issues.companyId })
                .from(issues)
                .where(eq(issues.id, id))
                .then((rows) => rows[0] ?? null);
            if (!issueCompany)
                throw notFound("Issue not found");
            await assertAssignableAgent(issueCompany.companyId, agentId);
            const now = new Date();
            const sameRunAssigneeCondition = checkoutRunId
                ? and(eq(issues.assigneeAgentId, agentId), or(isNull(issues.checkoutRunId), eq(issues.checkoutRunId, checkoutRunId)))
                : and(eq(issues.assigneeAgentId, agentId), isNull(issues.checkoutRunId));
            const executionLockCondition = checkoutRunId
                ? or(isNull(issues.executionRunId), eq(issues.executionRunId, checkoutRunId))
                : isNull(issues.executionRunId);
            const updated = await db
                .update(issues)
                .set({
                assigneeAgentId: agentId,
                assigneeUserId: null,
                checkoutRunId,
                executionRunId: checkoutRunId,
                status: "in_progress",
                startedAt: now,
                updatedAt: now,
            })
                .where(and(eq(issues.id, id), inArray(issues.status, expectedStatuses), or(isNull(issues.assigneeAgentId), sameRunAssigneeCondition), executionLockCondition))
                .returning()
                .then((rows) => rows[0] ?? null);
            if (updated) {
                const [enriched] = await withIssueLabels(db, [updated]);
                return enriched;
            }
            const current = await db
                .select({
                id: issues.id,
                status: issues.status,
                assigneeAgentId: issues.assigneeAgentId,
                checkoutRunId: issues.checkoutRunId,
                executionRunId: issues.executionRunId,
            })
                .from(issues)
                .where(eq(issues.id, id))
                .then((rows) => rows[0] ?? null);
            if (!current)
                throw notFound("Issue not found");
            if (current.assigneeAgentId === agentId &&
                current.status === "in_progress" &&
                current.checkoutRunId == null &&
                (current.executionRunId == null || current.executionRunId === checkoutRunId) &&
                checkoutRunId) {
                const adopted = await db
                    .update(issues)
                    .set({
                    checkoutRunId,
                    executionRunId: checkoutRunId,
                    updatedAt: new Date(),
                })
                    .where(and(eq(issues.id, id), eq(issues.status, "in_progress"), eq(issues.assigneeAgentId, agentId), isNull(issues.checkoutRunId), or(isNull(issues.executionRunId), eq(issues.executionRunId, checkoutRunId))))
                    .returning()
                    .then((rows) => rows[0] ?? null);
                if (adopted)
                    return adopted;
            }
            if (checkoutRunId &&
                current.assigneeAgentId === agentId &&
                current.status === "in_progress" &&
                current.checkoutRunId &&
                current.checkoutRunId !== checkoutRunId) {
                const adopted = await adoptStaleCheckoutRun({
                    issueId: id,
                    actorAgentId: agentId,
                    actorRunId: checkoutRunId,
                    expectedCheckoutRunId: current.checkoutRunId,
                });
                if (adopted) {
                    const row = await db.select().from(issues).where(eq(issues.id, id)).then((rows) => rows[0]);
                    const [enriched] = await withIssueLabels(db, [row]);
                    return enriched;
                }
            }
            // If this run already owns it and it's in_progress, return it (no self-409)
            if (current.assigneeAgentId === agentId &&
                current.status === "in_progress" &&
                sameRunLock(current.checkoutRunId, checkoutRunId)) {
                const row = await db.select().from(issues).where(eq(issues.id, id)).then((rows) => rows[0]);
                const [enriched] = await withIssueLabels(db, [row]);
                return enriched;
            }
            throw conflict("Issue checkout conflict", {
                issueId: current.id,
                status: current.status,
                assigneeAgentId: current.assigneeAgentId,
                checkoutRunId: current.checkoutRunId,
                executionRunId: current.executionRunId,
            });
        },
        assertCheckoutOwner: async (id, actorAgentId, actorRunId) => {
            const current = await db
                .select({
                id: issues.id,
                status: issues.status,
                assigneeAgentId: issues.assigneeAgentId,
                checkoutRunId: issues.checkoutRunId,
            })
                .from(issues)
                .where(eq(issues.id, id))
                .then((rows) => rows[0] ?? null);
            if (!current)
                throw notFound("Issue not found");
            if (current.status === "in_progress" &&
                current.assigneeAgentId === actorAgentId &&
                sameRunLock(current.checkoutRunId, actorRunId)) {
                return { ...current, adoptedFromRunId: null };
            }
            if (actorRunId &&
                current.status === "in_progress" &&
                current.assigneeAgentId === actorAgentId &&
                current.checkoutRunId &&
                current.checkoutRunId !== actorRunId) {
                const adopted = await adoptStaleCheckoutRun({
                    issueId: id,
                    actorAgentId,
                    actorRunId,
                    expectedCheckoutRunId: current.checkoutRunId,
                });
                if (adopted) {
                    return {
                        ...adopted,
                        adoptedFromRunId: current.checkoutRunId,
                    };
                }
            }
            throw conflict("Issue run ownership conflict", {
                issueId: current.id,
                status: current.status,
                assigneeAgentId: current.assigneeAgentId,
                checkoutRunId: current.checkoutRunId,
                actorAgentId,
                actorRunId,
            });
        },
        release: async (id, actorAgentId, actorRunId) => {
            const existing = await db
                .select()
                .from(issues)
                .where(eq(issues.id, id))
                .then((rows) => rows[0] ?? null);
            if (!existing)
                return null;
            if (actorAgentId && existing.assigneeAgentId && existing.assigneeAgentId !== actorAgentId) {
                throw conflict("Only assignee can release issue");
            }
            if (actorAgentId &&
                existing.status === "in_progress" &&
                existing.assigneeAgentId === actorAgentId &&
                existing.checkoutRunId &&
                !sameRunLock(existing.checkoutRunId, actorRunId ?? null)) {
                throw conflict("Only checkout run can release issue", {
                    issueId: existing.id,
                    assigneeAgentId: existing.assigneeAgentId,
                    checkoutRunId: existing.checkoutRunId,
                    actorRunId: actorRunId ?? null,
                });
            }
            const updated = await db
                .update(issues)
                .set({
                status: "todo",
                assigneeAgentId: null,
                checkoutRunId: null,
                updatedAt: new Date(),
            })
                .where(eq(issues.id, id))
                .returning()
                .then((rows) => rows[0] ?? null);
            if (!updated)
                return null;
            const [enriched] = await withIssueLabels(db, [updated]);
            return enriched;
        },
        listLabels: (companyId) => db.select().from(labels).where(eq(labels.companyId, companyId)).orderBy(asc(labels.name), asc(labels.id)),
        getLabelById: (id) => db
            .select()
            .from(labels)
            .where(eq(labels.id, id))
            .then((rows) => rows[0] ?? null),
        createLabel: async (companyId, data) => {
            const [created] = await db
                .insert(labels)
                .values({
                companyId,
                name: data.name.trim(),
                color: data.color,
            })
                .returning();
            return created;
        },
        deleteLabel: async (id) => db
            .delete(labels)
            .where(eq(labels.id, id))
            .returning()
            .then((rows) => rows[0] ?? null),
        listComments: async (issueId, opts) => {
            const order = opts?.order === "asc" ? "asc" : "desc";
            const afterCommentId = opts?.afterCommentId?.trim() || null;
            const limit = opts?.limit && opts.limit > 0
                ? Math.min(Math.floor(opts.limit), MAX_ISSUE_COMMENT_PAGE_LIMIT)
                : null;
            const conditions = [eq(issueComments.issueId, issueId)];
            if (afterCommentId) {
                const anchor = await db
                    .select({
                    id: issueComments.id,
                    createdAt: issueComments.createdAt,
                })
                    .from(issueComments)
                    .where(and(eq(issueComments.issueId, issueId), eq(issueComments.id, afterCommentId)))
                    .then((rows) => rows[0] ?? null);
                if (!anchor)
                    return [];
                conditions.push(order === "asc"
                    ? sql `(
                ${issueComments.createdAt} > ${anchor.createdAt}
                OR (${issueComments.createdAt} = ${anchor.createdAt} AND ${issueComments.id} > ${anchor.id})
              )`
                    : sql `(
                ${issueComments.createdAt} < ${anchor.createdAt}
                OR (${issueComments.createdAt} = ${anchor.createdAt} AND ${issueComments.id} < ${anchor.id})
              )`);
            }
            const query = db
                .select()
                .from(issueComments)
                .where(and(...conditions))
                .orderBy(order === "asc" ? asc(issueComments.createdAt) : desc(issueComments.createdAt), order === "asc" ? asc(issueComments.id) : desc(issueComments.id));
            const comments = limit ? await query.limit(limit) : await query;
            const { censorUsernameInLogs } = await instanceSettings.getGeneral();
            return comments.map((comment) => redactIssueComment(comment, censorUsernameInLogs));
        },
        getCommentCursor: async (issueId) => {
            const [latest, countRow] = await Promise.all([
                db
                    .select({
                    latestCommentId: issueComments.id,
                    latestCommentAt: issueComments.createdAt,
                })
                    .from(issueComments)
                    .where(eq(issueComments.issueId, issueId))
                    .orderBy(desc(issueComments.createdAt), desc(issueComments.id))
                    .limit(1)
                    .then((rows) => rows[0] ?? null),
                db
                    .select({
                    totalComments: sql `count(*)::int`,
                })
                    .from(issueComments)
                    .where(eq(issueComments.issueId, issueId))
                    .then((rows) => rows[0] ?? null),
            ]);
            return {
                totalComments: Number(countRow?.totalComments ?? 0),
                latestCommentId: latest?.latestCommentId ?? null,
                latestCommentAt: latest?.latestCommentAt ?? null,
            };
        },
        getComment: (commentId) => instanceSettings.getGeneral().then(({ censorUsernameInLogs }) => db
            .select()
            .from(issueComments)
            .where(eq(issueComments.id, commentId))
            .then((rows) => {
            const comment = rows[0] ?? null;
            return comment ? redactIssueComment(comment, censorUsernameInLogs) : null;
        })),
        addComment: async (issueId, body, actor) => {
            const issue = await db
                .select({ companyId: issues.companyId })
                .from(issues)
                .where(eq(issues.id, issueId))
                .then((rows) => rows[0] ?? null);
            if (!issue)
                throw notFound("Issue not found");
            const currentUserRedactionOptions = {
                enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
            };
            const redactedBody = redactCurrentUserText(body, currentUserRedactionOptions);
            const [comment] = await db
                .insert(issueComments)
                .values({
                companyId: issue.companyId,
                issueId,
                authorAgentId: actor.agentId ?? null,
                authorUserId: actor.userId ?? null,
                createdByRunId: actor.runId ?? null,
                body: redactedBody,
            })
                .returning();
            // Update issue's updatedAt so comment activity is reflected in recency sorting
            await db
                .update(issues)
                .set({ updatedAt: new Date() })
                .where(eq(issues.id, issueId));
            return redactIssueComment(comment, currentUserRedactionOptions.enabled);
        },
        createAttachment: async (input) => {
            const issue = await db
                .select({ id: issues.id, companyId: issues.companyId })
                .from(issues)
                .where(eq(issues.id, input.issueId))
                .then((rows) => rows[0] ?? null);
            if (!issue)
                throw notFound("Issue not found");
            if (input.issueCommentId) {
                const comment = await db
                    .select({ id: issueComments.id, companyId: issueComments.companyId, issueId: issueComments.issueId })
                    .from(issueComments)
                    .where(eq(issueComments.id, input.issueCommentId))
                    .then((rows) => rows[0] ?? null);
                if (!comment)
                    throw notFound("Issue comment not found");
                if (comment.companyId !== issue.companyId || comment.issueId !== issue.id) {
                    throw unprocessable("Attachment comment must belong to same issue and company");
                }
            }
            return db.transaction(async (tx) => {
                const [asset] = await tx
                    .insert(assets)
                    .values({
                    companyId: issue.companyId,
                    provider: input.provider,
                    objectKey: input.objectKey,
                    contentType: input.contentType,
                    byteSize: input.byteSize,
                    sha256: input.sha256,
                    originalFilename: input.originalFilename ?? null,
                    createdByAgentId: input.createdByAgentId ?? null,
                    createdByUserId: input.createdByUserId ?? null,
                })
                    .returning();
                const [attachment] = await tx
                    .insert(issueAttachments)
                    .values({
                    companyId: issue.companyId,
                    issueId: issue.id,
                    assetId: asset.id,
                    issueCommentId: input.issueCommentId ?? null,
                })
                    .returning();
                return {
                    id: attachment.id,
                    companyId: attachment.companyId,
                    issueId: attachment.issueId,
                    issueCommentId: attachment.issueCommentId,
                    assetId: attachment.assetId,
                    provider: asset.provider,
                    objectKey: asset.objectKey,
                    contentType: asset.contentType,
                    byteSize: asset.byteSize,
                    sha256: asset.sha256,
                    originalFilename: asset.originalFilename,
                    createdByAgentId: asset.createdByAgentId,
                    createdByUserId: asset.createdByUserId,
                    createdAt: attachment.createdAt,
                    updatedAt: attachment.updatedAt,
                };
            });
        },
        listAttachments: async (issueId) => db
            .select({
            id: issueAttachments.id,
            companyId: issueAttachments.companyId,
            issueId: issueAttachments.issueId,
            issueCommentId: issueAttachments.issueCommentId,
            assetId: issueAttachments.assetId,
            provider: assets.provider,
            objectKey: assets.objectKey,
            contentType: assets.contentType,
            byteSize: assets.byteSize,
            sha256: assets.sha256,
            originalFilename: assets.originalFilename,
            createdByAgentId: assets.createdByAgentId,
            createdByUserId: assets.createdByUserId,
            createdAt: issueAttachments.createdAt,
            updatedAt: issueAttachments.updatedAt,
        })
            .from(issueAttachments)
            .innerJoin(assets, eq(issueAttachments.assetId, assets.id))
            .where(eq(issueAttachments.issueId, issueId))
            .orderBy(desc(issueAttachments.createdAt)),
        getAttachmentById: async (id) => db
            .select({
            id: issueAttachments.id,
            companyId: issueAttachments.companyId,
            issueId: issueAttachments.issueId,
            issueCommentId: issueAttachments.issueCommentId,
            assetId: issueAttachments.assetId,
            provider: assets.provider,
            objectKey: assets.objectKey,
            contentType: assets.contentType,
            byteSize: assets.byteSize,
            sha256: assets.sha256,
            originalFilename: assets.originalFilename,
            createdByAgentId: assets.createdByAgentId,
            createdByUserId: assets.createdByUserId,
            createdAt: issueAttachments.createdAt,
            updatedAt: issueAttachments.updatedAt,
        })
            .from(issueAttachments)
            .innerJoin(assets, eq(issueAttachments.assetId, assets.id))
            .where(eq(issueAttachments.id, id))
            .then((rows) => rows[0] ?? null),
        removeAttachment: async (id) => db.transaction(async (tx) => {
            const existing = await tx
                .select({
                id: issueAttachments.id,
                companyId: issueAttachments.companyId,
                issueId: issueAttachments.issueId,
                issueCommentId: issueAttachments.issueCommentId,
                assetId: issueAttachments.assetId,
                provider: assets.provider,
                objectKey: assets.objectKey,
                contentType: assets.contentType,
                byteSize: assets.byteSize,
                sha256: assets.sha256,
                originalFilename: assets.originalFilename,
                createdByAgentId: assets.createdByAgentId,
                createdByUserId: assets.createdByUserId,
                createdAt: issueAttachments.createdAt,
                updatedAt: issueAttachments.updatedAt,
            })
                .from(issueAttachments)
                .innerJoin(assets, eq(issueAttachments.assetId, assets.id))
                .where(eq(issueAttachments.id, id))
                .then((rows) => rows[0] ?? null);
            if (!existing)
                return null;
            await tx.delete(issueAttachments).where(eq(issueAttachments.id, id));
            await tx.delete(assets).where(eq(assets.id, existing.assetId));
            return existing;
        }),
        findMentionedAgents: async (companyId, body) => {
            const re = /\B@([^\s@,!?.]+)/g;
            const tokens = new Set();
            let m;
            while ((m = re.exec(body)) !== null) {
                const normalized = normalizeAgentMentionToken(m[1]);
                if (normalized)
                    tokens.add(normalized.toLowerCase());
            }
            const explicitAgentMentionIds = extractAgentMentionIds(body);
            if (tokens.size === 0 && explicitAgentMentionIds.length === 0)
                return [];
            const rows = await db.select({ id: agents.id, name: agents.name })
                .from(agents).where(eq(agents.companyId, companyId));
            const resolved = new Set(explicitAgentMentionIds);
            for (const agent of rows) {
                if (tokens.has(agent.name.toLowerCase())) {
                    resolved.add(agent.id);
                }
            }
            return [...resolved];
        },
        findMentionedProjectIds: async (issueId) => {
            const issue = await db
                .select({
                companyId: issues.companyId,
                title: issues.title,
                description: issues.description,
            })
                .from(issues)
                .where(eq(issues.id, issueId))
                .then((rows) => rows[0] ?? null);
            if (!issue)
                return [];
            const comments = await db
                .select({ body: issueComments.body })
                .from(issueComments)
                .where(eq(issueComments.issueId, issueId));
            const mentionedIds = new Set();
            for (const source of [
                issue.title,
                issue.description ?? "",
                ...comments.map((comment) => comment.body),
            ]) {
                for (const projectId of extractProjectMentionIds(source)) {
                    mentionedIds.add(projectId);
                }
            }
            if (mentionedIds.size === 0)
                return [];
            const rows = await db
                .select({ id: projects.id })
                .from(projects)
                .where(and(eq(projects.companyId, issue.companyId), inArray(projects.id, [...mentionedIds])));
            const valid = new Set(rows.map((row) => row.id));
            return [...mentionedIds].filter((projectId) => valid.has(projectId));
        },
        getAncestors: async (issueId) => {
            const raw = [];
            const visited = new Set([issueId]);
            const start = await db.select().from(issues).where(eq(issues.id, issueId)).then(r => r[0] ?? null);
            let currentId = start?.parentId ?? null;
            while (currentId && !visited.has(currentId) && raw.length < 50) {
                visited.add(currentId);
                const parent = await db.select({
                    id: issues.id, identifier: issues.identifier, title: issues.title, description: issues.description,
                    status: issues.status, priority: issues.priority,
                    assigneeAgentId: issues.assigneeAgentId, projectId: issues.projectId,
                    goalId: issues.goalId, parentId: issues.parentId,
                }).from(issues).where(eq(issues.id, currentId)).then(r => r[0] ?? null);
                if (!parent)
                    break;
                raw.push({
                    id: parent.id, identifier: parent.identifier ?? null, title: parent.title, description: parent.description ?? null,
                    status: parent.status, priority: parent.priority,
                    assigneeAgentId: parent.assigneeAgentId ?? null,
                    projectId: parent.projectId ?? null, goalId: parent.goalId ?? null,
                });
                currentId = parent.parentId ?? null;
            }
            // Batch-fetch referenced projects and goals
            const projectIds = [...new Set(raw.map(a => a.projectId).filter((id) => id != null))];
            const goalIds = [...new Set(raw.map(a => a.goalId).filter((id) => id != null))];
            const projectMap = new Map();
            const goalMap = new Map();
            if (projectIds.length > 0) {
                const workspaceRows = await db
                    .select()
                    .from(projectWorkspaces)
                    .where(inArray(projectWorkspaces.projectId, projectIds))
                    .orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id));
                const workspaceMap = new Map();
                for (const workspace of workspaceRows) {
                    const existing = workspaceMap.get(workspace.projectId);
                    if (existing)
                        existing.push(workspace);
                    else
                        workspaceMap.set(workspace.projectId, [workspace]);
                }
                const rows = await db.select({
                    id: projects.id, name: projects.name, description: projects.description,
                    status: projects.status, goalId: projects.goalId,
                }).from(projects).where(inArray(projects.id, projectIds));
                for (const r of rows) {
                    const projectWorkspaceRows = workspaceMap.get(r.id) ?? [];
                    const workspaces = projectWorkspaceRows.map((workspace) => ({
                        id: workspace.id,
                        companyId: workspace.companyId,
                        projectId: workspace.projectId,
                        name: workspace.name,
                        cwd: workspace.cwd,
                        repoUrl: workspace.repoUrl ?? null,
                        repoRef: workspace.repoRef ?? null,
                        metadata: workspace.metadata ?? null,
                        isPrimary: workspace.isPrimary,
                        createdAt: workspace.createdAt,
                        updatedAt: workspace.updatedAt,
                    }));
                    const primaryWorkspace = workspaces.find((workspace) => workspace.isPrimary) ?? workspaces[0] ?? null;
                    projectMap.set(r.id, {
                        ...r,
                        workspaces,
                        primaryWorkspace,
                    });
                    // Also collect goalIds from projects
                    if (r.goalId && !goalIds.includes(r.goalId))
                        goalIds.push(r.goalId);
                }
            }
            if (goalIds.length > 0) {
                const rows = await db.select({
                    id: goals.id, title: goals.title, description: goals.description,
                    level: goals.level, status: goals.status,
                }).from(goals).where(inArray(goals.id, goalIds));
                for (const r of rows)
                    goalMap.set(r.id, r);
            }
            return raw.map(a => ({
                ...a,
                project: a.projectId ? projectMap.get(a.projectId) ?? null : null,
                goal: a.goalId ? goalMap.get(a.goalId) ?? null : null,
            }));
        },
    };
}
//# sourceMappingURL=issues.js.map