// Extracted from src/db/repository.ts (mechanical split — bodies unchanged).
// local-tasks repository domain. Facade: src/db/repository.ts (Repository class).
import Database from 'better-sqlite3';
import { rmSync, existsSync, realpathSync } from 'fs';
import { join, normalize, sep } from 'path';
import { randomUUID } from 'crypto';
import { logger } from '../../logger.js';
import { buildVisibilityWhere } from '../../bridge/visibility.js';
import { buildTitleFromGoal } from '../../title-generation.js';
import { utc, localTaskRepoName } from './shared.js';
import { Job, SubtaskInfo, JobRow, rowToJob } from './jobs.js';
import * as taskSearchRepo from './task-search.js';
import * as jobsRepo from './jobs.js';
import * as spacesRepo from './spaces.js';

/**
 * Shared SQL fragments for LocalTask read queries.
 *
 * getLocalTask / listLocalTasks / getLocalTaskByShareToken all need to expose
 * the owner's display name and the org display name of `visibility_scope_org_id`.
 * These constants keep the three queries in sync.
 *
 * Usage: splice into the SELECT list and the FROM-clause joins, e.g.
 *
 *   SELECT lt.*, ${LOCAL_TASK_DISPLAY_SELECT}
 *   FROM local_tasks lt
 *   ${LOCAL_TASK_DISPLAY_JOIN}
 *   WHERE ...
 *
 * A correlated subquery (`MIN(org_name)`) is used instead of a JOIN because
 * user_gitea_orgs is keyed per-user, and we only need any one display name
 * for the org id — this avoids row-multiplication across the join.
 */
export const LOCAL_TASK_DISPLAY_SELECT = `
  u.name AS owner_name,
  COALESCE(
    (SELECT MIN(org_name) FROM user_gitea_orgs WHERE org_id = lt.visibility_scope_org_id),
    (SELECT name FROM local_orgs WHERE id = lt.visibility_scope_org_id)
  ) AS visibility_scope_org_name
`.trim();

export const LOCAL_TASK_DISPLAY_JOIN = `LEFT JOIN users u ON u.id = lt.owner_id`;


export type TitleSource = 'auto' | 'agent' | 'user';


export interface LocalTask {
  id: number;
  title: string;
  /** Provenance of `title`. 'user' is never auto-overwritten by the agent. */
  titleSource: TitleSource;
  body: string;
  pieceName: string;
  profile: 'auto' | 'fast' | 'quality' | string;
  outputFormat: 'text' | 'markdown' | 'json' | string;
  askPolicy: 'low' | 'high' | string;
  priority: 'low' | 'medium' | 'high' | string;
  state: 'open' | 'closed' | string;
  workspacePath: string | null;
  /** 実行先ワークスペースの種類。persistent=スペース共有ツリー / ephemeral=使い捨て。 */
  workspaceMode: 'persistent' | 'ephemeral';
  ownerId: string | null;
  ownerName?: string | null;
  visibility: 'private' | 'org' | 'public';
  visibilityScopeOrgId: string | null;
  visibilityScopeOrgName?: string | null;
  /** 所属スペース。null は owner の個人スペース（＝現行の data/users/{owner}）に解決される。 */
  spaceId: string | null;
  /** 実行ログ root（計画5）。null = 後方互換で workspacePath/logs に解決。 */
  runtimeDir: string | null;
  createdAt: string;
  updatedAt: string;
  feedbackRating: 'good' | 'bad' | null;
  feedbackTags: string[] | null;
  feedbackComment: string | null;
  feedbackAt: string | null;
  shareToken: string | null;
  sharedAt: string | null;
  browserSessionProfileId?: number | null;
  /**
   * Mission Brief: per-task pinned memo. Carries goal / done / open /
   * clarifications. Always rendered at the top of every movement's
   * system prompt. The LLM updates it via mission_update; the user
   * edits it from the Overview tab.
   */
  missionBrief: MissionBrief | null;
  /** Per-task runtime options (e.g. { mcpDisabled, skillsDisabled }). */
  options: Record<string, unknown>;
  latestJob?: Job | null;
  subtasks?: SubtaskInfo[];
  subtaskCount?: number;
  subtaskCompleted?: number;
}


/**
 * Ordered Mission Brief field list. Single source of truth for parse / update
 * / API whitelist / MissionUpdate tool so a future field is a one-line
 * addition here (plus its typed interface member). Legacy fields come first so
 * JSON key order and title-derivation (goal only) stay unchanged.
 */
export const MISSION_BRIEF_FIELDS = [
  'goal',
  'done',
  'open',
  'clarifications',
  'user_constraints',
  'decisions',
  'current_focus',
] as const;


export type MissionBriefField = (typeof MISSION_BRIEF_FIELDS)[number];


export interface MissionBrief {
  goal: string;
  done: string;
  open: string;
  clarifications: string;
  /** Durable user-stated constraints ("do not change X"). */
  user_constraints?: string;
  /** Decisions made after clarification, with rationale. */
  decisions?: string;
  /** What this movement is actively working on right now. */
  current_focus?: string;
}


