// Extracted from src/db/repository.ts (mechanical split — bodies unchanged).
// jobs repository domain. Facade: src/db/repository.ts (Repository class).
import Database from 'better-sqlite3';
import { randomUUID } from 'crypto';
import { logger } from '../../logger.js';
import { buildVisibilityWhere } from '../../bridge/visibility.js';
import { utc, localTaskRepoName } from './shared.js';

export type JobStatus =
  | 'queued'
  | 'dispatching'
  | 'running'
  | 'succeeded'
  | 'failed'
  | 'retry'
  | 'cancelled'
  | 'waiting_human'
  | 'waiting_subtasks';


export type JobRole = 'auto' | 'fast' | 'quality' | 'reflection';

/** @deprecated Use JobRole instead */
export type JobProfile = JobRole;

/** @deprecated Removed — taskClass is no longer a separate concept */
export type TaskClass = 'auto' | 'low_level' | 'high_level';


export interface Job {
  id: string;
  repo: string;
  issueNumber: number;
  prNumber: number | null;
  status: JobStatus;
  pieceName: string;
  currentMovement: string | null;
  currentActivity: string | null;
  instruction: string;
  branchName: string | null;
  worktreePath: string | null;
  attempt: number;
  maxAttempts: number;
  nextRetryAt: string | null;
  errorSummary: string | null;
  abortReason: string | null;
  resumeMovement: string | null;
  waitReason: string | null;
  askCount: number;
  workerId: string | null;
  /**
   * Physical backend id (e.g. LiteLLM deployment name) that handled
   * this job's LLM calls when running through a proxy worker. Set on
   * the first LLM call and never overwritten — sticky-backend policy
   * per design Open Question #3. NULL for direct workers and for jobs
   * that haven't issued any proxied LLM call yet.
   */
  lastBackendId: string | null;
  parentJobId: string | null;
  continuedFromJobId: string | null;
  subtaskDepth: number;
  requiredRole: JobRole;
  /** @deprecated Use requiredRole */
  requiredProfile: JobRole;
  ownerId: string | null;
  visibility: 'private' | 'org' | 'public';
  visibilityScopeOrgId: string | null;
  contextPromptTokens: number | null;
  contextLimitTokens: number | null;
  contextUpdatedAt: string | null;
  browserSessionProfileId?: number | null;
  taskKind: 'agent' | 'reflection';
  payload: string | null;
  /** 所属スペース。親タスク/親ジョブから継承される（Task 5 で設定）。null は個人スペース。 */
  spaceId: string | null;
  /** 実行ログ root（計画5）。NULL = 後方互換で workspacePath/logs に解決。サブタスクが親から継承する。 */
  runtimeDir: string | null;
  createdAt: string;
  updatedAt: string;
}


export interface CreateJobParams {
  repo: string;
  issueNumber: number;
  instruction: string;
  pieceName?: string;
  maxAttempts?: number;
  resumeMovement?: string | null;
  askCount?: number;
  role?: JobRole;
  /** @deprecated Use role instead */
  profile?: JobRole;
  parentJobId?: string | null;
  continuedFromJobId?: string | null;
  subtaskDepth?: number;
  ownerId?: string | null;
  visibility?: 'private' | 'org' | 'public';
  visibilityScopeOrgId?: string | null;
  browserSessionProfileId?: number | null;
  taskKind?: 'agent' | 'reflection';
  payload?: string;
  /** 所属スペース（spec §5.7 親→子継承）。NULL は owner 個人スペースに解決。 */
  spaceId?: string | null;
  /** 実行ログ root（計画5）。NULL = 後方互換で workspacePath/logs に解決。 */
  runtimeDir?: string | null;
}


export interface SubtaskInfo {
  id: string;
  issueNumber: number;
  status: JobStatus;
  instruction: string;
  worktreePath: string | null;
  createdAt: string;
  updatedAt: string;
  children?: SubtaskInfo[];
  childCount?: number;
  childCompleted?: number;
}