export interface LocalTaskComment {
  id: number;
  taskId: number;
  author: string;
  kind: string;
  body: string;
  /** Filenames saved to the task's input/ dir for this comment (UI download links). */
  attachments: string[];
  createdAt: string;
  injectedAt: string | null;
}


export interface CreateLocalTaskParams {
  title: string;
  /** Defaults to 'auto'. Pass 'user' when the caller supplied an explicit title. */
  titleSource?: TitleSource;
  body: string;
  pieceName?: string;
  profile?: 'auto' | 'fast' | 'quality';
  outputFormat?: 'text' | 'markdown' | 'json';
  askPolicy?: 'low' | 'high';
  priority?: 'low' | 'medium' | 'high';
  workspacePath?: string | null;
  /** ワークスペースの種類。未指定は 'persistent'（スペース共有）。 */
  workspaceMode?: 'persistent' | 'ephemeral';
  ownerId?: string | null;
  visibility?: 'private' | 'org' | 'public';
  visibilityScopeOrgId?: string | null;
  browserSessionProfileId?: number | null;
  /** 所属スペース。未指定（null）は owner の個人スペースに解決される。 */
  spaceId?: string | null;
  /** Per-task runtime options (e.g. { mcpDisabled, skillsDisabled }). Stored as JSON. */
  options?: Record<string, unknown>;
}


export interface LocalTaskRow {
  id: number;
  title: string;
  title_source: string | null;
  body: string;
  piece_name: string;
  profile: string;
  output_format: string;
  ask_policy: string;
  priority: string;
  state: string;
  workspace_path: string | null;
  workspace_mode: string | null;
  runtime_dir: string | null;
  owner_id: string | null;
  owner_name?: string | null;
  visibility: string | null;
  visibility_scope_org_id: string | null;
  visibility_scope_org_name?: string | null;
  space_id: string | null;
  created_at: string;
  updated_at: string;
  feedback_rating: string | null;
  feedback_comment: string | null;
  feedback_tags: string | null;
  feedback_at: string | null;
  share_token: string | null;
  shared_at: string | null;
  mission_brief: string | null;
  browser_session_profile_id: number | null;
  options: string | null;
}


export interface LocalTaskCommentRow {
  id: number;
  task_id: number;
  author: string;
  kind: string;
  body: string;
  attachments: string | null;
  created_at: string;
  injected_at: string | null;
}


export function rowToLocalTask(row: LocalTaskRow): LocalTask {
  return {
    id: row.id,
    title: row.title,
    titleSource: (row.title_source as TitleSource | null) ?? 'auto',
    body: row.body,
    pieceName: row.piece_name,
    profile: row.profile,
    outputFormat: row.output_format,
    askPolicy: row.ask_policy,
    priority: row.priority,
    state: row.state,
    workspacePath: row.workspace_path,
    workspaceMode: row.workspace_mode === 'ephemeral' ? 'ephemeral' : 'persistent',
    ownerId: row.owner_id ?? null,
    ownerName: row.owner_name ?? null,
    visibility: (row.visibility ?? 'private') as LocalTask['visibility'],
    visibilityScopeOrgId: row.visibility_scope_org_id ?? null,
    visibilityScopeOrgName: row.visibility_scope_org_name ?? null,
    spaceId: row.space_id ?? null,
    runtimeDir: row.runtime_dir ?? null,
    createdAt: utc(row.created_at),
    updatedAt: utc(row.updated_at),
    feedbackRating: (row.feedback_rating as 'good' | 'bad' | null) ?? null,
    feedbackTags: row.feedback_tags ? JSON.parse(row.feedback_tags) : null,
    feedbackComment: row.feedback_comment ?? null,
    feedbackAt: row.feedback_at ? utc(row.feedback_at) : null,
    shareToken: row.share_token ?? null,
    sharedAt: row.shared_at ? utc(row.shared_at) : null,
    browserSessionProfileId: row.browser_session_profile_id ?? null,
    missionBrief: parseMissionBrief(row.mission_brief),
    options: parseTaskOptions(row.options),
  };
}


export function parseMissionBrief(raw: string | null | undefined): MissionBrief | null {
  if (!raw) return null;
  try {
    const parsed = JSON.parse(raw);
    if (!parsed || typeof parsed !== 'object') return null;
    const brief = {} as MissionBrief;
    for (const field of MISSION_BRIEF_FIELDS) {
      brief[field] = typeof parsed[field] === 'string' ? parsed[field] : '';
    }
    if (MISSION_BRIEF_FIELDS.every((field) => !brief[field])) return null;
    return brief;
  } catch {
    return null;
  }
}


export function parseTaskOptions(raw: string | null | undefined): Record<string, unknown> {
  if (!raw) return {};
  try {
    const parsed = JSON.parse(raw);
    if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
      return parsed as Record<string, unknown>;
    }
    return {};
  } catch {
    return {};
  }
}


export function rowToLocalTaskComment(row: LocalTaskCommentRow): LocalTaskComment {
  return {
    id: row.id,
    taskId: row.task_id,
    author: row.author,
    kind: row.kind,
    body: row.body,
    attachments: parseCommentAttachments(row.attachments),
    createdAt: utc(row.created_at),
    injectedAt: row.injected_at ? utc(row.injected_at) : null,
  };
}


/** Parse the JSON attachments column to a string[]; tolerate null/legacy/garbage. */
export function parseCommentAttachments(raw: string | null): string[] {
  if (!raw) return [];
  try {
    const parsed = JSON.parse(raw);
    return Array.isArray(parsed) ? parsed.filter((n): n is string => typeof n === 'string') : [];
  } catch {
    return [];
  }
}


  /**
   * タスクの実効スペースを解決し FolderContext を返す。
   * - space_id があればそのスペース、無ければ owner の個人スペース（無ければ lazy 生成）。
   * - owner も無い（no-auth）の場合は 'local' の個人スペース。
   * 返り値は userPiecesDir/readUserAgentsMd 等にそのまま渡せる { rootDir, leafId }。
   */
  export async function resolveTaskFolderContext(db: Database.Database, taskId: number, userFolderRoot: string): Promise<import('../../spaces/folder-resolver.js').FolderContext> {
    const { resolveSpaceFolder } = await import('../../spaces/folder-resolver.js');
    const row = db
      .prepare(`SELECT space_id, owner_id FROM local_tasks WHERE id = ?`)
      .get(taskId) as { space_id: string | null; owner_id: string | null } | undefined;
    const ownerId = row?.owner_id ?? 'local';
    if (row?.space_id) {
      const space = await spacesRepo.getSpace(db, row.space_id);
      if (space) return resolveSpaceFolder(space, userFolderRoot);
      // 参照先スペースが消えている場合は個人スペースにフォールバック
    }
    const personal = await spacesRepo.ensurePersonalSpace(db, ownerId);
    return resolveSpaceFolder(personal, userFolderRoot);
  }


  export async function createLocalTask(db: Database.Database, params: CreateLocalTaskParams): Promise<LocalTask> {
    const result = db
      .prepare(
        `INSERT INTO local_tasks (title, title_source, body, piece_name, profile, output_format, ask_policy, priority, workspace_path, workspace_mode, owner_id, visibility, visibility_scope_org_id, browser_session_profile_id, space_id, options)
         VALUES (@title, @titleSource, @body, @pieceName, @profile, @outputFormat, @askPolicy, @priority, @workspacePath, @workspaceMode, @ownerId, @visibility, @visibilityScopeOrgId, @browserSessionProfileId, @spaceId, @options)`
      )
      .run({
        title: params.title,
        titleSource: params.titleSource ?? 'auto',
        body: params.body,
        pieceName: params.pieceName ?? 'chat',
        profile: params.profile ?? 'auto',
        outputFormat: params.outputFormat ?? 'markdown',
        askPolicy: params.askPolicy ?? 'low',
        priority: params.priority ?? 'medium',
        workspacePath: params.workspacePath ?? null,
        workspaceMode: params.workspaceMode ?? 'persistent',
        ownerId: params.ownerId ?? null,
        visibility: params.visibility ?? 'private',
        visibilityScopeOrgId: params.visibilityScopeOrgId ?? null,
        browserSessionProfileId: params.browserSessionProfileId ?? null,
        spaceId: params.spaceId ?? null,
        options: JSON.stringify(params.options ?? {}),
      });

    const task = await getLocalTask(db, Number(result.lastInsertRowid));
    if (!task) throw new Error('createLocalTask: failed to load inserted task');
    return task;
  }


  export async function getLocalTask(db: Database.Database, taskId: number, opts?: { viewer?: Express.User }): Promise<LocalTask | null> {
    const viewerClause = opts?.viewer
      ? buildVisibilityWhere(opts.viewer, 'lt', { spaceColumn: 'space_id' })
      : { clause: '1=1', params: [] as unknown[] };
    const row = db
      .prepare(`
        SELECT lt.*,
               ${LOCAL_TASK_DISPLAY_SELECT}
        FROM local_tasks lt
        ${LOCAL_TASK_DISPLAY_JOIN}
        WHERE lt.id = ? AND ${viewerClause.clause}
      `)
      .get(taskId, ...viewerClause.params) as LocalTaskRow | undefined;
    if (!row) return null;
    const task = rowToLocalTask(row);
    task.latestJob = await jobsRepo.getLatestJobForIssue(db, localTaskRepoName(taskId), taskId);
    // サブタスク情報を付与
    if (task.latestJob) {
      const subJobs = await jobsRepo.getSubJobs(db, task.latestJob.id);
      if (subJobs.length > 0) {
        task.subtasks = await jobsRepo.buildSubtaskInfos(db, subJobs);
        task.subtaskCount = subJobs.length;
        task.subtaskCompleted = subJobs.filter(j =>
          ['succeeded', 'failed', 'cancelled'].includes(j.status)
        ).length;
      }
    }
    return task;
  }


  export async function shareLocalTask(db: Database.Database, taskId: number): Promise<string> {
    const existing = await getLocalTask(db, taskId);
    if (!existing) throw new Error(`Task ${taskId} not found`);
    if (existing.shareToken) return existing.shareToken;

    const token = randomUUID();
    db.prepare(
      `UPDATE local_tasks SET share_token = ?, shared_at = datetime('now'), updated_at = datetime('now') WHERE id = ?`
    ).run(token, taskId);
    return token;
  }


  export async function unshareLocalTask(db: Database.Database, taskId: number): Promise<void> {
    db.prepare(
      `UPDATE local_tasks SET share_token = NULL, shared_at = NULL, updated_at = datetime('now') WHERE id = ?`
    ).run(taskId);
  }


  /**
   * Mission Brief: partial-replace update. Only fields explicitly provided
   * are written; undefined leaves the field untouched. Passing all-empty
   * strings is treated as "clear the brief" (NULL in storage).
   *
   * Returns the merged brief so callers can echo it back to the client
   * without an extra read.
   */
  export async function updateMissionBrief(db: Database.Database, taskId: number, patch: Partial<MissionBrief>): Promise<MissionBrief | null> {
    return updateMissionBriefSync(db, taskId, patch);
  }


  /** Sync variant used by the engine's MissionBriefIO so it can be called
   * from sync paths (e.g. buildSystemPrompt). better-sqlite3 is sync
   * underneath anyway. */
  export function updateMissionBriefSync(db: Database.Database, taskId: number, patch: Partial<MissionBrief>): MissionBrief | null {
    const row = db
      .prepare(`SELECT mission_brief, title_source FROM local_tasks WHERE id = ?`)
      .get(taskId) as { mission_brief: string | null; title_source: string | null } | undefined;
    const existing = parseMissionBrief(row?.mission_brief ?? null);
    const next = {} as MissionBrief;
    for (const field of MISSION_BRIEF_FIELDS) {
      next[field] = patch[field] !== undefined ? patch[field] : existing?.[field] ?? '';
    }
    const allEmpty = MISSION_BRIEF_FIELDS.every((field) => !next[field]);
    const stored = allEmpty ? null : JSON.stringify(next);

    // Derive the task title from the agent's goal (no LLM call). Only when the
    // goal value actually changed (agents re-send an unchanged brief across
    // iterations — re-deriving every time would churn updated_at and flicker
    // the title) and the user hasn't taken manual control (a user edit pins
    // title_source='user' and is never overwritten).
    const goalChanged = patch.goal !== undefined && patch.goal !== (existing?.goal ?? '');
    const derivedTitle = (goalChanged && (row?.title_source ?? 'auto') !== 'user')
      ? buildTitleFromGoal(next.goal)
      : '';

    // Atomic: persist the brief and the derived title as one unit so a crash
    // between them can't leave the title out of sync with the goal.
    db.transaction(() => {
      db.prepare(
        `UPDATE local_tasks SET mission_brief = ?, updated_at = datetime('now') WHERE id = ?`
      ).run(stored, taskId);
      if (derivedTitle) {
        db.prepare(
          `UPDATE local_tasks SET title = ?, title_source = 'agent' WHERE id = ?`
        ).run(derivedTitle, taskId);
      }
    })();
    return allEmpty ? null : next;
  }


  /** Sync read of the mission brief column. Used by MissionBriefIO.read() */
  export function getMissionBriefSync(db: Database.Database, taskId: number): MissionBrief | null {
    const row = db.prepare(`SELECT mission_brief FROM local_tasks WHERE id = ?`).get(taskId) as { mission_brief: string | null } | undefined;
    return parseMissionBrief(row?.mission_brief ?? null);
  }


  /**
   * Construct a MissionBriefIO bound to a specific local task. The engine
   * uses this to thread mission brief read/write into the ToolContext
   * without leaking the repository instance into tool code.
   */
  export function makeMissionBriefIO(db: Database.Database, taskId: number): import('../../engine/tools/core.js').MissionBriefIO {
    return {
      read: () => getMissionBriefSync(db, taskId),
      update: (patch) => updateMissionBriefSync(db, taskId, patch),
    };
  }


  /**
   * Construct a TaskConversationIO bound to a single local task (comments) +
   * this run's transcript path. Used by SearchTaskConversation /
   * ReadTaskConversation; the binding is what scopes those tools to the current
   * task so they can't read another task's logs.
   */
  export function makeTaskConversationIO(db: Database.Database, taskId: number, transcriptPath?: string): import('../../engine/tools/core.js').TaskConversationIO {
    return {
      listComments: () => listLocalTaskComments(db, taskId),
      transcriptPath,
    };
  }


  /**
   * Transcript-only conversation IO for runs with no backing local_task
   * (sub-tasks). Comments are always empty — sub-tasks don't write
   * local_task comments — so only the transcript source is active. Mirrors
   * makeTaskConversationIO but without a taskId.
   */
  export function makeTranscriptOnlyConversationIO(transcriptPath: string | undefined): import('../../engine/tools/core.js').TaskConversationIO {
    return {
      listComments: async () => [],
      transcriptPath,
    };
  }


  export async function getLocalTaskByShareToken(db: Database.Database, token: string): Promise<LocalTask | null> {
    const row = db
      .prepare(`
        SELECT lt.*,
               ${LOCAL_TASK_DISPLAY_SELECT}
        FROM local_tasks lt
        ${LOCAL_TASK_DISPLAY_JOIN}
        WHERE lt.share_token = ?
      `)
      .get(token) as LocalTaskRow | undefined;
    if (!row) return null;
    const task = rowToLocalTask(row);
    task.latestJob = await jobsRepo.getLatestJobForIssue(db, localTaskRepoName(task.id), task.id);
    if (task.latestJob) {
      const subJobs = await jobsRepo.getSubJobs(db, task.latestJob.id);
      if (subJobs.length > 0) {
        task.subtasks = await jobsRepo.buildSubtaskInfos(db, subJobs);
        task.subtaskCount = subJobs.length;
        task.subtaskCompleted = subJobs.filter(j =>
          ['succeeded', 'failed', 'cancelled'].includes(j.status)
        ).length;
      }
    }
    return task;
  }


  export async function listLocalTasks(db: Database.Database, filter?: { ownerId?: string; viewer?: Express.User }): Promise<LocalTask[]> {
    // 1. Single JOIN query: local_tasks LEFT JOIN jobs (latest per task via correlated subquery)
    const conditions: string[] = [];
    const queryParams: unknown[] = [];
    if (filter?.ownerId) {
      conditions.push('lt.owner_id = ?');
      queryParams.push(filter.ownerId);
    }
    if (filter?.viewer) {
      const w = buildVisibilityWhere(filter.viewer, 'lt', { spaceColumn: 'space_id' });
      conditions.push(w.clause);
      queryParams.push(...w.params);
    }
    const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';

    const joinedRows = db
      .prepare(`
        SELECT
          lt.*,
          ${LOCAL_TASK_DISPLAY_SELECT},
          j.id              AS job_id,
          j.repo            AS job_repo,
          j.issue_number    AS job_issue_number,
          j.pr_number       AS job_pr_number,
          j.status          AS job_status,
          j.piece_name      AS job_piece_name,
          j.required_profile AS job_required_profile,
          j.task_class      AS job_task_class,
          j.current_movement AS job_current_movement,
          j.current_activity AS job_current_activity,
          j.instruction     AS job_instruction,
          j.branch_name     AS job_branch_name,
          j.worktree_path   AS job_worktree_path,
          j.attempt         AS job_attempt,
          j.max_attempts    AS job_max_attempts,
          j.next_retry_at   AS job_next_retry_at,
          j.error_summary   AS job_error_summary,
          j.abort_reason    AS job_abort_reason,
          j.resume_movement AS job_resume_movement,
          j.wait_reason     AS job_wait_reason,
          j.ask_count       AS job_ask_count,
          j.worker_id       AS job_worker_id,
          j.last_backend_id AS job_last_backend_id,
          j.parent_job_id   AS job_parent_job_id,
          j.continued_from_job_id AS job_continued_from_job_id,
          j.subtask_depth   AS job_subtask_depth,
          j.owner_id        AS job_owner_id,
          j.visibility      AS job_visibility,
          j.visibility_scope_org_id AS job_visibility_scope_org_id,
          j.created_at      AS job_created_at,
          j.updated_at      AS job_updated_at,
          j.context_prompt_tokens AS job_context_prompt_tokens,
          j.context_limit_tokens  AS job_context_limit_tokens,
          j.context_updated_at    AS job_context_updated_at,
          j.browser_session_profile_id AS job_browser_session_profile_id,
          j.task_kind       AS job_task_kind,
          j.payload         AS job_payload
        FROM local_tasks lt
        ${LOCAL_TASK_DISPLAY_JOIN}
        LEFT JOIN jobs j ON j.id = (
          SELECT j2.id FROM jobs j2
          WHERE j2.repo = 'local/task-' || lt.id
            AND j2.issue_number = lt.id
          ORDER BY j2.created_at DESC, j2.rowid DESC
          LIMIT 1
        )
        ${whereClause}
        ORDER BY lt.updated_at DESC, lt.id DESC
      `)
      .all(...queryParams) as Array<LocalTaskRow & {
        job_id: string | null;
        job_repo: string | null;
        job_issue_number: number | null;
        job_pr_number: number | null;
        job_status: string | null;
        job_piece_name: string | null;
        job_required_profile: string | null;
        job_task_class: string | null;
        job_current_movement: string | null;
        job_current_activity: string | null;
        job_instruction: string | null;
        job_branch_name: string | null;
        job_worktree_path: string | null;
        job_attempt: number | null;
        job_max_attempts: number | null;
        job_next_retry_at: string | null;
        job_error_summary: string | null;
        job_abort_reason: string | null;
        job_resume_movement: string | null;
        job_wait_reason: string | null;
        job_ask_count: number | null;
        job_worker_id: string | null;
        job_last_backend_id: string | null;
        job_parent_job_id: string | null;
        job_continued_from_job_id: string | null;
        job_subtask_depth: number | null;
        job_owner_id: string | null;
        job_visibility: string | null;
        job_visibility_scope_org_id: string | null;
        job_created_at: string | null;
        job_updated_at: string | null;
        job_context_prompt_tokens: number | null;
        job_context_limit_tokens: number | null;
        job_context_updated_at: string | null;
        job_browser_session_profile_id: number | null;
        job_task_kind: string | null;
        job_payload: string | null;
      }>;

    // Build tasks with latestJob from joined data
    const tasks: LocalTask[] = [];
    const jobIds: string[] = [];

    for (const row of joinedRows) {
      const task = rowToLocalTask(row);
      if (row.job_id) {
        task.latestJob = rowToJob({
          id: row.job_id,
          repo: row.job_repo!,
          issue_number: row.job_issue_number!,
          pr_number: row.job_pr_number ?? null,
          status: row.job_status!,
          piece_name: row.job_piece_name!,
          current_movement: row.job_current_movement ?? null,
          current_activity: row.job_current_activity ?? null,
          instruction: row.job_instruction!,
          branch_name: row.job_branch_name ?? null,
          worktree_path: row.job_worktree_path ?? null,
          attempt: row.job_attempt!,
          max_attempts: row.job_max_attempts!,
          next_retry_at: row.job_next_retry_at ?? null,
          error_summary: row.job_error_summary ?? null,
          abort_reason: row.job_abort_reason ?? null,
          resume_movement: row.job_resume_movement ?? null,
          wait_reason: row.job_wait_reason ?? null,
          ask_count: row.job_ask_count!,
          worker_id: row.job_worker_id ?? null,
          last_backend_id: row.job_last_backend_id ?? null,
          parent_job_id: row.job_parent_job_id ?? null,
          continued_from_job_id: row.job_continued_from_job_id ?? null,
          subtask_depth: row.job_subtask_depth ?? 0,
          required_profile: row.job_required_profile!,
          task_class: row.job_task_class!,
          owner_id: row.job_owner_id ?? null,
          visibility: row.job_visibility ?? null,
          visibility_scope_org_id: row.job_visibility_scope_org_id ?? null,
          context_prompt_tokens: row.job_context_prompt_tokens ?? null,
          context_limit_tokens: row.job_context_limit_tokens ?? null,
          context_updated_at: row.job_context_updated_at ?? null,
          browser_session_profile_id: row.job_browser_session_profile_id ?? null,
          task_kind: row.job_task_kind ?? 'agent',
          payload: row.job_payload ?? null,
          // この join は latestJob 表示用。スペース解決は local_tasks.space_id を使うため
          // ここでは job の space_id / runtime_dir を選択しておらず null で足りる。
          space_id: null,
          runtime_dir: null,
          created_at: row.job_created_at!,
          updated_at: row.job_updated_at!,
        });
        jobIds.push(row.job_id);
      } else {
        task.latestJob = null;
      }
      tasks.push(task);
    }

    // 2. Single query for all sub-jobs
    if (jobIds.length > 0) {
      const placeholders = jobIds.map(() => '?').join(', ');
      const subJobRows = db
        .prepare(`
          SELECT * FROM (
            SELECT j.*, ROW_NUMBER() OVER (
              PARTITION BY j.parent_job_id, j.issue_number
              ORDER BY j.created_at DESC, j.rowid DESC
            ) AS rn
            FROM jobs j
            WHERE j.parent_job_id IN (${placeholders})
          ) WHERE rn = 1
          ORDER BY parent_job_id, issue_number ASC
        `)
        .all(...jobIds) as JobRow[];

      // Group sub-jobs by parent_job_id
      const subJobsByParent = new Map<string, Job[]>();
      for (const row of subJobRows) {
        const parentId = row.parent_job_id!;
        if (!subJobsByParent.has(parentId)) {
          subJobsByParent.set(parentId, []);
        }
        subJobsByParent.get(parentId)!.push(rowToJob(row));
      }

      // 3. Attach subtask info to tasks
      for (const task of tasks) {
        if (task.latestJob && subJobsByParent.has(task.latestJob.id)) {
          const subJobs = subJobsByParent.get(task.latestJob.id)!;
          task.subtasks = await jobsRepo.buildSubtaskInfos(db, subJobs);
          task.subtaskCount = subJobs.length;
          task.subtaskCompleted = subJobs.filter(j =>
            ['succeeded', 'failed', 'cancelled'].includes(j.status)
          ).length;
        }
      }
    }

    return tasks;
  }


  export async function updateLocalTask(db: Database.Database, taskId: number, updates: Partial<Omit<LocalTask, 'id' | 'createdAt' | 'latestJob'>>): Promise<void> {
    const setClauses: string[] = ["updated_at = datetime('now')"];
    const params: Record<string, unknown> = { taskId };
    const fieldMap: Record<string, string> = {
      title: 'title',
      titleSource: 'title_source',
      body: 'body',
      pieceName: 'piece_name',
      profile: 'profile',
      outputFormat: 'output_format',
      askPolicy: 'ask_policy',
      priority: 'priority',
      state: 'state',
      workspacePath: 'workspace_path',
      runtimeDir: 'runtime_dir',
      visibility: 'visibility',
      visibilityScopeOrgId: 'visibility_scope_org_id',
    };

    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;

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


  export async function updateFeedback(db: Database.Database, taskId: number, feedback: {
    rating: 'good' | 'bad';
    tags: string[];
    comment: string | null;
  }): Promise<void> {
    db
      .prepare(`
        UPDATE local_tasks
        SET feedback_rating = @rating,
            feedback_tags = @tags,
            feedback_comment = @comment,
            feedback_at = datetime('now'),
            updated_at = datetime('now')
        WHERE id = @taskId
      `)
      .run({
        taskId,
        rating: feedback.rating,
        tags: JSON.stringify(feedback.tags),
        comment: feedback.comment,
      });
  }


  export async function addLocalTaskComment(db: Database.Database, taskId: number, author: string, body: string, kind: string = 'comment', attachments: string[] = []): Promise<LocalTaskComment> {
    const attachmentsJson = attachments.length > 0 ? JSON.stringify(attachments) : null;
    const result = db
      .prepare('INSERT INTO local_task_comments (task_id, author, kind, body, attachments) VALUES (?, ?, ?, ?, ?)')
      .run(taskId, author, kind, body, attachmentsJson);
    db
      .prepare("UPDATE local_tasks SET updated_at = datetime('now') WHERE id = ?")
      .run(taskId);
    const row = db
      .prepare('SELECT * FROM local_task_comments WHERE id = ?')
      .get(Number(result.lastInsertRowid)) as LocalTaskCommentRow | undefined;
    if (!row) throw new Error('addLocalTaskComment: failed to load inserted comment');
    taskSearchRepo.indexTaskComment(db, row.id, taskId, author, kind, row.created_at, body, attachmentsJson);
    return rowToLocalTaskComment(row);
  }


  export async function listLocalTaskComments(db: Database.Database, taskId: number): Promise<LocalTaskComment[]> {
    const rows = db
      .prepare('SELECT * FROM local_task_comments WHERE task_id = ? ORDER BY created_at ASC, id ASC')
      .all(taskId) as LocalTaskCommentRow[];
    return rows.map(rowToLocalTaskComment);
  }


  export async function getUninjectedComments(db: Database.Database, taskId: number, sinceId: number = 0): Promise<LocalTaskComment[]> {
    const rows = db
      .prepare(
        `SELECT * FROM local_task_comments
         WHERE task_id = ? AND id > ? AND author = 'user' AND injected_at IS NULL
         ORDER BY id ASC`
      )
      .all(taskId, sinceId) as LocalTaskCommentRow[];
    return rows.map(rowToLocalTaskComment);
  }


  export function markCommentsInjected(db: Database.Database, commentIds: number[]): void {
    if (commentIds.length === 0) return;
    const placeholders = commentIds.map(() => '?').join(',');
    db
      .prepare(`UPDATE local_task_comments SET injected_at = datetime('now') WHERE id IN (${placeholders})`)
      .run(...commentIds);
  }


  /**
   * Latest agent-authored "result" or "ask" comment for a task. Used by the
   * piece handoff feature to surface the previous job's terminal output as
   * context to a continuation job's LLM. Returns null when none exist
   * (e.g., task has not yet completed any job).
   */
  export async function getLatestResultComment(db: Database.Database, taskId: number): Promise<{ body: string; kind: string; createdAt: string } | null> {
    const row = db
      .prepare(
        `SELECT body, kind, created_at
         FROM local_task_comments
         WHERE task_id = ? AND author = 'agent' AND kind IN ('result', 'ask')
         ORDER BY created_at DESC
         LIMIT 1`
      )
      .get(taskId) as { body: string; kind: string; created_at: string } | undefined;
    return row ? { body: row.body, kind: row.kind, createdAt: row.created_at } : null;
  }


  export async function deleteLocalTask(db: Database.Database, taskId: number, worktreeDir?: string): Promise<void> {
    const repoName = localTaskRepoName(taskId);

    // タスク存在確認 & workspace_path / space_id / mode / runtime_dir を取得（削除前に必要）
    const taskRow = db
      .prepare('SELECT workspace_path, space_id, workspace_mode, runtime_dir FROM local_tasks WHERE id = ?')
      .get(taskId) as {
        workspace_path: string | null;
        space_id: string | null;
        workspace_mode: string | null;
        runtime_dir: string | null;
      } | undefined;
    if (!taskRow) {
      throw new Error(`deleteLocalTask: task ${taskId} not found`);
    }

    // running/dispatching なジョブがある場合は削除を拒否
    const activeJob = db
      .prepare("SELECT id FROM jobs WHERE repo = ? AND status IN ('running', 'dispatching') LIMIT 1")
      .get(repoName) as { id: string } | undefined;
    if (activeJob) {
      throw new Error(`deleteLocalTask: task ${taskId} has an active job (${activeJob.id})`);
    }

    // DB 操作をトランザクションで実行
    const deleteTransaction = db.transaction(() => {
      db.prepare('DELETE FROM issue_locks WHERE repo = ?').run(repoName);
      db.prepare('DELETE FROM jobs WHERE repo = ?').run(repoName);
      db.prepare('DELETE FROM task_comment_index WHERE task_id = ?').run(taskId);
      db.prepare('DELETE FROM local_tasks WHERE id = ?').run(taskId);
    });
    deleteTransaction();

    // ワークスペース削除（DB トランザクション外 — ロールバック不可のため）。
    // persistent スペースタスクの workspace_path は **スペース共有ツリー**
    // (`{worktreeDir}/space/{spaceId}/files`) で、同じスペースの全会話が共有する。
    // 1 会話の削除でこれを rm すると他会話の output/input まで巻き添えで消える（データ消失）
    // ため、共有ツリーは削除しない（DB 行のみ削除）。ephemeral/local の per-task
    // ワークスペースは従来どおり削除する。会話固有の runtime_dir
    // (`space/{spaceId}/runs/{taskId}` = logs/checklists/raw) は per-会話なので掃除する。

    // パスヘルパー: normalize（末尾区切り除去）/ 実体 realpath / 末尾セグメント一致。
    const norm = (p: string): string => normalize(p).replace(/[/\\]+$/, '');
    const realOf = (p: string): string => {
      const n = norm(p);
      try { return existsSync(n) ? realpathSync(n) : n; } catch { return n; }
    };
    const hasTail = (path: string, tail: string[]): boolean => {
      const segs = path.split(/[/\\]+/);
      return segs.length >= tail.length && tail.every((t, i) => segs[segs.length - tail.length + i] === t);
    };

    // 削除は **ホワイトリスト + worktreeDir アンカー** 方式。「共有ツリー形を blacklist して
    // 例外を追う」と symlink/normalization のたびに抜け道が増えるため、逆に **設定された
    // worktreeDir 配下に root され、かつシステムが作る per-task 形** のものだけを消す。
    // 判定は logical と realpath の **両方** で行い、どちらかが worktree 外/別実体を指す
    // symlink なら消さない（共有ツリーや worktree 外データを巻き込まない）。
    // worktreeDir が無い経路（legacy/一部テスト）は安全側に倒して fs 削除を一切しない。
    if (worktreeDir) {
      const root = realOf(worktreeDir);
      const rooted = (real: string): boolean => real === root || real.startsWith(root + sep);

      // workspace_path: per-task は ephemeral/{taskId} または旧 local/{taskId} のみ。共有ツリー
      // (space/{id}/files)・worktree 外・symlink リダイレクトは保持。
      if (taskRow.workspace_path) {
        const wsReal = realOf(taskRow.workspace_path);
        const perTaskShape = (s: string): boolean =>
          hasTail(s, ['ephemeral', String(taskId)]) || hasTail(s, ['local', String(taskId)]);
        if (rooted(wsReal) && perTaskShape(wsReal) && perTaskShape(norm(taskRow.workspace_path)) && existsSync(wsReal)) {
          rmSync(wsReal, { recursive: true, force: true });
        } else {
          logger.info(`Repository: deleteLocalTask kept non-per-task workspace ${taskRow.workspace_path} (task ${taskId})`);
        }
      }

      // runtime_dir: 会話固有 (space/<任意>/runs/{taskId})。worktree 配下に root され、logical/real
      // 両方が runs/{taskId} 形のときだけ掃除（共有 files を指す symlink は real 形が一致せず除外）。
      if (taskRow.runtime_dir) {
        const rtReal = realOf(taskRow.runtime_dir);
        const runsShape = (s: string): boolean =>
          hasTail(s, ['runs', String(taskId)]) && s.split(/[/\\]+/).slice(-4)[0] === 'space';
        if (rooted(rtReal) && runsShape(rtReal) && runsShape(norm(taskRow.runtime_dir)) && existsSync(rtReal)) {
          rmSync(rtReal, { recursive: true, force: true });
        } else {
          logger.info(`Repository: deleteLocalTask kept runtime_dir ${taskRow.runtime_dir} (task ${taskId})`);
        }
      }
    }

    logger.info(`Repository: deleted local task ${taskId}`);
  }