export interface JobRow {
  id: string;
  repo: string;
  issue_number: number;
  pr_number: number | null;
  status: string;
  piece_name: string;
  current_movement: string | null;
  current_activity: string | null;
  instruction: string;
  branch_name: string | null;
  worktree_path: string | null;
  attempt: number;
  max_attempts: number;
  next_retry_at: string | null;
  error_summary: string | null;
  abort_reason: string | null;
  resume_movement: string | null;
  wait_reason: string | null;
  ask_count: number;
  worker_id: string | null;
  last_backend_id: string | null;
  parent_job_id: string | null;
  continued_from_job_id: string | null;
  subtask_depth: number;
  required_profile: string;
  task_class: string;
  owner_id: string | null;
  visibility: string | null;
  visibility_scope_org_id: string | null;
  context_prompt_tokens: number | null;
  context_limit_tokens: number | null;
  context_updated_at: string | null;
  browser_session_profile_id: number | null;
  task_kind: string;
  payload: string | null;
  space_id: string | null;
  runtime_dir: string | null;
  created_at: string;
  updated_at: string;
}


export function isJobRole(value: string): value is JobRole {
  return value === 'auto' || value === 'fast' || value === 'quality' || value === 'reflection';
}


export function normalizeJobRole(value: string | undefined): JobRole {
  return value && isJobRole(value) ? value : 'auto';
}


export function deriveJobRole(instruction: string, explicitRole?: JobRole): JobRole {
  if (explicitRole) return explicitRole;
  const match = /ui_profile:\s*(auto|fast|quality)/i.exec(instruction);
  return normalizeJobRole(match?.[1]?.toLowerCase());
}


export function rowToJob(row: JobRow): Job {
  return {
    id: row.id,
    repo: row.repo,
    issueNumber: row.issue_number,
    prNumber: row.pr_number,
    status: row.status as JobStatus,
    pieceName: row.piece_name,
    currentMovement: row.current_movement,
    currentActivity: row.current_activity,
    instruction: row.instruction,
    branchName: row.branch_name,
    worktreePath: row.worktree_path,
    attempt: row.attempt,
    maxAttempts: row.max_attempts,
    nextRetryAt: utc(row.next_retry_at),
    errorSummary: row.error_summary,
    abortReason: row.abort_reason ?? null,
    resumeMovement: row.resume_movement,
    waitReason: row.wait_reason ?? null,
    askCount: row.ask_count,
    workerId: row.worker_id,
    lastBackendId: row.last_backend_id ?? null,
    parentJobId: row.parent_job_id,
    continuedFromJobId: row.continued_from_job_id ?? null,
    subtaskDepth: row.subtask_depth ?? 0,
    requiredRole: normalizeJobRole(row.required_profile),
    requiredProfile: normalizeJobRole(row.required_profile),
    ownerId: row.owner_id ?? null,
    visibility: (row.visibility === 'org' || row.visibility === 'public' ? row.visibility : 'private'),
    visibilityScopeOrgId: row.visibility_scope_org_id ?? null,
    contextPromptTokens: row.context_prompt_tokens,
    contextLimitTokens: row.context_limit_tokens,
    contextUpdatedAt: row.context_updated_at ? utc(row.context_updated_at) : null,
    browserSessionProfileId: row.browser_session_profile_id ?? null,
    taskKind: row.task_kind === 'reflection' ? 'reflection' : 'agent',
    payload: row.payload,
    spaceId: row.space_id ?? null,
    runtimeDir: row.runtime_dir ?? null,
    createdAt: utc(row.created_at),
    updatedAt: utc(row.updated_at),
  };
}

  // Non-terminal job states. A task with a job in any of these already has work
  // pending/running, so a new user comment should be appended to it rather than
  // spawning a second job. waiting_human is excluded on purpose: a comment there
  // is the answer that resumes via a fresh job.
  const PENDING_JOB_STATES = ['queued', 'dispatching', 'running', 'waiting_subtasks', 'retry'] as const;


  export function insertJobSync(db: Database.Database, params: CreateJobParams): Job {
    const id = randomUUID();
    const now = new Date().toISOString();
    const pieceName = params.pieceName ?? 'chat';
    const maxAttempts = params.maxAttempts ?? 3;
    const resumeMovement = params.resumeMovement ?? null;
    const askCount = params.askCount ?? 0;
    const requiredRole = deriveJobRole(params.instruction, params.role ?? params.profile);

    db
      .prepare(
        `INSERT INTO jobs (id, repo, issue_number, status, piece_name, required_profile, task_class, instruction, attempt, max_attempts, resume_movement, ask_count, worker_id, parent_job_id, continued_from_job_id, subtask_depth, owner_id, visibility, visibility_scope_org_id, browser_session_profile_id, task_kind, payload, space_id, runtime_dir, created_at, updated_at)
         VALUES (@id, @repo, @issueNumber, 'queued', @pieceName, @requiredRole, 'auto', @instruction, 1, @maxAttempts, @resumeMovement, @askCount, NULL, @parentJobId, @continuedFromJobId, @subtaskDepth, @ownerId, @visibility, @visibilityScopeOrgId, @browserSessionProfileId, @taskKind, @payload, @spaceId, @runtimeDir, @now, @now)`
      )
      .run({
        id,
        repo: params.repo,
        issueNumber: params.issueNumber,
        pieceName,
        instruction: params.instruction,
        maxAttempts,
        resumeMovement,
        askCount,
        requiredRole,
        parentJobId: params.parentJobId ?? null,
        continuedFromJobId: params.continuedFromJobId ?? null,
        subtaskDepth: params.subtaskDepth ?? 0,
        ownerId: params.ownerId ?? null,
        visibility: params.visibility ?? 'private',
        visibilityScopeOrgId: params.visibilityScopeOrgId ?? null,
        browserSessionProfileId: params.browserSessionProfileId ?? null,
        taskKind: params.taskKind ?? 'agent',
        payload: params.payload ?? null,
        spaceId: params.spaceId ?? null,
        runtimeDir: params.runtimeDir ?? null,
        now,
      });

    const job = getJobSync(db, id);
    if (!job) throw new Error(`createJob: failed to retrieve created job ${id}`);
    return job;
  }


  export async function createJob(db: Database.Database, params: CreateJobParams): Promise<Job> {
    return insertJobSync(db, params);
  }


  /**
   * Atomically reuse-or-create. If a non-terminal job already exists for the
   * issue, return it (created=false) without inserting; otherwise insert a new
   * queued job (created=true). better-sqlite3 transactions run synchronously, so
   * the check+insert can't interleave with a concurrent request — this closes
   * the TOCTOU race where two near-simultaneous comments each spawned a job,
   * leaving the newest (queued) duplicate as the task's latestJob while an older
   * job actually ran (status stuck on "Inbox").
   */
  export function createJobIfNoPending(db: Database.Database, params: CreateJobParams, opts?: { blockOnToolRequestPause?: boolean }): { job: Job; created: boolean; blockedByToolRequest?: boolean } {
    const states = PENDING_JOB_STATES;
    const placeholders = states.map(() => '?').join(',');
    const tx = db.transaction((): { job: Job; created: boolean; blockedByToolRequest?: boolean } => {
      // Approval-pause guard: atomically refuse to create a normal resume job
      // while a job is parked for tool OR package approval (both resolved via
      // approve/deny, which re-queues the original). Done inside the same
      // transaction as the insert so there is no check-then-act race with the
      // engine parking the job.
      if (opts?.blockOnToolRequestPause) {
        const parked = db
          .prepare(
            `SELECT * FROM jobs WHERE repo = ? AND issue_number = ?
               AND status = 'waiting_human' AND wait_reason IN ('tool_request', 'package_request')
             ORDER BY created_at DESC, rowid DESC LIMIT 1`,
          )
          .get(params.repo, params.issueNumber) as JobRow | undefined;
        if (parked) return { job: rowToJob(parked), created: false, blockedByToolRequest: true };
      }
      const existing = db
        .prepare(
          `SELECT * FROM jobs WHERE repo = ? AND issue_number = ? AND status IN (${placeholders})
           ORDER BY created_at DESC, rowid DESC LIMIT 1`,
        )
        .get(params.repo, params.issueNumber, ...states) as JobRow | undefined;
      if (existing) return { job: rowToJob(existing), created: false };
      return { job: insertJobSync(db, params), created: true };
    });
    return tx();
  }


  export async function getJob(db: Database.Database, id: string, opts?: { viewer?: Express.User }): Promise<Job | null> {
    const viewerClause = opts?.viewer
      ? buildVisibilityWhere(opts.viewer, 'j', { spaceColumn: 'space_id' })
      : { clause: '1=1', params: [] as unknown[] };
    const row = db
      .prepare(`SELECT j.* FROM jobs j WHERE j.id = ? AND ${viewerClause.clause}`)
      .get(id, ...viewerClause.params) as JobRow | undefined;
    return row ? rowToJob(row) : null;
  }


  /** サブジョブ一覧を SubtaskInfo[] に変換。waiting_subtasks の子は再帰的に children を取得する */
  export async function buildSubtaskInfos(db: Database.Database, subJobs: Job[], maxDepth: number = 3): Promise<SubtaskInfo[]> {
    return Promise.all(subJobs.map(async (j): Promise<SubtaskInfo> => {
      const info: SubtaskInfo = {
        id: j.id,
        issueNumber: j.issueNumber,
        status: j.status,
        instruction: j.instruction,
        worktreePath: j.worktreePath,
        createdAt: j.createdAt,
        updatedAt: j.updatedAt,
      };
      // 再帰: waiting_subtasks のサブタスクは孫タスク情報も取得
      if (j.status === 'waiting_subtasks' && maxDepth > 0) {
        const grandChildren = await getSubJobs(db, j.id);
        if (grandChildren.length > 0) {
          info.children = await buildSubtaskInfos(db, grandChildren, maxDepth - 1);
          info.childCount = grandChildren.length;
          info.childCompleted = grandChildren.filter(g =>
            ['succeeded', 'failed', 'cancelled'].includes(g.status)
          ).length;
        }
      }
      return info;
    }));
  }


  export function getJobSync(db: Database.Database, id: string): Job | null {
    const row = db
      .prepare('SELECT * FROM jobs WHERE id = ?')
      .get(id) as JobRow | undefined;
    return row ? rowToJob(row) : null;
  }


  /**
   * ジョブの現在のステータスを同期的に取得する。
   * キャンセルチェックなど、非同期が使えない箇所で利用する。
   */
  export function getJobStatusSync(db: Database.Database, id: string): JobStatus | null {
    const row = db
      .prepare('SELECT status FROM jobs WHERE id = ?')
      .get(id) as { status: string } | undefined;
    return row ? (row.status as JobStatus) : null;
  }


  /**
   * Returns true if `workerId` currently has at least one job with status='running'.
   * Used by the Side Info Panel's worker status endpoint. Boolean-only on purpose:
   * never expose the job id/title/owner to other users in the shared panel.
   */
  export function isWorkerBusy(db: Database.Database, workerId: string): boolean {
    const row = db
      .prepare(`SELECT 1 AS hit FROM jobs WHERE worker_id = ? AND status = 'running' LIMIT 1`)
      .get(workerId) as { hit: number } | undefined;
    return !!row;
  }


  /**
   * いま実行中 (status='running') のジョブを worker_id ごとにまとめ、占有ユーザーの
   * 表示名と種別 (`task_kind`: 'agent' 通常 / 'reflection' 学習 等) を返す。
   * **admin 専用の監視用**（ワーカー / GPU パネルで「誰が・何で GPU を占有しているか」を
   * 出す）。一般ユーザーには呼び出し側で渡さない（dashboard-workers の privacy 既定を維持）。
   *
   * `user` は `users.name`。null（owner 不明 / no-auth 'local'）は owner_id →'unknown'
   * の順でフォールバック。`(user, kind)` の組で重複排除し、1 ワーカーに複数ユーザー /
   * 複数種別が同居するケースに対応する。
   */
  export function listRunningJobOwnersByWorker(db: Database.Database): Map<string, Array<{ user: string; kind: string }>> {
    const rows = db
      .prepare(
        `SELECT j.worker_id AS workerId, j.owner_id AS ownerId, j.task_kind AS taskKind, u.name AS ownerName
         FROM jobs j
         LEFT JOIN users u ON u.id = j.owner_id
         WHERE j.status = 'running' AND j.worker_id IS NOT NULL`,
      )
      .all() as Array<{ workerId: string; ownerId: string | null; taskKind: string | null; ownerName: string | null }>;
    const byWorker = new Map<string, Array<{ user: string; kind: string }>>();
    for (const r of rows) {
      const user = (r.ownerName && r.ownerName.trim()) || r.ownerId || 'unknown';
      const kind = r.taskKind || 'agent';
      const list = byWorker.get(r.workerId);
      if (list) {
        if (!list.some((o) => o.user === user && o.kind === kind)) list.push({ user, kind });
      } else {
        byWorker.set(r.workerId, [{ user, kind }]);
      }
    }
    return byWorker;
  }


  export async function updateJob(db: Database.Database, id: string, updates: Partial<Omit<Job, 'id' | 'createdAt'>>): Promise<void> {
    const setClauses: string[] = ["updated_at = datetime('now')"];
    const params: Record<string, unknown> = { id };

    const fieldMap: Record<string, string> = {
      status: 'status',
      pieceName: 'piece_name',
      currentMovement: 'current_movement',
      currentActivity: 'current_activity',
      instruction: 'instruction',
      branchName: 'branch_name',
      worktreePath: 'worktree_path',
      prNumber: 'pr_number',
      attempt: 'attempt',
      maxAttempts: 'max_attempts',
      nextRetryAt: 'next_retry_at',
      errorSummary: 'error_summary',
      abortReason: 'abort_reason',
      resumeMovement: 'resume_movement',
      waitReason: 'wait_reason',
      askCount: 'ask_count',
      workerId: 'worker_id',
      lastBackendId: 'last_backend_id',
      parentJobId: 'parent_job_id',
      subtaskDepth: 'subtask_depth',
      requiredRole: 'required_profile',
      requiredProfile: 'required_profile',
    };

    for (const [jsKey, dbCol] of Object.entries(fieldMap)) {
      const val = (updates as Record<string, unknown>)[jsKey];
      if (val !== undefined) {
        setClauses.push(`${dbCol} = @${jsKey}`);
        params[jsKey] = val;
      }
    }

    if (setClauses.length === 1) return; // updated_at のみ = 実質変更なし

    db
      .prepare(`UPDATE jobs SET ${setClauses.join(', ')} WHERE id = @id`)
      .run(params);
  }


  /** ジョブの updated_at のみを更新（ハートビート用）。updateJob は変更フィールドなしだと早期リターンするため別メソッド */
  export function touchJobUpdatedAt(db: Database.Database, id: string): void {
    db.prepare("UPDATE jobs SET updated_at = datetime('now') WHERE id = ?").run(id);
  }


  /**
   * Re-queue jobs parked with wait_reason='mcp_auth_required' for the given owner.
   * Worker re-evaluates required_mcp on next pickup and will pause again if other servers
   * are still unauthorized. _serverId is accepted for API symmetry but not used at SQL time
   * (filtering by piece's required_mcp happens at the worker side).
   *
   * Returns the number of jobs actually re-queued.
   */
  export function resumeMcpWaitingJobs(db: Database.Database, ownerId: string, _serverId: string): number {
    const result = db
      .prepare(
        `UPDATE jobs
           SET status='queued', wait_reason=NULL, updated_at=datetime('now')
         WHERE status='waiting_human' AND wait_reason='mcp_auth_required'
           AND owner_id = ?`,
      )
      .run(ownerId);
    return result.changes;
  }


  /**
   * Re-queue a job parked with wait_reason='tool_request' after a user
   * decided (approve/deny) the pending tool request. The worker resumes from
   * `resume_movement` and re-reads the task's granted_tools. Returns the number
   * of jobs re-queued (0 if the job is gone or no longer parked for approval).
   */
  export function resumeToolRequestJob(db: Database.Database, jobId: string): number {
    const result = db
      .prepare(
        `UPDATE jobs
            SET status='queued', wait_reason=NULL, updated_at=datetime('now')
          WHERE id = ? AND status='waiting_human' AND wait_reason='tool_request'`,
      )
      .run(jobId);
    return result.changes;
  }

  /**
   * Re-queue a job parked with wait_reason='package_request' after a user
   * decided (approve/deny) the pending Python package request. On approve the
   * package is already installed into the space overlay, so the resumed
   * movement re-runs with the wheel importable. Returns the number of jobs
   * re-queued (0 if the job is gone or no longer parked for this reason).
   */
  export function resumePackageRequestJob(db: Database.Database, jobId: string): number {
    const result = db
      .prepare(
        `UPDATE jobs
            SET status='queued', wait_reason=NULL, updated_at=datetime('now')
          WHERE id = ? AND status='waiting_human' AND wait_reason='package_request'`,
      )
      .run(jobId);
    return result.changes;
  }


  export async function lockIssue(db: Database.Database, repo: string, issueNumber: number, jobId: string): Promise<boolean> {
    try {
      db
        .prepare('INSERT INTO issue_locks (repo, issue_number, job_id) VALUES (?, ?, ?)')
        .run(repo, issueNumber, jobId);
      return true;
    } catch {
      return false;
    }
  }


  export async function unlockIssue(db: Database.Database, repo: string, issueNumber: number): Promise<void> {
    db
      .prepare('DELETE FROM issue_locks WHERE repo = ? AND issue_number = ?')
      .run(repo, issueNumber);
  }


  export async function deleteJobsForIssue(db: Database.Database, repo: string, issueNumber: number): Promise<number> {
    const result = db
      .prepare('DELETE FROM jobs WHERE repo = ? AND issue_number = ?')
      .run(repo, issueNumber);
    db
      .prepare('DELETE FROM issue_locks WHERE repo = ? AND issue_number = ?')
      .run(repo, issueNumber);
    return result.changes;
  }


  export async function claimNextJob(db: Database.Database, workerId: string): Promise<Job | null> {
    const row = db.prepare(`
      UPDATE jobs
      SET status = 'running', worker_id = ?, updated_at = datetime('now')
      WHERE id = (
        SELECT j.id
        FROM jobs j
        JOIN worker_nodes w ON w.worker_id = ?
        WHERE j.status = 'queued'
          AND w.enabled = 1
          AND w.healthy = 1
          AND instr(w.profile_tags, ',' || j.required_profile || ',') > 0
          AND NOT EXISTS (
            SELECT 1 FROM issue_locks il
            WHERE il.repo = j.repo AND il.issue_number = j.issue_number
          )
        ORDER BY j.created_at ASC
        LIMIT 1
      )
      RETURNING *
    `).get(workerId, workerId) as JobRow | undefined;
    return row ? rowToJob(row) : null;
  }


  /**
   * リトライ待ちジョブの中から next_retry_at を過ぎたものを1件取得して running に遷移
   */
  export async function claimNextRetryJob(db: Database.Database, workerId: string): Promise<Job | null> {
    const row = db.prepare(`
      UPDATE jobs
      SET status = 'running', worker_id = ?, updated_at = datetime('now')
      WHERE id = (
        SELECT j.id
        FROM jobs j
        JOIN worker_nodes w ON w.worker_id = ?
        WHERE j.status = 'retry'
          AND replace(j.next_retry_at, 'T', ' ') <= datetime('now')
          AND w.enabled = 1
          AND w.healthy = 1
          AND instr(w.profile_tags, ',' || j.required_profile || ',') > 0
          AND NOT EXISTS (
            SELECT 1 FROM issue_locks il
            WHERE il.repo = j.repo AND il.issue_number = j.issue_number
          )
        ORDER BY j.next_retry_at ASC
        LIMIT 1
      )
      RETURNING *
    `).get(workerId, workerId) as JobRow | undefined;
    return row ? rowToJob(row) : null;
  }


  /**
   * Read-only peek at the next job this worker WOULD claim (retry-priority,
   * then oldest queued), without claiming it. Used by the idle-preferring
   * claim gate to learn the next job's role before deciding whether to defer
   * to an idler sibling. Mirrors the claimNext*Job WHERE clauses exactly.
   */
  export async function peekNextClaimable(db: Database.Database, workerId: string): Promise<Job | null> {
    const retry = db.prepare(`
      SELECT j.*
      FROM jobs j
      JOIN worker_nodes w ON w.worker_id = ?
      WHERE j.status = 'retry'
        AND replace(j.next_retry_at, 'T', ' ') <= datetime('now')
        AND w.enabled = 1
        AND w.healthy = 1
        AND instr(w.profile_tags, ',' || j.required_profile || ',') > 0
        AND NOT EXISTS (
          SELECT 1 FROM issue_locks il
          WHERE il.repo = j.repo AND il.issue_number = j.issue_number
        )
      ORDER BY j.next_retry_at ASC
      LIMIT 1
    `).get(workerId) as JobRow | undefined;
    if (retry) return rowToJob(retry);

    const queued = db.prepare(`
      SELECT j.*
      FROM jobs j
      JOIN worker_nodes w ON w.worker_id = ?
      WHERE j.status = 'queued'
        AND w.enabled = 1
        AND w.healthy = 1
        AND instr(w.profile_tags, ',' || j.required_profile || ',') > 0
        AND NOT EXISTS (
          SELECT 1 FROM issue_locks il
          WHERE il.repo = j.repo AND il.issue_number = j.issue_number
        )
      ORDER BY j.created_at ASC
      LIMIT 1
    `).get(workerId) as JobRow | undefined;
    return queued ? rowToJob(queued) : null;
  }


  export async function getJobsByStatus(db: Database.Database, status: JobStatus): Promise<Job[]> {
    const rows = db
      .prepare('SELECT * FROM jobs WHERE status = ? ORDER BY created_at ASC')
      .all(status) as JobRow[];
    return rows.map(rowToJob);
  }


  export async function getLatestJobForIssue(db: Database.Database, repo: string, issueNumber: number): Promise<Job | null> {
    const row = db
      .prepare('SELECT * FROM jobs WHERE repo = ? AND issue_number = ? ORDER BY created_at DESC LIMIT 1')
      .get(repo, issueNumber) as JobRow | undefined;
    return row ? rowToJob(row) : null;
  }


  export async function updateJobContext(db: Database.Database, jobId: string, payload: { promptTokens: number; limitTokens: number }): Promise<void> {
    const updatedAt = new Date().toISOString();
    db
      .prepare(
        `UPDATE jobs
         SET context_prompt_tokens = ?,
             context_limit_tokens = ?,
             context_updated_at = ?
         WHERE id = ?`
      )
      .run(payload.promptTokens, payload.limitTokens, updatedAt, jobId);
  }


  /** 起動時に孤立ジョブを回復 */
  export async function recoverOrphanedJobs(db: Database.Database): Promise<number> {
    const result = db
      .prepare("UPDATE jobs SET status = 'queued', worker_id = NULL, updated_at = datetime('now') WHERE status IN ('running', 'dispatching')")
      .run();
    if (result.changes > 0) {
      db.prepare('DELETE FROM issue_locks').run();
      logger.warn(`Repository: recovered ${result.changes} orphaned jobs, cleared issue locks`);
    }
    // waiting_subtasks のジョブで全サブジョブが完了済みのものを再キュー
    // 同一 issue_number に複数ジョブがある場合、最新のみで判定する
    const subtaskRecovery = db.prepare(`
      UPDATE jobs
      SET status = 'queued', updated_at = datetime('now')
      WHERE status = 'waiting_subtasks'
        AND (
          SELECT COUNT(*) FROM (
            SELECT s.status, ROW_NUMBER() OVER (
              PARTITION BY s.issue_number
              ORDER BY s.created_at DESC, s.rowid DESC
            ) AS rn
            FROM jobs s
            WHERE s.parent_job_id = jobs.id
          ) WHERE rn = 1
            AND status NOT IN ('succeeded','failed','cancelled')
        ) = 0
    `).run();
    if (subtaskRecovery.changes > 0) {
      logger.warn(`Repository: recovered ${subtaskRecovery.changes} waiting_subtasks jobs`);
    }
    return result.changes;
  }


  /**
   * running/dispatching 状態のまま staleMinutes 以上 updated_at が更新されていないジョブを
   * queued に戻す（ランタイム watchdog）
   */
  export function recoverStuckRunningJobs(db: Database.Database, staleMinutes: number): number {
    const rows = db.prepare(`
      UPDATE jobs
      SET status = 'queued',
          worker_id = NULL,
          error_summary = 'Recovered: stuck in running for over ' || ? || ' minutes',
          updated_at = datetime('now')
      WHERE status IN ('running', 'dispatching')
        AND updated_at < datetime('now', '-' || ? || ' minutes')
      RETURNING id, repo
    `).all(staleMinutes, staleMinutes) as Array<{ id: string; repo: string }>;
    if (rows.length > 0) {
      // issue ロックも解除
      for (const row of rows) {
        db.prepare('DELETE FROM issue_locks WHERE job_id = ?').run(row.id);
      }
      logger.warn(`Repository: recovered ${rows.length} stuck jobs (stale > ${staleMinutes}min): ${rows.map(r => r.repo).join(', ')}`);
    }
    return rows.length;
  }


  /** running/dispatching 状態のジョブを全て queued に戻す（graceful shutdown 用） */
  export function requeueRunningJobs(db: Database.Database): number {
    const result = db
      .prepare("UPDATE jobs SET status = 'queued', worker_id = NULL, updated_at = datetime('now') WHERE status IN ('running', 'dispatching')")
      .run();
    if (result.changes > 0) {
      db.prepare('DELETE FROM issue_locks').run();
      logger.warn(`Repository: requeued ${result.changes} running jobs, cleared issue locks`);
    }
    return result.changes;
  }


  export function getDistinctRepos(db: Database.Database): string[] {
    const rows = db.prepare('SELECT DISTINCT repo FROM jobs ORDER BY repo').all() as { repo: string }[];
    return rows.map(r => r.repo);
  }


  export function getJobsByRepo(db: Database.Database, repoName: string): Job[] {
    const rows = db.prepare('SELECT * FROM jobs WHERE repo = ? ORDER BY created_at DESC').all(repoName) as JobRow[];
    return rows.map(rowToJob);
  }


  /** Issue ごとに最新の Job だけを返す（カンバンUI用） */
  export function getLatestJobsPerIssue(db: Database.Database, repoName: string): Job[] {
    const rows = db.prepare(`
      SELECT j.*
      FROM jobs j
      WHERE j.repo = ?
        AND j.id = (
          SELECT j2.id
          FROM jobs j2
          WHERE j2.repo = j.repo
            AND j2.issue_number = j.issue_number
          ORDER BY j2.updated_at DESC, j2.created_at DESC, j2.rowid DESC
          LIMIT 1
        )
      ORDER BY j.updated_at DESC
    `).all(repoName) as JobRow[];
    return rows.map(rowToJob);
  }


  // Cascade a local_task visibility change to all jobs spawned for that task
  // and their recursive subtask descendants (repo='subtask/<parentJobId>').
  // Returns the number of job rows updated.
  export async function updateJobsVisibilityForTask(db: Database.Database, taskId: number, updates: { visibility: 'private' | 'org' | 'public'; visibilityScopeOrgId: string | null }): Promise<number> {
    const repoName = localTaskRepoName(taskId);
    const now = new Date().toISOString();
    const result = db
      .prepare(`
        WITH RECURSIVE job_tree(id) AS (
          SELECT id FROM jobs WHERE repo = ?
          UNION ALL
          SELECT j.id FROM jobs j JOIN job_tree jt ON j.parent_job_id = jt.id
        )
        UPDATE jobs
        SET visibility = ?,
            visibility_scope_org_id = ?,
            updated_at = ?
        WHERE id IN (SELECT id FROM job_tree)
      `)
      .run(repoName, updates.visibility, updates.visibilityScopeOrgId, now);
    return result.changes;
  }


  export async function getSubJobs(db: Database.Database, parentJobId: string): Promise<Job[]> {
    // 同一 issue_number に複数ジョブがある場合（ASK再投入等）、最新のみ返す
    // ROW_NUMBER() + rowid で同一 created_at でも一意に決定する
    const rows = db
      .prepare(`
        SELECT * FROM (
          SELECT j.*, ROW_NUMBER() OVER (
            PARTITION BY j.issue_number
            ORDER BY j.created_at DESC, j.rowid DESC
          ) AS rn
          FROM jobs j
          WHERE j.parent_job_id = ?
        ) WHERE rn = 1
        ORDER BY issue_number ASC
      `)
      .all(parentJobId) as JobRow[];
    return rows.map(rowToJob);
  }


  /**
   * 全サブジョブが終端状態なら親ジョブを再キューに戻す。
   * 再キューできた場合 true を返す。
   */
  export async function requeueParentJobIfAllSubtasksDone(db: Database.Database, parentJobId: string): Promise<boolean> {
    // 同一 issue_number に複数ジョブがある場合、最新のもの(ROW_NUMBER=1)のみで判定する
    const result = db.prepare(`
      UPDATE jobs
      SET status = 'queued',
          updated_at = datetime('now')
      WHERE id = ?
        AND status = 'waiting_subtasks'
        AND (
          SELECT COUNT(*) FROM (
            SELECT status, ROW_NUMBER() OVER (
              PARTITION BY issue_number
              ORDER BY created_at DESC, rowid DESC
            ) AS rn
            FROM jobs
            WHERE parent_job_id = ?
          ) WHERE rn = 1
            AND status NOT IN ('succeeded', 'failed', 'cancelled')
        ) = 0
    `).run(parentJobId, parentJobId);
    return result.changes > 0;
  }


  /**
   * 実行中のジョブをキャンセル状態に変更する。
   * running または dispatching 状態のジョブのみ対象。
   * 戻り値: キャンセル対象ジョブが見つかったら true、見つからなかったら false。
   */
  export function requestJobCancel(db: Database.Database, jobId: string): boolean {
    const result = db.prepare(`
      UPDATE jobs
      SET status = 'cancelled', updated_at = datetime('now')
      WHERE id = ? AND status IN ('running', 'dispatching')
    `).run(jobId);
    return result.changes > 0;
  }
