import { z } from 'zod';
import { getEnv } from '../config/env.js';
import { db } from '../db/client.js';
import {
  parseAutomationContextIds,
  stringifyAutomationContextIds,
} from './automation-context-service.js';
import {
  normalizeLegacyAutomationBehaviorType,
  resolveAutomationType,
  resolveStoredAutomationTypeId,
  type AutomationBehaviorType,
} from './automation-type-config-service.js';
import { shouldTriggerRetryFromActionNote } from './plan-retry-policy.js';

export const PLAN_TABLE = 'plan_items';
export const PLAN_ISSUE_TABLE = 'plan_issue_histories';
export const PLAN_ACTION_TABLE = 'plan_action_histories';
export const PLAN_SOURCE_WORK_TABLE = 'plan_source_work_histories';
export const PLAN_RELEASE_REVIEW_TABLE = 'plan_release_reviews';
export const planStatuses = ['등록', '작업중', '작업완료', '릴리즈완료', '완료'] as const;
export const planAutomationTypes = ['none', 'plan', 'command_execution', 'non_source_work', 'auto_worker'] as const;
export const planWorkerStatuses = [
  '대기',
  '브랜치생성중',
  '브랜치준비',
  '자동작업중',
  'release반영대기',
  'release반영중',
  'release반영완료',
  'main반영대기',
  'main반영중',
  'main반영완료',
  '자동완료',
  '브랜치실패',
  'release반영실패',
  'main반영실패',
  '자동작업실패',
  '작업취소',
] as const;
export const planReleaseReviewStatuses = ['pending', 'reviewing', 'approved', 'changes-requested'] as const;

export type PlanAutomationUsageSnapshot = {
  tokenTotals: {
    total: number;
    input: number;
    output: number;
    cached: number;
    reasoning: number;
  };
  totalTokens: number;
  retryCount: number;
  sourceWorkCount: number;
  processingStartedAt: string | null;
  processingEndedAt: string | null;
  processingEndedAtSource: string | null;
  processingDurationSeconds: number | null;
};

type PlanRowOptions = {
  issueTags?: string[];
  hasOpenIssues?: boolean;
  maskNote?: boolean;
  noteMasked?: boolean;
  releaseReviewNote?: string;
  exposeConfiguredAutomationType?: boolean;
};

export const statusSchema = z.enum(planStatuses);

export const setupSchema = z.object({
  recreate: z.boolean().optional(),
});

function resolvePlanAutomationTypeAlias(value: unknown) {
  return normalizeLegacyAutomationBehaviorType(value);
}

export const planAutomationTypeSchema = z.preprocess(resolvePlanAutomationTypeAlias, z.string().trim().min(1).max(120));

export const createPlanSchema = z.object({
  workId: z.string().trim().optional().default('작업ID'),
  note: z.string().default(''),
  automationType: z.preprocess(resolvePlanAutomationTypeAlias, z.string().trim().min(1).max(120).default('none')),
  automationContextIds: z.array(z.string().trim().min(1).max(160)).max(20).optional().default([]),
  releaseTarget: z.string().trim().min(1).default('release'),
  jangsingProcessingRequired: z.boolean().default(true),
  autoDeployToMain: z.boolean().default(true),
  suppressWebPush: z.boolean().default(false),
  repeatRequestEnabled: z.boolean().default(false),
  repeatIntervalMinutes: z.coerce.number().int().min(1).max(1440).default(60),
});

export const updatePlanSchema = z.object({
  workId: z.string().trim().optional(),
  note: z.string().optional(),
  automationType: z.preprocess(resolvePlanAutomationTypeAlias, z.string().trim().min(1).max(120).optional()),
  automationContextIds: z.array(z.string().trim().min(1).max(160)).max(20).optional(),
  releaseTarget: z.string().trim().min(1).optional(),
  jangsingProcessingRequired: z.boolean().optional(),
  autoDeployToMain: z.boolean().optional(),
  suppressWebPush: z.boolean().optional(),
  repeatRequestEnabled: z.boolean().optional(),
  repeatIntervalMinutes: z.coerce.number().int().min(1).max(1440).optional(),
});

export const updatePlanJangsingProcessingSchema = z.object({
  jangsingProcessingRequired: z.boolean(),
});

export const issueActionSchema = z.object({
  actionNote: z.string().trim().min(1),
  resolve: z.boolean().default(false),
  retry: z.boolean().default(false),
});

const planListStatusFilterAliases = new Set(['all', 'in-progress', 'done', 'error']);

export const listPlanQuerySchema = z.object({
  status: z.preprocess((value) => {
    if (typeof value !== 'string') {
      return value;
    }

    const normalizedValue = value.trim();

    if (!normalizedValue || planListStatusFilterAliases.has(normalizedValue)) {
      return undefined;
    }

    return normalizedValue;
  }, statusSchema.optional()),
});

export type PlanStatus = (typeof planStatuses)[number];
export type PlanAutomationType = string;
export type PlanWorkerStatus = (typeof planWorkerStatuses)[number];
export type PlanReleaseReviewStatus = (typeof planReleaseReviewStatuses)[number];

export const planReleaseReviewStatusSchema = z.enum(planReleaseReviewStatuses);

const planReleaseReviewMetadataSchema = z.object({
  summary: z.string().trim().min(1).max(500).optional(),
  pageSelectionIds: z.array(z.string().trim().min(1).max(160)).max(12).optional(),
  checkedPageSelectionIds: z.array(z.string().trim().min(1).max(160)).max(12).optional(),
  docIds: z.array(z.string().trim().min(1).max(160)).max(12).optional(),
  componentIds: z.array(z.string().trim().min(1).max(160)).max(12).optional(),
  widgetIds: z.array(z.string().trim().min(1).max(160)).max(12).optional(),
});

export const updatePlanReleaseReviewSchema = z.object({
  status: planReleaseReviewStatusSchema.optional(),
  reviewNote: z.string().max(4000).optional(),
  metadata: planReleaseReviewMetadataSchema.optional(),
});

export type PlanReleaseReviewMetadata = z.infer<typeof planReleaseReviewMetadataSchema>;

const planFailureWorkerStatuses = new Set<PlanWorkerStatus>([
  '브랜치실패',
  '자동작업실패',
  'release반영실패',
  'main반영실패',
]);
const functionCheckEditableStatuses = new Set<PlanStatus>(['작업완료', '릴리즈완료', '완료']);

const automationIssueTags = ['#브랜치실패', '#자동작업실패', '#release반영실패', '#main반영실패'] as const;
const WORKLOG_EVIDENCE_PATH_PATTERN = /^docs\/(?:worklogs\/.+\.md|assets\/worklogs\/.+)/i;

function sanitizeBranchToken(value: string) {
  return value
    .trim()
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '')
    .slice(0, 48);
}

export function normalizePlanWorkId(value?: string | null) {
  const workId = String(value ?? '').trim();
  const normalized = workId.replace(/^\[|\]$/g, '').replace(/\s+/g, '').toLowerCase();

  if (!workId || normalized === '작업id' || normalized === 'workid' || normalized === 'undefined' || normalized === 'null') {
    return '작업ID';
  }

  return workId;
}

export function normalizePlanAutomationType(value: unknown): PlanAutomationType {
  const normalizedValue = resolvePlanAutomationTypeAlias(value);
  return planAutomationTypes.includes(normalizedValue as AutomationBehaviorType)
    ? (normalizedValue as AutomationBehaviorType)
    : 'none';
}

function shouldSkipLifecycleSourceWork(row: Record<string, unknown> | undefined | null) {
  return normalizePlanAutomationType(row?.automation_type) === 'plan';
}

export function formatPlanNotificationLabel(workId: string | null | undefined, id: number) {
  const normalizedWorkId = normalizePlanWorkId(workId);
  return normalizedWorkId === '작업ID' ? `#${id}` : normalizedWorkId;
}

export function maskPlanNote(value: unknown) {
  const normalized = String(value ?? '').replace(/\s+/g, ' ').trim();

  if (!normalized) {
    return '';
  }

  const words = normalized.split(' ').filter(Boolean);

  if (words.length === 1) {
    const [word] = words;
    if (word.length <= 1) {
      return '*';
    }

    return `${word[0]}${'*'.repeat(word.length - 1)}`;
  }

  return words
    .map((word, index) => {
      if (word.length <= 1) {
        return '*';
      }

      if (index === 0) {
        return `${word[0]}${'*'.repeat(word.length - 1)}`;
      }

      if (index === words.length - 1) {
        return `${'*'.repeat(word.length - 1)}${word[word.length - 1]}`;
      }

      return word;
    })
    .join(' ');
}

export function normalizeChangedFiles(rawChangedFiles: string[] | null | undefined) {
  return [...new Set((rawChangedFiles ?? []).map((file) => String(file ?? '').trim()).filter(Boolean))];
}

export function filterRetryWorklogEvidencePaths(
  changedFiles: string[],
  existingChangedFilesList: string[][],
) {
  const normalizedChangedFiles = normalizeChangedFiles(changedFiles);
  const existingEvidencePaths = new Set(
    existingChangedFilesList
      .flatMap((files) => normalizeChangedFiles(files))
      .filter((file) => WORKLOG_EVIDENCE_PATH_PATTERN.test(file)),
  );

  return normalizedChangedFiles.filter(
    (file) => !WORKLOG_EVIDENCE_PATH_PATTERN.test(file) || !existingEvidencePaths.has(file),
  );
}

export function buildPlanBranchName(workId: string, id: number) {
  const token = sanitizeBranchToken(workId) || `plan-${id}`;
  const prefix = /^auto-worklog-\d{4}-\d{2}-\d{2}$/i.test(String(workId ?? '').trim()) ? 'hotfix' : 'feature';
  return `${prefix}/plan-${id}-${token}`;
}

export function shouldUseLocalMainPlanMode(automationType: unknown) {
  const env = getEnv();
  return Boolean(env.PLAN_LOCAL_MAIN_MODE) && normalizePlanAutomationType(automationType) !== 'auto_worker';
}

export function mapPlanRow(
  row: Record<string, unknown>,
  options?: PlanRowOptions,
) {
  return {
    id: row.id,
    workId: row.work_id,
    note: options?.maskNote ? maskPlanNote(row.note) : row.note,
    automationType: options?.exposeConfiguredAutomationType ? resolveStoredAutomationTypeId(row) : normalizePlanAutomationType(row.automation_type),
    automationBehaviorType: normalizePlanAutomationType(row.automation_type),
    automationContextIds: parseAutomationContextIds(row.automation_context_ids_json),
    releaseReviewNote: options?.releaseReviewNote ?? '',
    noteMasked: Boolean(options?.noteMasked),
    status: row.status,
    jangsingProcessingRequired:
      typeof row.jangsing_processing_required === 'boolean'
        ? row.jangsing_processing_required
        : row.normal_processing_level === '상',
    autoDeployToMain: Boolean(row.auto_deploy_to_main ?? true),
    suppressWebPush: Boolean(row.suppress_web_push ?? false),
    repeatRequestEnabled: Boolean(row.repeat_request_enabled ?? false),
    repeatIntervalMinutes: Number(row.repeat_interval_minutes ?? 60),
    assignedBranch: row.assigned_branch,
    releaseTarget: row.release_target,
    workerStatus: row.worker_status,
    lastError: row.last_error,
    issueTags: options?.issueTags ?? [],
    hasOpenIssues: options?.hasOpenIssues ?? false,
    startedAt: row.started_at,
    completedAt: row.completed_at,
    mergedAt: row.merged_at,
    usageSnapshot: mapPlanAutomationUsageSnapshot(row.usage_snapshot),
    createdAt: row.created_at,
    updatedAt: row.updated_at,
  };
}

export function mapPlanIssueRow(row: Record<string, unknown>) {
  return {
    id: row.id,
    planItemId: row.plan_item_id,
    issueTag: row.issue_tag,
    message: row.message,
    actionNote: row.action_note,
    resolved: row.resolved,
    resolvedAt: row.resolved_at,
    createdAt: row.created_at,
  };
}

export function mapPlanActionRow(row: Record<string, unknown>) {
  return {
    id: row.id,
    planItemId: row.plan_item_id,
    actionType: row.action_type,
    note: row.note,
    createdAt: row.created_at,
  };
}

export function mapPlanSourceWorkRow(row: Record<string, unknown>) {
  const changedFilesText = String(row.changed_files ?? '[]');
  let changedFiles: string[] = [];
  const sourceFilesText = String(row.source_files ?? '[]');
  type SourceFileRow = {
    path: string;
    previousPath: string | null;
    status: 'added' | 'modified' | 'deleted' | 'renamed' | 'binary' | 'unknown';
    language: string;
    content: string;
  };
  let sourceFiles: SourceFileRow[] = [];

  try {
    changedFiles = normalizeChangedFiles(JSON.parse(changedFilesText) as string[]);
  } catch {
    changedFiles = [];
  }

  try {
    sourceFiles = JSON.parse(sourceFilesText) as SourceFileRow[];
  } catch {
    sourceFiles = [];
  }

  return {
    id: row.id,
    planItemId: row.plan_item_id,
    summary: row.summary,
    branchName: row.branch_name,
    commitHash: row.commit_hash,
    previewUrl: row.preview_url ?? null,
    changedFiles,
    commandLog: row.command_log,
    diffText: row.diff_text,
    sourceFiles,
    createdAt: row.created_at,
  };
}

function normalizePlanAutomationUsageSnapshotMetric(value: unknown) {
  const normalized = Number(value ?? 0);
  return Number.isFinite(normalized) ? Math.max(0, Math.round(normalized)) : 0;
}

export function mapPlanAutomationUsageSnapshot(value: unknown): PlanAutomationUsageSnapshot | null {
  let parsedValue = value;

  if (typeof value === 'string') {
    const trimmedValue = value.trim();

    if (!trimmedValue) {
      return null;
    }

    try {
      parsedValue = JSON.parse(trimmedValue);
    } catch {
      return null;
    }
  }

  if (!parsedValue || typeof parsedValue !== 'object') {
    return null;
  }

  const snapshot = parsedValue as Partial<PlanAutomationUsageSnapshot> & {
    tokenTotals?: Partial<PlanAutomationUsageSnapshot['tokenTotals']>;
  };
  const tokenTotals: Partial<PlanAutomationUsageSnapshot['tokenTotals']> = snapshot.tokenTotals ?? {};

  return {
    tokenTotals: {
      total: normalizePlanAutomationUsageSnapshotMetric(tokenTotals.total),
      input: normalizePlanAutomationUsageSnapshotMetric(tokenTotals.input),
      output: normalizePlanAutomationUsageSnapshotMetric(tokenTotals.output),
      cached: normalizePlanAutomationUsageSnapshotMetric(tokenTotals.cached),
      reasoning: normalizePlanAutomationUsageSnapshotMetric(tokenTotals.reasoning),
    },
    totalTokens: normalizePlanAutomationUsageSnapshotMetric(snapshot.totalTokens),
    retryCount: normalizePlanAutomationUsageSnapshotMetric(snapshot.retryCount),
    sourceWorkCount: normalizePlanAutomationUsageSnapshotMetric(snapshot.sourceWorkCount),
    processingStartedAt:
      typeof snapshot.processingStartedAt === 'string' && snapshot.processingStartedAt.trim()
        ? snapshot.processingStartedAt
        : null,
    processingEndedAt:
      typeof snapshot.processingEndedAt === 'string' && snapshot.processingEndedAt.trim()
        ? snapshot.processingEndedAt
        : null,
    processingEndedAtSource:
      typeof snapshot.processingEndedAtSource === 'string' && snapshot.processingEndedAtSource.trim()
        ? snapshot.processingEndedAtSource
        : null,
    processingDurationSeconds: Number.isFinite(Number(snapshot.processingDurationSeconds))
      ? Math.max(0, Math.round(Number(snapshot.processingDurationSeconds)))
      : null,
  };
}

export function mapPlanReleaseReviewMetadata(value: unknown): PlanReleaseReviewMetadata {
  if (typeof value === 'string' && value.trim()) {
    try {
      return planReleaseReviewMetadataSchema.parse(JSON.parse(value));
    } catch {
      return {};
    }
  }

  if (value && typeof value === 'object') {
    try {
      return planReleaseReviewMetadataSchema.parse(value);
    } catch {
      return {};
    }
  }

  return {};
}

export function mapPlanReleaseReviewRow(row: Record<string, unknown> | null | undefined, planItemId: number) {
  const metadata = mapPlanReleaseReviewMetadata(row?.metadata ?? null);

  return {
    id: row?.id ? Number(row.id) : null,
    planItemId,
    status: (row?.status ? String(row.status) : 'pending') as PlanReleaseReviewStatus,
    reviewNote: row?.review_note ? String(row.review_note) : '',
    checkedByClientId: row?.checked_by_client_id ? String(row.checked_by_client_id) : null,
    checkedByNickname: row?.checked_by_nickname ? String(row.checked_by_nickname) : null,
    checkedAt: row?.checked_at ? String(row.checked_at) : null,
    metadata,
    createdAt: row?.created_at ? String(row.created_at) : null,
    updatedAt: row?.updated_at ? String(row.updated_at) : null,
  };
}

function extractAutomationTokenUsageText(sourceWork: Pick<ReturnType<typeof mapPlanSourceWorkRow>, 'summary' | 'commandLog'>) {
  const candidates = [
    typeof sourceWork.commandLog === 'string' ? sourceWork.commandLog : null,
    typeof sourceWork.summary === 'string' ? sourceWork.summary : null,
  ];

  for (const candidate of candidates) {
    if (!candidate) {
      continue;
    }

    const line = candidate
      .split('\n')
      .map((entry) => entry.trim())
      .find((entry) => /^토큰 사용량:\s*/.test(entry));

    if (line) {
      return line.replace(/^토큰 사용량:\s*/u, '').trim();
    }
  }

  return null;
}

function parseTokenMetricValue(valueText: string, unitText: string | undefined) {
  const normalizedValue = Number(String(valueText ?? '').replace(/,/g, ''));

  if (!Number.isFinite(normalizedValue)) {
    return null;
  }

  const unit = String(unitText ?? '').trim().toLowerCase();

  if (unit === 'k') {
    return Math.round(normalizedValue * 1_000);
  }

  if (unit === 'm') {
    return Math.round(normalizedValue * 1_000_000);
  }

  return Math.round(normalizedValue);
}

function parseAutomationTokenUsageMetrics(tokenUsageText: string) {
  const normalizedText = tokenUsageText
    .replace(/^tokens?\s+used\s*:?\s*/iu, '')
    .replace(/\(([^)]+)\)/g, ', $1')
    .trim();
  const metrics = new Map<string, number>();

  for (const match of normalizedText.matchAll(/(\d[\d,]*(?:\.\d+)?)\s*([km]?)\s*(input|output|total|cached|reasoning)\b/giu)) {
    const label = match[3]?.toLowerCase() ?? '';
    const value = parseTokenMetricValue(match[1] ?? '', match[2]);

    if (!label || value === null) {
      continue;
    }

    metrics.set(label, value);
  }

  for (const match of normalizedText.matchAll(/\b(input|output|total|cached|reasoning)\s*[:=]?\s*(\d[\d,]*(?:\.\d+)?)\s*([km]?)/giu)) {
    const label = match[1]?.toLowerCase() ?? '';
    const value = parseTokenMetricValue(match[2] ?? '', match[3]);

    if (!label || value === null) {
      continue;
    }

    metrics.set(label, value);
  }

  if (metrics.size > 0) {
    return metrics;
  }

  const fallbackMatch = normalizedText.match(/(\d[\d,]*(?:\.\d+)?)\s*([km]?)/i);

  if (!fallbackMatch) {
    return null;
  }

  const fallbackValue = parseTokenMetricValue(fallbackMatch[1], fallbackMatch[2]);

  if (fallbackValue === null) {
    return null;
  }

  return new Map([['total', fallbackValue]]);
}

function getAutomationTotalTokenCount(metrics: Map<string, number>) {
  const total = metrics.get('total');

  if (typeof total === 'number' && Number.isFinite(total)) {
    return total;
  }

  return ['input', 'output', 'cached', 'reasoning'].reduce((sum, key) => sum + (metrics.get(key) ?? 0), 0);
}

function isPlanAutomationWorkerActive(workerStatus: unknown) {
  return [
    '브랜치생성중',
    '브랜치준비',
    '자동작업중',
    'release반영대기',
    'release반영중',
    'main반영대기',
    'main반영중',
  ].includes(String(workerStatus ?? '').trim());
}

async function getLatestPlanReviewCheckedAt(planItemId: number) {
  const row = await db(PLAN_RELEASE_REVIEW_TABLE)
    .select('checked_at', 'updated_at', 'id')
    .where({ plan_item_id: planItemId })
    .orderBy([
      { column: 'updated_at', order: 'desc' },
      { column: 'id', order: 'desc' },
    ])
    .first();

  return row?.checked_at ? String(row.checked_at) : null;
}

function resolvePlanProcessingEndedAt(
  row: Record<string, unknown>,
  reviewCheckedAt: string | null,
): { endedAt: string | null; source: string | null } {
  if (reviewCheckedAt) {
    return {
      endedAt: reviewCheckedAt,
      source: 'review_checked_at',
    };
  }

  if (row.merged_at) {
    return {
      endedAt: String(row.merged_at),
      source: 'merged_at',
    };
  }

  if (row.completed_at) {
    return {
      endedAt: String(row.completed_at),
      source: 'completed_at',
    };
  }

  if (row.updated_at && row.started_at && !isPlanAutomationWorkerActive(row.worker_status)) {
    return {
      endedAt: String(row.updated_at),
      source: 'updated_at',
    };
  }

  if (row.started_at && isPlanAutomationWorkerActive(row.worker_status)) {
    return {
      endedAt: new Date().toISOString(),
      source: 'in_progress',
    };
  }

  return {
    endedAt: null,
    source: null,
  };
}

function buildPlanAutomationUsageSnapshot(
  sourceWorks: Array<Pick<ReturnType<typeof mapPlanSourceWorkRow>, 'summary' | 'commandLog' | 'createdAt'>>,
  row: Record<string, unknown>,
  reviewCheckedAt: string | null,
): PlanAutomationUsageSnapshot | null {
  const totals = new Map<string, number>();

  sourceWorks.forEach((sourceWork) => {
    const tokenUsageText = extractAutomationTokenUsageText(sourceWork);

    if (!tokenUsageText) {
      return;
    }

    const metrics = parseAutomationTokenUsageMetrics(tokenUsageText);

    if (!metrics) {
      return;
    }

    metrics.forEach((value, key) => {
      totals.set(key, (totals.get(key) ?? 0) + value);
    });
  });

  const sourceWorkCount = sourceWorks.length;
  const processingStartedAt =
    row.started_at ? String(row.started_at) : sourceWorks[0]?.createdAt ? String(sourceWorks[0].createdAt) : null;
  const { endedAt, source } = resolvePlanProcessingEndedAt(row, reviewCheckedAt);
  const processingDurationSeconds =
    processingStartedAt && endedAt
      ? Math.max(
          0,
          Math.round((new Date(endedAt).getTime() - new Date(processingStartedAt).getTime()) / 1_000),
        )
      : null;
  const totalTokens = getAutomationTotalTokenCount(totals);

  if (sourceWorkCount === 0 && !processingStartedAt && totalTokens === 0) {
    return null;
  }

  return {
    tokenTotals: {
      total: Math.max(0, totals.get('total') ?? 0),
      input: Math.max(0, totals.get('input') ?? 0),
      output: Math.max(0, totals.get('output') ?? 0),
      cached: Math.max(0, totals.get('cached') ?? 0),
      reasoning: Math.max(0, totals.get('reasoning') ?? 0),
    },
    totalTokens,
    retryCount: Math.max(0, sourceWorkCount - 1),
    sourceWorkCount,
    processingStartedAt,
    processingEndedAt: endedAt,
    processingEndedAtSource: source,
    processingDurationSeconds,
  };
}

export async function syncPlanAutomationUsageSnapshot(planItemId: number) {
  await ensurePlanTable();

  const row = await db(PLAN_TABLE).where({ id: planItemId }).first();

  if (!row) {
    return null;
  }

  const sourceWorkRows = await db(PLAN_SOURCE_WORK_TABLE)
    .select('summary', 'command_log', 'created_at')
    .where({ plan_item_id: planItemId })
    .orderBy('created_at', 'asc')
    .orderBy('id', 'asc');
  const reviewCheckedAt = await getLatestPlanReviewCheckedAt(planItemId);
  const snapshot = buildPlanAutomationUsageSnapshot(
    sourceWorkRows.map((sourceWorkRow) => ({
      summary: sourceWorkRow.summary,
      commandLog: sourceWorkRow.command_log,
      createdAt: String(sourceWorkRow.created_at),
    })),
    row,
    reviewCheckedAt,
  );

  await db(PLAN_TABLE)
    .where({ id: planItemId })
    .update({
      usage_snapshot: snapshot ? JSON.stringify(snapshot) : null,
    });

  return snapshot;
}

async function listPlanReleaseReviewNoteMap(planItemIds: number[]) {
  if (planItemIds.length === 0) {
    return new Map<number, string>();
  }

  const rows = await db(PLAN_RELEASE_REVIEW_TABLE)
    .select('plan_item_id', 'review_note', 'updated_at', 'id')
    .whereIn('plan_item_id', planItemIds)
    .orderBy([
      { column: 'updated_at', order: 'desc' },
      { column: 'id', order: 'desc' },
    ]);

  const noteMap = new Map<number, string>();

  for (const row of rows) {
    const planItemId = Number(row.plan_item_id);

    if (!Number.isFinite(planItemId) || noteMap.has(planItemId)) {
      continue;
    }

    noteMap.set(planItemId, row.review_note ? String(row.review_note) : '');
  }

  return noteMap;
}

async function ensurePlanFailureIssueHistory(planItemId: number) {
  const planRow = await db(PLAN_TABLE).where({ id: planItemId }).first();

  if (!planRow || !planRow.worker_status || !planFailureWorkerStatuses.has(planRow.worker_status)) {
    return;
  }

  if (!planRow.last_error) {
    return;
  }

  const existingIssue = await db(PLAN_ISSUE_TABLE)
    .where({
      plan_item_id: planItemId,
      issue_tag: `#${planRow.worker_status}`,
      message: planRow.last_error,
    })
    .first();

  if (existingIssue) {
    return;
  }

  await db(PLAN_ISSUE_TABLE).insert({
    plan_item_id: planItemId,
    issue_tag: `#${planRow.worker_status}`,
    message: planRow.last_error,
    resolved: false,
  });
}

async function ensureColumn(
  columnName: string,
  addColumn: (table: any) => void,
) {
  const hasColumn = await db.schema.hasColumn(PLAN_TABLE, columnName);

  if (hasColumn) {
    return;
  }

  await db.schema.alterTable(PLAN_TABLE, (table) => {
    addColumn(table);
  });
}

async function syncPlanColumns() {
  const hasIssueNoteColumn = await db.schema.hasColumn(PLAN_TABLE, 'issue_note');

  if (hasIssueNoteColumn) {
    await db.schema.alterTable(PLAN_TABLE, (table) => {
      table.dropColumn('issue_note');
    });
  }

  await ensureColumn('assigned_branch', (table) => {
    table.string('assigned_branch', 200).nullable();
  });
  await ensureColumn('release_target', (table) => {
    table.string('release_target', 120).notNullable().defaultTo('release');
  });
  await ensureColumn('automation_type', (table) => {
    table.string('automation_type', 40).notNullable().defaultTo('none');
  });
  await ensureColumn('automation_type_id', (table) => {
    table.string('automation_type_id', 120).nullable();
  });
  await ensureColumn('automation_context_ids_json', (table) => {
    table.text('automation_context_ids_json').notNullable().defaultTo('[]');
  });
  await ensureColumn('auto_deploy_to_main', (table) => {
    table.boolean('auto_deploy_to_main').notNullable().defaultTo(true);
  });
  await ensureColumn('suppress_web_push', (table) => {
    table.boolean('suppress_web_push').notNullable().defaultTo(false);
  });
  await ensureColumn('repeat_request_enabled', (table) => {
    table.boolean('repeat_request_enabled').notNullable().defaultTo(false);
  });
  await ensureColumn('repeat_interval_minutes', (table) => {
    table.integer('repeat_interval_minutes').notNullable().defaultTo(60);
  });
  await ensureColumn('normal_processing_level', (table) => {
    table.string('normal_processing_level', 20).notNullable().defaultTo('중');
  });
  await ensureColumn('jangsing_processing_required', (table) => {
    table.boolean('jangsing_processing_required').notNullable().defaultTo(true);
  });
  await ensureColumn('worker_status', (table) => {
    table.string('worker_status', 80).nullable();
  });
  await ensureColumn('last_error', (table) => {
    table.text('last_error').nullable();
  });
  await ensureColumn('locked_by', (table) => {
    table.string('locked_by', 120).nullable();
  });
  await ensureColumn('locked_at', (table) => {
    table.timestamp('locked_at', { useTz: true }).nullable();
  });
  await ensureColumn('started_at', (table) => {
    table.timestamp('started_at', { useTz: true }).nullable();
  });
  await ensureColumn('completed_at', (table) => {
    table.timestamp('completed_at', { useTz: true }).nullable();
  });
  await ensureColumn('merged_at', (table) => {
    table.timestamp('merged_at', { useTz: true }).nullable();
  });
  await ensureColumn('usage_snapshot', (table) => {
    table.text('usage_snapshot').nullable();
  });

  await db(PLAN_TABLE)
    .where({ automation_type: 'plan_registration' })
    .update({ automation_type: 'plan' });
  await db(PLAN_TABLE)
    .where({ automation_type: 'general_development' })
    .update({ automation_type: 'auto_worker' });
  await db(PLAN_TABLE)
    .whereNull('automation_type_id')
    .update({
      automation_type_id: db.raw('automation_type'),
    });
}

async function dropPlanWorkIdUniqueConstraint() {
  await db.raw(
    `
      do $$
      declare constraint_name text;
      begin
        for constraint_name in
          select con.conname
          from pg_constraint con
          join pg_class rel on rel.oid = con.conrelid
          join pg_namespace nsp on nsp.oid = rel.relnamespace
          join pg_attribute att on att.attrelid = rel.oid and att.attnum = any(con.conkey)
          where nsp.nspname = current_schema()
            and rel.relname = '${PLAN_TABLE}'
            and con.contype = 'u'
            and att.attname = 'work_id'
        loop
          execute format('alter table %I.%I drop constraint %I', current_schema(), '${PLAN_TABLE}', constraint_name);
        end loop;
      end
      $$;
    `,
  );
}

async function ensurePlanIssueTable() {
  const exists = await db.schema.hasTable(PLAN_ISSUE_TABLE);

  if (exists) {
    return;
  }

  await db.schema.createTable(PLAN_ISSUE_TABLE, (table) => {
    table.increments('id').primary();
    table.integer('plan_item_id').notNullable().index();
    table.string('issue_tag', 120).notNullable();
    table.text('message').notNullable();
    table.text('action_note').nullable();
    table.boolean('resolved').notNullable().defaultTo(false);
    table.timestamp('resolved_at', { useTz: true }).nullable();
    table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
  });
}

async function ensurePlanActionTable() {
  const exists = await db.schema.hasTable(PLAN_ACTION_TABLE);

  if (exists) {
    return;
  }

  await db.schema.createTable(PLAN_ACTION_TABLE, (table) => {
    table.increments('id').primary();
    table.integer('plan_item_id').notNullable().index();
    table.string('action_type', 120).notNullable();
    table.text('note').notNullable();
    table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
  });
}

async function ensurePlanSourceWorkTable() {
  const exists = await db.schema.hasTable(PLAN_SOURCE_WORK_TABLE);

  if (exists) {
    const hasPreviewUrlColumn = await db.schema.hasColumn(PLAN_SOURCE_WORK_TABLE, 'preview_url');

    if (!hasPreviewUrlColumn) {
      await db.schema.alterTable(PLAN_SOURCE_WORK_TABLE, (table) => {
        table.string('preview_url', 2000).nullable();
      });
    }

    const hasSourceFilesColumn = await db.schema.hasColumn(PLAN_SOURCE_WORK_TABLE, 'source_files');

    if (!hasSourceFilesColumn) {
      await db.schema.alterTable(PLAN_SOURCE_WORK_TABLE, (table) => {
        table.text('source_files').notNullable().defaultTo('[]');
      });
    }
    return;
  }

  await db.schema.createTable(PLAN_SOURCE_WORK_TABLE, (table) => {
    table.increments('id').primary();
    table.integer('plan_item_id').notNullable().index();
    table.text('summary').notNullable();
    table.string('branch_name', 200).notNullable();
    table.string('commit_hash', 80).nullable();
    table.string('preview_url', 2000).nullable();
    table.text('changed_files').notNullable().defaultTo('[]');
    table.text('command_log').nullable();
    table.text('diff_text').nullable();
    table.text('source_files').notNullable().defaultTo('[]');
    table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
  });
}

async function ensurePlanReleaseReviewTable() {
  const exists = await db.schema.hasTable(PLAN_RELEASE_REVIEW_TABLE);

  if (!exists) {
    await db.schema.createTable(PLAN_RELEASE_REVIEW_TABLE, (table) => {
      table.increments('id').primary();
      table.integer('plan_item_id').notNullable().unique().index();
      table.string('status', 40).notNullable().defaultTo('pending');
      table.text('review_note').notNullable().defaultTo('');
      table.string('checked_by_client_id', 120).nullable();
      table.string('checked_by_nickname', 80).nullable();
      table.timestamp('checked_at', { useTz: true }).nullable();
      table.text('metadata').notNullable().defaultTo('{}');
      table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
      table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
    });

    return;
  }

  const requiredColumns: Array<[string, (table: any) => void]> = [
    ['plan_item_id', (table) => table.integer('plan_item_id').notNullable().unique().index()],
    ['status', (table) => table.string('status', 40).notNullable().defaultTo('pending')],
    ['review_note', (table) => table.text('review_note').notNullable().defaultTo('')],
    ['checked_by_client_id', (table) => table.string('checked_by_client_id', 120).nullable()],
    ['checked_by_nickname', (table) => table.string('checked_by_nickname', 80).nullable()],
    ['checked_at', (table) => table.timestamp('checked_at', { useTz: true }).nullable()],
    ['metadata', (table) => table.text('metadata').notNullable().defaultTo('{}')],
    ['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
    ['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
  ];

  for (const [columnName, createColumn] of requiredColumns) {
    const hasColumn = await db.schema.hasColumn(PLAN_RELEASE_REVIEW_TABLE, columnName);

    if (!hasColumn) {
      await db.schema.alterTable(PLAN_RELEASE_REVIEW_TABLE, (table) => {
        createColumn(table);
      });
    }
  }
}

export async function ensurePlanTable() {
  const exists = await db.schema.hasTable(PLAN_TABLE);

  if (!exists) {
    try {
      await db.schema.createTable(PLAN_TABLE, (table) => {
        table.increments('id').primary();
        table.string('work_id', 120).notNullable().unique();
        table.text('note').notNullable().defaultTo('');
        table.string('status', 40).notNullable().defaultTo('등록');
        table.boolean('jangsing_processing_required').notNullable().defaultTo(true);
        table.boolean('auto_deploy_to_main').notNullable().defaultTo(true);
        table.boolean('suppress_web_push').notNullable().defaultTo(false);
        table.string('assigned_branch', 200).nullable();
        table.string('release_target', 120).notNullable().defaultTo('release');
        table.string('worker_status', 80).nullable();
        table.text('last_error').nullable();
        table.string('locked_by', 120).nullable();
        table.timestamp('locked_at', { useTz: true }).nullable();
        table.timestamp('started_at', { useTz: true }).nullable();
        table.timestamp('completed_at', { useTz: true }).nullable();
        table.timestamp('merged_at', { useTz: true }).nullable();
        table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
        table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
      });
    } catch (error) {
      const dbError = error as { code?: string };

      if (dbError.code !== '42P07' && dbError.code !== '23505') {
        throw error;
      }
    }
  }

  await syncPlanColumns();
  await dropPlanWorkIdUniqueConstraint();
  await ensurePlanIssueTable();
  await ensurePlanActionTable();
  await ensurePlanSourceWorkTable();
  await ensurePlanReleaseReviewTable();
  await db(PLAN_TABLE)
    .whereNull('jangsing_processing_required')
    .update({
      jangsing_processing_required: true,
    });
  await db(PLAN_TABLE)
    .whereNull('suppress_web_push')
    .update({
      suppress_web_push: false,
    });
  await db(PLAN_TABLE)
    .whereIn('status', ['작업완료', '릴리즈완료', '완료'] as never[])
    .whereNull('completed_at')
    .update({
      completed_at: db.raw('coalesce(merged_at, updated_at, created_at)'),
      updated_at: db.fn.now(),
    });
  await db(PLAN_TABLE)
    .where({ status: '이슈' as never })
    .update({
      status: '등록',
      worker_status: db.raw("coalesce(worker_status, '브랜치실패')"),
      updated_at: db.fn.now(),
    });
  await db(PLAN_TABLE)
    .where({ status: '개발완료' as never })
    .update({
      status: '작업완료',
      worker_status: db.raw("case when worker_status in ('병합대기','병합중') then 'release반영대기' when worker_status='병합완료' then 'main반영완료' else worker_status end"),
      updated_at: db.fn.now(),
    });
  await db(PLAN_TABLE)
    .whereIn('worker_status', ['release반영완료', 'main반영대기', 'main반영중', 'main반영실패'] as never[])
    .whereNot({ status: '완료' as never })
    .update({
      status: '릴리즈완료',
      updated_at: db.fn.now(),
    });
}

export async function createPlanItem(payload: z.infer<typeof createPlanSchema>) {
  await ensurePlanTable();
  const workId = normalizePlanWorkId(payload.workId);
  const automationType = await resolveAutomationType(payload.automationType);

  const rows = await db(PLAN_TABLE)
    .insert({
      work_id: workId,
      note: payload.note,
      status: '등록',
      automation_type: automationType.behaviorType,
      automation_type_id: automationType.id,
      automation_context_ids_json: stringifyAutomationContextIds(payload.automationContextIds),
      release_target: payload.releaseTarget,
      jangsing_processing_required: payload.jangsingProcessingRequired,
      auto_deploy_to_main: payload.autoDeployToMain,
      suppress_web_push: payload.suppressWebPush,
      repeat_request_enabled: payload.repeatRequestEnabled,
      repeat_interval_minutes: payload.repeatIntervalMinutes,
      worker_status: '대기',
      last_error: null,
      updated_at: db.fn.now(),
    })
    .returning('*');

  return rows[0];
}

export async function createCompletedPlanExecutionLogItem(payload: z.infer<typeof createPlanSchema>) {
  await ensurePlanTable();
  const workId = normalizePlanWorkId(payload.workId);
  const automationType = await resolveAutomationType(payload.automationType);

  const rows = await db(PLAN_TABLE)
    .insert({
      work_id: workId,
      note: payload.note,
      status: '완료',
      automation_type: automationType.behaviorType,
      automation_type_id: automationType.id,
      automation_context_ids_json: stringifyAutomationContextIds(payload.automationContextIds),
      release_target: payload.releaseTarget,
      jangsing_processing_required: payload.jangsingProcessingRequired,
      auto_deploy_to_main: payload.autoDeployToMain,
      suppress_web_push: payload.suppressWebPush,
      repeat_request_enabled: payload.repeatRequestEnabled,
      repeat_interval_minutes: payload.repeatIntervalMinutes,
      worker_status: '자동완료',
      last_error: null,
      started_at: db.fn.now(),
      completed_at: db.fn.now(),
      updated_at: db.fn.now(),
    })
    .returning('*');

  if (rows[0]?.id) {
    await syncPlanAutomationUsageSnapshot(Number(rows[0].id));
  }

  return rows[0];
}

export async function upsertAutoPlanItem(args: {
  workId: string;
  note: string;
  releaseTarget: string;
  jangsingProcessingRequired: boolean;
  autoDeployToMain: boolean;
  suppressWebPush?: boolean;
  automationType?: PlanAutomationType;
  automationContextIds?: string[];
  requeue: boolean;
}) {
  await ensurePlanTable();

  const workId = normalizePlanWorkId(args.workId);
  const existingRow = await db(PLAN_TABLE)
    .where({ work_id: workId })
    .orderBy('id', 'desc')
    .first();

  if (!existingRow) {
    const automationType = await resolveAutomationType(args.automationType);
    return {
      row: await createPlanItem({
        workId,
        note: args.note,
        automationType: automationType.id,
        automationContextIds: args.automationContextIds ?? [],
        releaseTarget: args.releaseTarget,
        jangsingProcessingRequired: args.jangsingProcessingRequired,
        autoDeployToMain: args.autoDeployToMain,
        suppressWebPush: args.suppressWebPush ?? false,
        repeatRequestEnabled: false,
        repeatIntervalMinutes: 60,
      }),
      action: 'created' as const,
    };
  }

  const nextReleaseTarget = args.releaseTarget || existingRow.release_target || 'release';
  const nextAutomationType = await resolveAutomationType(args.automationType ?? existingRow.automation_type_id ?? existingRow.automation_type);
  const nextJangsingProcessingRequired = args.jangsingProcessingRequired;
  const nextAutoDeployToMain = args.autoDeployToMain;
  const nextSuppressWebPush = args.suppressWebPush ?? Boolean(existingRow.suppress_web_push ?? false);
  const nextNote = args.note;
  const nextAutomationContextIds = args.automationContextIds ?? parseAutomationContextIds(existingRow.automation_context_ids_json);
  const currentJangsingProcessingRequired =
    typeof existingRow.jangsing_processing_required === 'boolean'
      ? existingRow.jangsing_processing_required
      : existingRow.normal_processing_level === '상';
  const hasPayloadChange =
    existingRow.note !== nextNote ||
    String(existingRow.automation_type_id ?? existingRow.automation_type ?? 'none') !== nextAutomationType.id ||
    (existingRow.release_target ?? 'release') !== nextReleaseTarget ||
    Boolean(existingRow.auto_deploy_to_main ?? true) !== nextAutoDeployToMain ||
    Boolean(existingRow.suppress_web_push ?? false) !== nextSuppressWebPush ||
    JSON.stringify(parseAutomationContextIds(existingRow.automation_context_ids_json)) !== JSON.stringify(nextAutomationContextIds) ||
    Boolean(currentJangsingProcessingRequired) !== nextJangsingProcessingRequired;

  if (!args.requeue) {
    if (!hasPayloadChange || existingRow.status !== '등록') {
      return {
        row: existingRow,
        action: 'unchanged' as const,
      };
    }

    const rows = await db(PLAN_TABLE)
      .where({ id: existingRow.id })
      .update({
        note: nextNote,
        automation_type: nextAutomationType.behaviorType,
        automation_type_id: nextAutomationType.id,
        automation_context_ids_json: stringifyAutomationContextIds(nextAutomationContextIds),
        release_target: nextReleaseTarget,
        jangsing_processing_required: nextJangsingProcessingRequired,
        auto_deploy_to_main: nextAutoDeployToMain,
        suppress_web_push: nextSuppressWebPush,
        updated_at: db.fn.now(),
      })
      .returning('*');

    return {
      row: rows[0],
      action: 'updated' as const,
    };
  }

  const rows = await db(PLAN_TABLE)
    .where({ id: existingRow.id })
    .update({
      work_id: workId,
      note: nextNote,
      status: '등록',
      assigned_branch: null,
      automation_type: nextAutomationType.behaviorType,
      automation_type_id: nextAutomationType.id,
      automation_context_ids_json: stringifyAutomationContextIds(nextAutomationContextIds),
      release_target: nextReleaseTarget,
      jangsing_processing_required: nextJangsingProcessingRequired,
      auto_deploy_to_main: nextAutoDeployToMain,
      suppress_web_push: nextSuppressWebPush,
      worker_status: '대기',
      last_error: null,
      locked_by: null,
      locked_at: null,
      started_at: null,
      completed_at: null,
      merged_at: null,
      updated_at: db.fn.now(),
    })
    .returning('*');

  await createPlanActionHistory(
    Number(existingRow.id),
    '자동갱신',
    '업무일지 자동화 설정 기준으로 Plan 항목을 최신 상태로 갱신했습니다.',
  );
  await syncPlanAutomationUsageSnapshot(Number(existingRow.id));

  return {
    row: rows[0],
    action: 'requeued' as const,
  };
}

export async function updatePlanItem(id: number, payload: z.infer<typeof updatePlanSchema>) {
  await ensurePlanTable();

  const currentRow = await db(PLAN_TABLE).where({ id }).first();

  if (!currentRow) {
    return null;
  }

  const nextWorkId =
    payload.workId === undefined ? currentRow.work_id : normalizePlanWorkId(payload.workId);
  const nextAutomationType = await resolveAutomationType(
    payload.automationType ?? currentRow.automation_type_id ?? currentRow.automation_type,
  );
  const nextReleaseTarget = payload.releaseTarget ?? currentRow.release_target ?? 'release';
  const nextJangsingProcessingRequired =
    payload.jangsingProcessingRequired ??
    (typeof currentRow.jangsing_processing_required === 'boolean'
      ? currentRow.jangsing_processing_required
      : currentRow.normal_processing_level === '상');
  const nextAutoDeployToMain = payload.autoDeployToMain ?? currentRow.auto_deploy_to_main ?? true;
  const nextSuppressWebPush = payload.suppressWebPush ?? currentRow.suppress_web_push ?? false;
  const nextRepeatRequestEnabled = payload.repeatRequestEnabled ?? currentRow.repeat_request_enabled ?? false;
  const nextRepeatIntervalMinutes = payload.repeatIntervalMinutes ?? currentRow.repeat_interval_minutes ?? 60;
  const nextNote = payload.note ?? currentRow.note;
  const nextAutomationContextIds =
    payload.automationContextIds ?? parseAutomationContextIds(currentRow.automation_context_ids_json);

  const isOnlyJangsingUpdate =
    nextWorkId === currentRow.work_id &&
    nextAutomationType.id === String(currentRow.automation_type_id ?? currentRow.automation_type ?? 'none') &&
    nextReleaseTarget === (currentRow.release_target ?? 'release') &&
    nextAutoDeployToMain === (currentRow.auto_deploy_to_main ?? true) &&
    nextSuppressWebPush === (currentRow.suppress_web_push ?? false) &&
    nextRepeatRequestEnabled === (currentRow.repeat_request_enabled ?? false) &&
    nextRepeatIntervalMinutes === (currentRow.repeat_interval_minutes ?? 60) &&
    JSON.stringify(parseAutomationContextIds(currentRow.automation_context_ids_json)) === JSON.stringify(nextAutomationContextIds) &&
    nextNote === currentRow.note;

  if (payload.jangsingProcessingRequired !== undefined && isOnlyJangsingUpdate) {
    return updatePlanItemJangsingProcessingRequired(id, nextJangsingProcessingRequired);
  }

  if (currentRow.started_at || currentRow.status !== '등록') {
    throw new Error('작업시작 이후에는 원본 요청을 수정할 수 없습니다. 추가 조치사항은 이력에 기록해 주세요.');
  }

  const rows = await db(PLAN_TABLE)
    .where({ id })
    .update({
      work_id: nextWorkId,
      note: nextNote,
      status: currentRow.status,
      automation_type: nextAutomationType.behaviorType,
      automation_type_id: nextAutomationType.id,
      automation_context_ids_json: stringifyAutomationContextIds(nextAutomationContextIds),
      release_target: nextReleaseTarget,
      jangsing_processing_required: nextJangsingProcessingRequired,
      auto_deploy_to_main: nextAutoDeployToMain,
      suppress_web_push: nextSuppressWebPush,
      repeat_request_enabled: nextRepeatRequestEnabled,
      repeat_interval_minutes: nextRepeatIntervalMinutes,
      worker_status: currentRow.worker_status,
      last_error: currentRow.last_error,
      completed_at: currentRow.completed_at,
      updated_at: db.fn.now(),
    })
    .returning('*');

  return rows[0];
}

export async function updatePlanItemJangsingProcessingRequired(
  id: number,
  jangsingProcessingRequired: boolean,
) {
  await ensurePlanTable();

  const currentRow = await db(PLAN_TABLE).where({ id }).first();

  if (!currentRow) {
    return null;
  }

  if (!functionCheckEditableStatuses.has(currentRow.status)) {
    throw new Error('기능동작확인은 작업완료 건만 수정할 수 있습니다.');
  }

  const rows = await db(PLAN_TABLE)
    .where({ id })
    .update({
      jangsing_processing_required: jangsingProcessingRequired,
      updated_at: db.fn.now(),
    })
    .returning('*');

  return rows[0] ?? null;
}

export async function deletePlanItem(id: number) {
  await ensurePlanTable();

  const currentRow = await db(PLAN_TABLE).where({ id }).first();

  if (!currentRow) {
    return null;
  }

  await db.transaction(async (trx) => {
    await trx(PLAN_SOURCE_WORK_TABLE).where({ plan_item_id: id }).delete();
    await trx(PLAN_ACTION_TABLE).where({ plan_item_id: id }).delete();
    await trx(PLAN_ISSUE_TABLE).where({ plan_item_id: id }).delete();
    await trx(PLAN_TABLE).where({ id }).delete();
  });

  return currentRow;
}

export async function getBoardPostLinkedToPlanItem(planItemId: number) {
  const boardPostsTable = 'board_posts';
  const boardPostRequestsTable = 'board_post_requests';
  const hasBoardPostsTable = await db.schema.hasTable(boardPostsTable);

  if (!hasBoardPostsTable) {
    return null;
  }

  const hasBoardPostRequestsTable = await db.schema.hasTable(boardPostRequestsTable);

  if (hasBoardPostRequestsTable) {
    const linkedRequestRow = await db(boardPostRequestsTable)
      .join(boardPostsTable, `${boardPostRequestsTable}.board_post_id`, `${boardPostsTable}.id`)
      .select(`${boardPostsTable}.id`, `${boardPostsTable}.title`)
      .where(`${boardPostRequestsTable}.plan_item_id`, planItemId)
      .first();

    if (linkedRequestRow) {
      return linkedRequestRow;
    }
  }

  return db(boardPostsTable).select('id', 'title').where({ automation_plan_item_id: planItemId }).first();
}

export async function markPlanAsDevelopmentComplete(id: number) {
  await ensurePlanTable();

  const currentRow = await db(PLAN_TABLE).where({ id }).first();

  if (!currentRow) {
    return null;
  }

  const rows = await db(PLAN_TABLE)
    .where({ id })
    .update({
      status: '작업완료',
      jangsing_processing_required: true,
      worker_status: 'release반영대기',
      last_error: null,
      locked_by: null,
      locked_at: null,
      completed_at: currentRow.completed_at ?? db.fn.now(),
      updated_at: db.fn.now(),
    })
    .returning('*');

  await createPlanLifecycleSourceWorkHistory(
    id,
    '작업완료 처리로 release 반영 대기 상태로 전환했습니다.',
    currentRow.assigned_branch ?? currentRow.release_target ?? 'release',
    currentRow.assigned_branch
      ? `현재 작업 브랜치: ${currentRow.assigned_branch}`
      : `release 대기 브랜치: ${currentRow.release_target ?? 'release'}`,
  );
  await createPlanActionHistory(id, '작업완료', '작업완료 처리');
  await resolveAutomationIssueHistories(id);
  await syncPlanAutomationUsageSnapshot(id);

  return rows[0];
}

export async function markPlanAsCompleted(
  id: number,
  note?: string,
  workerStatus: Extract<PlanWorkerStatus, '자동완료' | 'main반영완료'> = '자동완료',
) {
  await ensurePlanTable();

  const currentRow = await db(PLAN_TABLE).where({ id }).first();

  if (!currentRow) {
    return null;
  }

  const rows = await db(PLAN_TABLE)
    .where({ id })
    .update({
      status: '완료',
      worker_status: workerStatus,
      last_error: null,
      locked_by: null,
      locked_at: null,
      completed_at: currentRow.completed_at ?? db.fn.now(),
      updated_at: db.fn.now(),
    })
    .returning('*');

  const sourceWorkCountRow = await db(PLAN_SOURCE_WORK_TABLE)
    .where({ plan_item_id: id })
    .count<{ count: string }>('id as count')
    .first();
  const sourceWorkCount = Number(sourceWorkCountRow?.count ?? 0);

  if (sourceWorkCount === 0 && !shouldSkipLifecycleSourceWork(currentRow)) {
    await createPlanLifecycleSourceWorkHistory(
      id,
      note ?? '작업 결과를 검토하고 완료 처리했습니다.',
      currentRow.assigned_branch ?? currentRow.release_target ?? getEnv().PLAN_MAIN_BRANCH,
      currentRow.assigned_branch
        ? `완료 기준 브랜치: ${currentRow.assigned_branch}`
        : `완료 기준 브랜치: ${currentRow.release_target ?? getEnv().PLAN_MAIN_BRANCH}`,
    );
  }

  await createPlanActionHistory(id, '완료처리', note ?? '작업을 완료 처리했습니다.');
  await resolveAutomationIssueHistories(id);
  await syncPlanAutomationUsageSnapshot(id);

  return rows[0];
}

export async function markPlanAsStarted(id: number) {
  await ensurePlanTable();

  const currentRow = await db(PLAN_TABLE).where({ id }).first();

  if (!currentRow) {
    return null;
  }

  const rows = await db(PLAN_TABLE)
    .where({ id })
    .update({
      status: '작업중',
      worker_status: currentRow.worker_status === '대기' ? null : currentRow.worker_status,
      last_error: currentRow.last_error,
      started_at: currentRow.started_at ?? db.fn.now(),
      updated_at: db.fn.now(),
    })
    .returning('*');

  await createPlanActionHistory(id, '작업시작', '작업을 시작했습니다.');
  await syncPlanAutomationUsageSnapshot(id);

  return rows[0];
}

export async function retryPlanBranch(id: number) {
  await ensurePlanTable();

  const currentRow = await db(PLAN_TABLE).where({ id }).first();

  if (!currentRow) {
    return null;
  }

  const rows = await db(PLAN_TABLE)
    .where({ id })
    .update({
      status: '등록',
      worker_status: '대기',
      last_error: null,
      locked_by: null,
      locked_at: null,
      updated_at: db.fn.now(),
    })
    .returning('*');

  await createPlanActionHistory(id, '브랜치재시도', '브랜치 재시도를 요청했습니다.');
  await syncPlanAutomationUsageSnapshot(id);

  return rows[0];
}

export async function retryPlanMerge(id: number) {
  await ensurePlanTable();

  const currentRow = await db(PLAN_TABLE).where({ id }).first();

  if (!currentRow) {
    return null;
  }

  const isMainRetry =
    currentRow.worker_status === 'main반영실패' ||
    currentRow.worker_status === 'main반영대기' ||
    currentRow.worker_status === 'main반영중' ||
    currentRow.status === '릴리즈완료';

  const rows = await db(PLAN_TABLE)
    .where({ id })
    .update({
      status: isMainRetry ? '릴리즈완료' : '작업완료',
      worker_status: isMainRetry ? 'main반영대기' : 'release반영대기',
      last_error: null,
      locked_by: null,
      locked_at: null,
      updated_at: db.fn.now(),
    })
    .returning('*');

  await createPlanActionHistory(
    id,
    isMainRetry ? 'main반영재시도' : 'release반영재시도',
    isMainRetry ? 'main 일괄 반영 재시도를 요청했습니다.' : 'release 반영 재시도를 요청했습니다.',
  );
  await syncPlanAutomationUsageSnapshot(id);

  return rows[0];
}

export async function requestPlanMainMerge(id: number) {
  await ensurePlanTable();

  const currentRow = await db(PLAN_TABLE).where({ id }).first();

  if (!currentRow) {
    return null;
  }

  if (currentRow.status !== '릴리즈완료') {
    return {
      item: await getPlanItemById(id),
      message: 'main 반영 요청은 release 반영이 완료된 이후에만 가능합니다.',
    };
  }

  const releaseTarget = String(currentRow.release_target ?? 'release');
  const pendingRows = await db(PLAN_TABLE)
    .select('id')
    .where({ status: '릴리즈완료', release_target: releaseTarget });

  const targetIds = pendingRows.map((row) => Number(row.id)).filter(Number.isFinite);

  await db(PLAN_TABLE)
    .whereIn('id', targetIds.length > 0 ? targetIds : [id])
    .update({
      last_error: null,
      locked_by: null,
      locked_at: null,
      worker_status: 'main반영대기',
      updated_at: db.fn.now(),
    });

  await createPlanActionHistory(
    id,
    'main반영요청',
    `${releaseTarget} 브랜치 기준 main 일괄 반영을 요청했습니다.`,
  );
  await Promise.all((targetIds.length > 0 ? targetIds : [id]).map((targetId) => syncPlanAutomationUsageSnapshot(targetId)));

  return {
    item: await getPlanItemById(id),
    message: `${releaseTarget} 브랜치 기준으로 ${Math.max(targetIds.length, 1)}건 main 일괄 반영을 요청했습니다.`,
  };
}

export async function retryPlanWork(id: number) {
  await ensurePlanTable();

  const currentRow = await db(PLAN_TABLE).where({ id }).first();

  if (!currentRow) {
    return null;
  }

  const rows = await db(PLAN_TABLE)
    .where({ id })
    .update({
      status: '작업중',
      worker_status: '브랜치준비',
      last_error: null,
      locked_by: null,
      locked_at: null,
      updated_at: db.fn.now(),
    })
    .returning('*');

  await createPlanActionHistory(id, '작업재처리', '자동 작업 재처리를 요청했습니다.');

  return rows[0];
}

export async function isPlanLockedByWorker(id: number, workerId: string) {
  await ensurePlanTable();

  const row = await db(PLAN_TABLE)
    .select('id')
    .where({ id, locked_by: workerId })
    .first();

  return Boolean(row);
}

export async function resumePlanDevelopmentFromRelease(id: number, actionNote: string) {
  await ensurePlanTable();

  const currentRow = await db(PLAN_TABLE).where({ id }).first();

  if (!currentRow) {
    return null;
  }

  if (currentRow.status !== '릴리즈완료') {
    return {
      didScheduleRetry: false,
      item: await getPlanItemById(id),
      message: null,
    };
  }

  const rows = await db(PLAN_TABLE)
    .where({ id })
    .update({
      status: '작업중',
      worker_status: '브랜치준비',
      last_error: null,
      locked_by: null,
      locked_at: null,
      merged_at: null,
      updated_at: db.fn.now(),
    })
    .returning('*');

  await syncPlanAutomationUsageSnapshot(id);

  await createPlanActionHistory(
    id,
    '릴리즈추가조치',
    `release 상태 추가 조치로 개발을 재개했습니다.\n${actionNote}`,
  );

  return {
    didScheduleRetry: false,
    item: await getPlanItemById(id),
    message: 'release 상태의 추가 조치를 반영해 추가 개발로 전환했습니다.',
    row: rows[0],
  };
}

export async function queuePlanRetryFromFailure(id: number, actionNote?: string) {
  await ensurePlanTable();

  const currentRow = await db(PLAN_TABLE).where({ id }).first();

  if (!currentRow) {
    return null;
  }

  return {
    didScheduleRetry: false,
    item: await getPlanItemById(id),
    message: shouldTriggerRetryFromActionNote(actionNote ?? '')
      ? '조치 이력을 저장했습니다. 자동 재시작은 하지 않았습니다. 필요하면 재시도 액션을 직접 실행해 주세요.'
      : '조치 이력을 저장했습니다. 자동화는 중단된 상태로 유지합니다.',
  };
}

export function shouldResumePlanDevelopmentFromIssueAction(
  status: PlanStatus | string | null | undefined,
  retry: boolean,
) {
  return retry && status === '릴리즈완료';
}

async function queuePlanRetryForCurrentStatus(id: number, workerStatus: string | null | undefined) {
  if (workerStatus === '자동작업중') {
    await retryPlanWork(id);
    return {
      didScheduleRetry: true,
      item: await getPlanItemById(id),
      message: '실행 중인 자동 작업을 정리하고 이슈 조치를 반영하도록 다시 실행합니다.',
    };
  }

  if (workerStatus === '브랜치실패') {
    await retryPlanBranch(id);
    return {
      didScheduleRetry: true,
      item: await getPlanItemById(id),
      message: '이슈 조치를 반영해 브랜치 재시도를 예약했습니다.',
    };
  }

  if (workerStatus === '자동작업실패') {
    await retryPlanWork(id);
    return {
      didScheduleRetry: true,
      item: await getPlanItemById(id),
      message: '이슈 조치를 반영해 자동 작업 재처리를 예약했습니다.',
    };
  }

  if (workerStatus === 'release반영실패') {
    await retryPlanMerge(id);
    return {
      didScheduleRetry: true,
      item: await getPlanItemById(id),
      message: '이슈 조치를 반영해 release 반영 재처리를 예약했습니다.',
    };
  }

  if (workerStatus === 'main반영실패') {
    return {
      didScheduleRetry: true,
      ...(await requestPlanMainMerge(id)),
    };
  }

  return {
    didScheduleRetry: false,
    item: await getPlanItemById(id),
    message: '이슈 조치 이력을 저장했고 자동 재처리 대상은 없습니다.',
  };
}

export async function queuePlanRetryFromIssueAction(id: number, actionNote: string, retry = false) {
  await ensurePlanTable();

  const currentRow = await db(PLAN_TABLE).where({ id }).first();

  if (!currentRow) {
    return null;
  }

  if (!retry) {
    return {
      didScheduleRetry: false,
      item: await getPlanItemById(id),
      message: '이슈 조치 이력을 저장했습니다. 재처리는 요청하지 않았습니다.',
    };
  }

  if (shouldResumePlanDevelopmentFromIssueAction(currentRow.status, retry)) {
    const resumeResult = await resumePlanDevelopmentFromRelease(id, actionNote);

    return {
      ...resumeResult,
      didScheduleRetry: Boolean(resumeResult?.message),
    };
  }

  return queuePlanRetryForCurrentStatus(id, currentRow.worker_status);
}

export async function cancelPlanRelease(id: number) {
  await ensurePlanTable();

  const currentRow = await db(PLAN_TABLE).where({ id }).first();

  if (!currentRow) {
    return null;
  }

  const releaseTarget = currentRow.release_target ?? 'release';
  const sourceWorkCountRow = await db(PLAN_SOURCE_WORK_TABLE)
    .where({ plan_item_id: id })
    .count<{ count: string }>('id as count')
    .first();
  const sourceWorkCount = Math.max(0, Number(sourceWorkCountRow?.count ?? 0) || 0);
  const isReleaseMergeFailure = currentRow.status === '작업완료' && currentRow.worker_status === 'release반영실패';
  const skippedRollbackBecauseNoSourceWork =
    sourceWorkCount === 0 &&
    (currentRow.status === '릴리즈완료' || currentRow.worker_status === 'main반영실패');
  const targetRows = isReleaseMergeFailure
    ? []
    : await db(PLAN_TABLE)
        .select('*')
        .where({ release_target: releaseTarget, status: '릴리즈완료' });

  const targetIds = isReleaseMergeFailure
    ? [id]
    : [...new Set(targetRows.map((row) => Number(row.id)).concat(id))];
  const historyMessage = isReleaseMergeFailure
    ? `release(${releaseTarget}) 반영 실패 상태에서 작업을 취소 처리했습니다.`
    : skippedRollbackBecauseNoSourceWork
      ? `소스 작업 증적이 없어 release(${releaseTarget}) 롤백 없이 작업을 취소 처리했습니다.`
      : `release(${releaseTarget}) 배포 내역을 롤백하고 작업을 취소 처리했습니다.`;
  const resultMessage = isReleaseMergeFailure
    ? `release(${releaseTarget}) 반영 실패 상태에서 작업취소로 완료 처리했습니다.`
    : skippedRollbackBecauseNoSourceWork
      ? `소스 작업 증적이 없어 rollback 없이 작업취소로 완료 처리했습니다.`
      : `release(${releaseTarget}) 배포 내역을 롤백하고 작업취소로 완료 처리했습니다.`;

  if (targetIds.length === 0) {
    return {
      item: await getPlanItemById(id),
      message: '취소할 release 반영 이력이 없습니다.',
    };
  }

  await db(PLAN_TABLE)
    .whereIn('id', targetIds)
    .update({
      status: '완료',
      worker_status: '작업취소',
      last_error: null,
      locked_by: null,
      locked_at: null,
      completed_at: db.fn.now(),
      updated_at: db.fn.now(),
    });

  for (const targetId of targetIds) {
    await createPlanActionHistory(
      targetId,
      '작업취소',
      historyMessage,
    );

    const issueRow = await createPlanIssueHistory(
      targetId,
      '작업취소',
      historyMessage,
    );

    await db(PLAN_ISSUE_TABLE)
      .where({ id: issueRow.id })
      .update({
        resolved: true,
        resolved_at: db.fn.now(),
      });

    await syncPlanAutomationUsageSnapshot(targetId);
  }

  return {
    item: await getPlanItemById(id),
    message: resultMessage,
  };
}

export async function createPlanActionHistory(planItemId: number, actionType: string, note: string) {
  await ensurePlanTable();

  const rows = await db(PLAN_ACTION_TABLE)
    .insert({
      plan_item_id: planItemId,
      action_type: actionType,
      note,
    })
    .returning('*');

  return rows[0];
}

export async function resolveAutomationIssueHistories(planItemId: number) {
  await ensurePlanTable();

  await db(PLAN_ISSUE_TABLE)
    .where({ plan_item_id: planItemId, resolved: false })
    .whereIn('issue_tag', [...automationIssueTags])
    .update({
      resolved: true,
      resolved_at: db.fn.now(),
    });
}

export async function listPlanActionHistories(planItemId: number) {
  await ensurePlanTable();

  return db(PLAN_ACTION_TABLE)
    .select('*')
    .where({ plan_item_id: planItemId })
    .orderBy('created_at', 'desc')
    .orderBy('id', 'desc');
}

export async function createPlanSourceWorkHistory(
  planItemId: number,
  payload: {
    summary: string;
    branchName: string;
    commitHash?: string | null;
    previewUrl?: string | null;
    changedFiles?: string[];
    commandLog?: string | null;
    diffText?: string | null;
    sourceFiles?: Array<{
      path: string;
      previousPath?: string | null;
      status: 'added' | 'modified' | 'deleted' | 'renamed' | 'binary' | 'unknown';
      language: string;
      content: string;
    }>;
  },
) {
  await ensurePlanTable();

  const rawChangedFiles = payload.changedFiles ?? [];
  const changedFiles = normalizeChangedFiles(rawChangedFiles);

  let filteredChangedFiles = changedFiles;

  if (changedFiles.some((file) => WORKLOG_EVIDENCE_PATH_PATTERN.test(file))) {
    const rows = await db(PLAN_SOURCE_WORK_TABLE)
      .select('changed_files')
      .where({ plan_item_id: planItemId })
      .orderBy('created_at', 'asc')
      .orderBy('id', 'asc');
    const existingChangedFilesList = rows.map((row) => {
      try {
        return JSON.parse(String(row.changed_files ?? '[]')) as string[];
      } catch {
        return [];
      }
    });

    filteredChangedFiles = filterRetryWorklogEvidencePaths(changedFiles, existingChangedFilesList);
  }

  const rows = await db(PLAN_SOURCE_WORK_TABLE)
    .insert({
      plan_item_id: planItemId,
      summary: payload.summary,
      branch_name: payload.branchName,
      commit_hash: payload.commitHash ?? null,
      preview_url: payload.previewUrl ?? null,
      changed_files: JSON.stringify(filteredChangedFiles),
      command_log: payload.commandLog ?? null,
      diff_text: payload.diffText ?? null,
      source_files: JSON.stringify(payload.sourceFiles ?? []),
    })
    .returning('*');

  await syncPlanAutomationUsageSnapshot(planItemId);

  return rows[0];
}

async function createPlanLifecycleSourceWorkHistory(
  planItemId: number,
  summary: string,
  branchName: string,
  commandLog?: string | null,
  previewUrl?: string | null,
) {
  return createPlanSourceWorkHistory(planItemId, {
    summary,
    branchName,
    previewUrl,
    changedFiles: [],
    commandLog: commandLog ?? null,
    diffText: null,
    sourceFiles: [],
  });
}

export async function listPlanSourceWorkHistories(planItemId: number) {
  await ensurePlanTable();

  return db(PLAN_SOURCE_WORK_TABLE)
    .select('*')
    .where({ plan_item_id: planItemId })
    .orderBy('created_at', 'desc')
    .orderBy('id', 'desc');
}

export async function getPlanSourceWorkHistory(planItemId: number, sourceWorkId: number) {
  await ensurePlanTable();

  return db(PLAN_SOURCE_WORK_TABLE)
    .select('*')
    .where({
      id: sourceWorkId,
      plan_item_id: planItemId,
    })
    .first();
}

export async function listLatestPlanSourceWorkMap(planItemIds: number[]) {
  if (planItemIds.length === 0) {
    return new Map<number, ReturnType<typeof mapPlanSourceWorkRow>>();
  }

  const rows = await db(PLAN_SOURCE_WORK_TABLE)
    .select('*')
    .whereIn('plan_item_id', planItemIds)
    .orderBy('created_at', 'desc')
    .orderBy('id', 'desc');

  const latestMap = new Map<number, ReturnType<typeof mapPlanSourceWorkRow>>();
  const fallbackMap = new Map<number, ReturnType<typeof mapPlanSourceWorkRow>>();

  rows.forEach((row) => {
    const planItemId = Number(row.plan_item_id);
    const mappedRow = mapPlanSourceWorkRow(row);
    const hasMeaningfulChanges = mappedRow.changedFiles.length > 0 || mappedRow.sourceFiles.length > 0;

    if (!fallbackMap.has(planItemId)) {
      fallbackMap.set(planItemId, mappedRow);
    }

    if (!latestMap.has(planItemId) && hasMeaningfulChanges) {
      latestMap.set(planItemId, mappedRow);
    }
  });

  planItemIds.forEach((planItemId) => {
    if (!latestMap.has(planItemId) && fallbackMap.has(planItemId)) {
      latestMap.set(planItemId, fallbackMap.get(planItemId)!);
    }
  });

  return latestMap;
}

async function listPlanReleaseReviewRowMap(planItemIds: number[]) {
  if (planItemIds.length === 0) {
    return new Map<number, Record<string, unknown>>();
  }

  const rows = await db(PLAN_RELEASE_REVIEW_TABLE)
    .select('*')
    .whereIn('plan_item_id', planItemIds)
    .orderBy('updated_at', 'desc')
    .orderBy('id', 'desc');

  const reviewMap = new Map<number, Record<string, unknown>>();

  rows.forEach((row) => {
    const planItemId = Number(row.plan_item_id);

    if (!reviewMap.has(planItemId)) {
      reviewMap.set(planItemId, row);
    }
  });

  return reviewMap;
}

function summarizePlanReviewText(value: string) {
  return String(value ?? '')
    .replace(/\s+/g, ' ')
    .trim()
    .slice(0, 220);
}

function inferReleaseReviewPageSelectionIds(changedFiles: string[]) {
  const selectionIds = new Set<string>(['page:plans:release-review', 'page:plans:release']);

  changedFiles.forEach((file) => {
    const normalized = String(file ?? '').trim();

    if (!normalized) {
      return;
    }

    if (normalized.startsWith('src/features/board/') || normalized.includes('/BoardPage.tsx')) {
      selectionIds.add('page:plans:board');
    }

    if (normalized.startsWith('src/features/history/')) {
      selectionIds.add('page:plans:history');
    }

    if (normalized.startsWith('src/components/') || normalized.startsWith('src/features/layout/')) {
      selectionIds.add('page:apis:components');
    }

    if (normalized.startsWith('src/widgets/')) {
      selectionIds.add('page:apis:widgets');
    }
  });

  return Array.from(selectionIds);
}

function inferReleaseReviewDocIds(changedFiles: string[]) {
  return Array.from(
    new Set(
      changedFiles
        .map((file) => {
          const normalized = String(file ?? '').trim();
          return normalized.startsWith('docs/') ? normalized.replace(/[^\w-]+/g, '-') : '';
        })
        .filter(Boolean),
    ),
  );
}

function toKebabCase(value: string) {
  return value
    .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
    .replace(/[_\s]+/g, '-')
    .toLowerCase();
}

function extractSampleTargetIds(
  sourceFiles: Array<{ path: string; content: string }> | undefined,
  changedFiles: string[],
  pathPrefix: string,
) {
  const targetIds = new Set<string>();

  (sourceFiles ?? []).forEach((sourceFile) => {
    const normalizedPath = String(sourceFile.path ?? '').trim();

    if (!normalizedPath.startsWith(pathPrefix)) {
      return;
    }

    const matches = String(sourceFile.content ?? '').matchAll(/componentId\s*:\s*['"]([^'"]+)['"]/g);

    for (const match of matches) {
      const targetId = String(match[1] ?? '').trim();

      if (targetId) {
        targetIds.add(targetId);
      }
    }
  });

  changedFiles.forEach((file) => {
    const normalized = String(file ?? '').trim();

    if (!normalized.startsWith(pathPrefix)) {
      return;
    }

    const relativePath = normalized.slice(pathPrefix.length);
    const firstSegment = relativePath.split('/').filter(Boolean)[0];

    if (firstSegment) {
      targetIds.add(toKebabCase(firstSegment));
    }
  });

  return Array.from(targetIds);
}

function inferReleaseReviewComponentIds(
  sourceFiles: Array<{ path: string; content: string }> | undefined,
  changedFiles: string[],
) {
  return extractSampleTargetIds(sourceFiles, changedFiles, 'src/components/');
}

function inferReleaseReviewWidgetIds(
  sourceFiles: Array<{ path: string; content: string }> | undefined,
  changedFiles: string[],
) {
  return extractSampleTargetIds(sourceFiles, changedFiles, 'src/widgets/');
}

function buildReleaseReviewSummary(
  row: Record<string, unknown>,
  latestSourceWork: ReturnType<typeof mapPlanSourceWorkRow> | undefined,
  reviewRow: Record<string, unknown> | undefined,
) {
  const metadata = mapPlanReleaseReviewMetadata(reviewRow?.metadata ?? null);
  const candidateSummary =
    metadata.summary ??
    summarizePlanReviewText(String(latestSourceWork?.summary ?? '')) ??
    summarizePlanReviewText(String(row.note ?? ''));

  return candidateSummary || summarizePlanReviewText(String(row.note ?? ''));
}

export async function upsertPlanReleaseReview(
  planItemId: number,
  payload: z.infer<typeof updatePlanReleaseReviewSchema>,
  actor?: {
    clientId?: string | null;
    nickname?: string | null;
  },
) {
  await ensurePlanTable();

  const currentItem = await db(PLAN_TABLE).where({ id: planItemId }).first();

  if (!currentItem) {
    return null;
  }

  const currentRow = await db(PLAN_RELEASE_REVIEW_TABLE).where({ plan_item_id: planItemId }).first();
  const currentMetadata = mapPlanReleaseReviewMetadata(currentRow?.metadata ?? null);
  const nextStatus = payload.status ?? ((currentRow?.status ? String(currentRow.status) : 'pending') as PlanReleaseReviewStatus);
  const nextReviewNote = payload.reviewNote ?? String(currentRow?.review_note ?? '');
  const nextMetadata = payload.metadata ? { ...currentMetadata, ...payload.metadata } : currentMetadata;
  const hasReviewerTrace = nextStatus !== 'pending' || nextReviewNote.trim().length > 0;

  const nextRow = {
    plan_item_id: planItemId,
    status: nextStatus,
    review_note: nextReviewNote,
    checked_by_client_id: hasReviewerTrace ? actor?.clientId?.trim() || null : null,
    checked_by_nickname: hasReviewerTrace ? actor?.nickname?.trim() || actor?.clientId?.trim() || null : null,
    checked_at: hasReviewerTrace ? db.fn.now() : null,
    metadata: JSON.stringify(nextMetadata),
    updated_at: db.fn.now(),
  };

  if (currentRow) {
    const rows = await db(PLAN_RELEASE_REVIEW_TABLE)
      .where({ plan_item_id: planItemId })
      .update(nextRow)
      .returning('*');

    await syncPlanAutomationUsageSnapshot(planItemId);
    return mapPlanReleaseReviewRow(rows[0], planItemId);
  }

  const rows = await db(PLAN_RELEASE_REVIEW_TABLE)
    .insert({
      ...nextRow,
      created_at: db.fn.now(),
    })
    .returning('*');

  await syncPlanAutomationUsageSnapshot(planItemId);
  return mapPlanReleaseReviewRow(rows[0], planItemId);
}

export async function listPlanReleaseReviewBoardItems(options?: Pick<PlanRowOptions, 'maskNote'>) {
  await ensurePlanTable();

  const rows = await db(PLAN_TABLE).select('*').orderBy('updated_at', 'desc').orderBy('id', 'desc');
  const releaseRows = rows.filter((row) => {
    const workerStatus = String(row.worker_status ?? '');
    return (
      row.status === '릴리즈완료' ||
      row.status === '완료' ||
      ['release반영중', 'main반영대기', 'main반영중', 'main반영실패', 'release반영완료'].includes(workerStatus)
    );
  });

  const planItemIds = releaseRows.map((row) => Number(row.id));
  const issueSummaryMap = await listPlanIssueSummaries(planItemIds);
  const latestSourceWorkMap = await listLatestPlanSourceWorkMap(planItemIds);
  const reviewRowMap = await listPlanReleaseReviewRowMap(planItemIds);

  return releaseRows.map((row) => {
    const planItemId = Number(row.id);
    const planItem = mapPlanRow(row, {
      ...(issueSummaryMap.get(planItemId) ?? { issueTags: [], hasOpenIssues: false }),
      maskNote: options?.maskNote,
      noteMasked: options?.maskNote,
      exposeConfiguredAutomationType: true,
    });
    const latestSourceWork = latestSourceWorkMap.get(planItemId) ?? null;
    const reviewRow = reviewRowMap.get(planItemId);
    const review = mapPlanReleaseReviewRow(reviewRow, planItemId);
    const changedFiles = latestSourceWork?.changedFiles ?? [];
    const sourceFiles = latestSourceWork?.sourceFiles ?? [];
    const metadata = {
      ...review.metadata,
      summary: review.metadata.summary ?? buildReleaseReviewSummary(row, latestSourceWork ?? undefined, reviewRow),
      pageSelectionIds: review.metadata.pageSelectionIds ?? inferReleaseReviewPageSelectionIds(changedFiles),
      docIds: review.metadata.docIds ?? inferReleaseReviewDocIds(changedFiles),
      componentIds: review.metadata.componentIds ?? inferReleaseReviewComponentIds(sourceFiles, changedFiles),
      widgetIds: review.metadata.widgetIds ?? inferReleaseReviewWidgetIds(sourceFiles, changedFiles),
    };

    return {
      planItem,
      review: {
        ...review,
        metadata,
      },
      latestSourceWork,
    };
  });
}

export async function createPlanIssueHistory(
  planItemId: number,
  issueTag: string,
  message: string,
) {
  await ensurePlanTable();

  const normalizedIssueTag = issueTag.startsWith('#') ? issueTag : `#${issueTag}`;
  const previousIssueWithAction = await db(PLAN_ISSUE_TABLE)
    .select('*')
    .where({
      plan_item_id: planItemId,
      issue_tag: normalizedIssueTag,
      message,
    })
    .whereNotNull('action_note')
    .orderBy('created_at', 'desc')
    .orderBy('id', 'desc')
    .first();

  const rows = await db(PLAN_ISSUE_TABLE)
    .insert({
      plan_item_id: planItemId,
      issue_tag: normalizedIssueTag,
      message,
      action_note: previousIssueWithAction?.action_note ?? null,
    })
    .returning('*');

  return rows[0];
}

export async function appendLatestIssueAction(planItemId: number, actionNote: string, resolve = false) {
  await ensurePlanTable();

  const issueRow = await db(PLAN_ISSUE_TABLE)
    .where({ plan_item_id: planItemId })
    .orderBy('resolved', 'asc')
    .orderBy('created_at', 'desc')
    .first();

  if (!issueRow) {
    throw new Error('기록할 이슈 이력이 없습니다.');
  }

  const nextActionNote = issueRow.action_note
    ? `${issueRow.action_note}\n[${new Date().toISOString()}] ${actionNote}`
    : `[${new Date().toISOString()}] ${actionNote}`;

  const rows = await db(PLAN_ISSUE_TABLE)
    .where({ id: issueRow.id })
    .update({
      action_note: nextActionNote,
      resolved: resolve ? true : issueRow.resolved,
      resolved_at: resolve ? db.fn.now() : issueRow.resolved_at,
    })
    .returning('*');

  await createPlanActionHistory(
    planItemId,
    resolve ? '이슈해결조치' : '이슈조치',
    actionNote,
  );

  return rows[0];
}

export async function listPlanIssueHistories(planItemId: number) {
  await ensurePlanTable();
  await ensurePlanFailureIssueHistory(planItemId);

  return db(PLAN_ISSUE_TABLE)
    .select('*')
    .where({ plan_item_id: planItemId })
    .orderBy('created_at', 'desc')
    .orderBy('id', 'desc');
}

export async function listPlanIssueSummaries(planItemIds: number[]) {
  await ensurePlanTable();

  if (planItemIds.length === 0) {
    return new Map<number, { issueTags: string[]; hasOpenIssues: boolean }>();
  }

  for (const planItemId of planItemIds) {
    await ensurePlanFailureIssueHistory(planItemId);
  }

  const rows = await db(PLAN_ISSUE_TABLE)
    .select('*')
    .whereIn('plan_item_id', planItemIds)
    .orderBy('created_at', 'desc')
    .orderBy('id', 'desc');

  const summaryMap = new Map<number, { issueTags: string[]; hasOpenIssues: boolean }>();

  rows.forEach((row) => {
    const planItemId = Number(row.plan_item_id);
    const current = summaryMap.get(planItemId) ?? { issueTags: [], hasOpenIssues: false };

    if (!row.resolved && typeof row.issue_tag === 'string' && !current.issueTags.includes(row.issue_tag)) {
      current.issueTags.push(row.issue_tag);
    }

    if (!row.resolved) {
      current.hasOpenIssues = true;
    }

    summaryMap.set(planItemId, current);
  });

  return summaryMap;
}

export async function claimNextPlanForBranch(workerId: string) {
  await ensurePlanTable();

  return db.transaction(async (trx) => {
    const row = await trx(PLAN_TABLE)
      .select('*')
      .where({ status: '등록' })
      .where((builder) => {
        builder.whereNull('worker_status').orWhere('worker_status', '대기');
      })
      .orderBy('created_at', 'asc')
      .forUpdate()
      .skipLocked()
      .first();

    if (!row) {
      return null;
    }

    const assignedBranch = shouldUseLocalMainPlanMode(row.automation_type)
      ? String(getEnv().PLAN_MAIN_BRANCH)
      : buildPlanBranchName(String(row.work_id), Number(row.id));
    const rows = await trx(PLAN_TABLE)
      .where({ id: row.id })
      .update({
        status: '작업중',
        assigned_branch: assignedBranch,
        worker_status: '브랜치생성중',
        last_error: null,
        locked_by: workerId,
        locked_at: db.fn.now(),
        started_at: row.started_at ?? db.fn.now(),
        updated_at: db.fn.now(),
      })
      .returning('*');

    return rows[0];
  });
}

export async function claimNextPlanForExecution(workerId: string) {
  await ensurePlanTable();

  return db.transaction(async (trx) => {
    const row = await trx(PLAN_TABLE)
      .select('*')
      .where({ status: '작업중', worker_status: '브랜치준비' })
      .whereNotNull('assigned_branch')
      .orderBy('updated_at', 'asc')
      .forUpdate()
      .skipLocked()
      .first();

    if (!row) {
      return null;
    }

    const rows = await trx(PLAN_TABLE)
      .where({ id: row.id })
      .update({
        worker_status: '자동작업중',
        last_error: null,
        locked_by: workerId,
        locked_at: db.fn.now(),
        updated_at: db.fn.now(),
      })
      .returning('*');

    return rows[0];
  });
}

export async function claimNextPlanForMerge(workerId: string) {
  await ensurePlanTable();

  return db.transaction(async (trx) => {
    const row = await trx(PLAN_TABLE)
      .select('*')
      .where({ status: '작업완료' })
      .whereNotNull('assigned_branch')
      .where((builder) => {
        builder.whereNull('worker_status').orWhere('worker_status', 'release반영대기');
      })
      .orderBy('updated_at', 'asc')
      .forUpdate()
      .skipLocked()
      .first();

    if (!row) {
      return null;
    }

    const rows = await trx(PLAN_TABLE)
      .where({ id: row.id })
      .update({
        worker_status: 'release반영중',
        last_error: null,
        locked_by: workerId,
        locked_at: db.fn.now(),
        updated_at: db.fn.now(),
      })
      .returning('*');

    return rows[0];
  });
}

export async function claimNextPlanForMainMerge(workerId: string) {
  await ensurePlanTable();

  return db.transaction(async (trx) => {
    const candidateRows = await trx(PLAN_TABLE)
      .select('*')
      .where({ status: '릴리즈완료', worker_status: 'main반영대기' })
      .whereNotNull('assigned_branch')
      .orderBy('updated_at', 'asc')
      .forUpdate();

    const row = candidateRows[0];

    if (!row) {
      return null;
    }

    const pendingReleaseRow = await trx(PLAN_TABLE)
      .select('id')
      .where({ release_target: row.release_target, status: '작업완료' })
      .first();

    if (pendingReleaseRow) {
      return null;
    }

    const batchRows = await trx(PLAN_TABLE)
      .select('id')
      .where({ status: '릴리즈완료', release_target: row.release_target })
      .whereIn('worker_status', ['main반영대기', 'main반영실패'] as never[]);

    const batchIds = batchRows.map((candidate) => Number(candidate.id)).filter(Number.isFinite);

    const rows = await trx(PLAN_TABLE)
      .whereIn('id', batchIds.length > 0 ? batchIds : [Number(row.id)])
      .update({
        worker_status: 'main반영중',
        last_error: null,
        locked_by: workerId,
        locked_at: db.fn.now(),
        updated_at: db.fn.now(),
      })
      .returning('*');

    return rows[0] ?? null;
  });
}

export async function markPlanBranchReady(id: number, workerId: string) {
  const rows = await db(PLAN_TABLE)
    .where({ id })
    .where({ locked_by: workerId })
    .update({
      worker_status: '브랜치준비',
      locked_by: null,
      locked_at: null,
      updated_at: db.fn.now(),
    })
    .returning('*');

  if (!rows[0]) {
    return null;
  }

  return rows[0];
}

export async function markPlanWorkCompleted(id: number, workerId: string, note?: string) {
  const currentRow = await db(PLAN_TABLE).where({ id }).first();
  const rows = await db(PLAN_TABLE)
    .where({ id })
    .where({ locked_by: workerId })
    .update({
      status: '작업완료',
      jangsing_processing_required: true,
      worker_status: 'release반영대기',
      last_error: null,
      locked_by: null,
      locked_at: null,
      updated_at: db.fn.now(),
    })
    .returning('*');

  if (!rows[0]) {
    return null;
  }

  if (currentRow) {
    await createPlanLifecycleSourceWorkHistory(
      id,
      '자동 작업 완료로 release 반영 대기 상태로 전환했습니다.',
      currentRow.assigned_branch ?? currentRow.release_target ?? 'release',
      currentRow.assigned_branch
        ? `현재 작업 브랜치: ${currentRow.assigned_branch}`
        : `release 대기 브랜치: ${currentRow.release_target ?? 'release'}`,
    );
  }
  await createPlanActionHistory(id, '작업완료', note ?? '자동 작업을 마치고 release 반영을 대기합니다.');
  await resolveAutomationIssueHistories(id);

  return rows[0];
}

export async function markPlanReleaseMerged(id: number, workerId: string) {
  const currentRow = await db(PLAN_TABLE).where({ id }).first();
  const autoDeployToMain = Boolean(currentRow?.auto_deploy_to_main ?? true);
  const rows = await db(PLAN_TABLE)
    .where({ id })
    .where({ locked_by: workerId })
    .update({
      status: '릴리즈완료',
      worker_status: autoDeployToMain ? 'main반영대기' : 'release반영완료',
      last_error: null,
      locked_by: null,
      locked_at: null,
      updated_at: db.fn.now(),
    })
    .returning('*');

  if (!rows[0]) {
    return null;
  }

  if (currentRow) {
    await createPlanLifecycleSourceWorkHistory(
      id,
      'release 브랜치 반영을 완료했습니다.',
      currentRow.release_target ?? 'release',
      `release 브랜치: ${currentRow.release_target ?? 'release'}`,
    );
  }
  await createPlanActionHistory(
    id,
    'release반영완료',
    autoDeployToMain
      ? 'release 브랜치 반영을 완료했고 main 자동 반영을 예약했습니다.'
      : 'release 브랜치 반영을 완료했습니다.',
  );
  await resolveAutomationIssueHistories(id);
  await syncPlanAutomationUsageSnapshot(id);

  return rows[0];
}

export async function markPlanMerged(id: number, workerId: string) {
  const currentRow = await db(PLAN_TABLE).where({ id }).first();

  if (!currentRow || currentRow.locked_by !== workerId) {
    return null;
  }

  const batchRows = await db(PLAN_TABLE)
    .select('*')
    .where({ status: '릴리즈완료', release_target: currentRow.release_target })
    .whereIn('worker_status', ['main반영대기', 'main반영중', 'main반영실패'] as never[]);

  const batchIds = batchRows.map((row) => Number(row.id)).filter(Number.isFinite);
  const targetRows = batchRows.length > 0 ? batchRows : [currentRow];

  const rows = await db(PLAN_TABLE)
    .whereIn('id', batchIds.length > 0 ? batchIds : [id])
    .update({
      status: '완료',
      worker_status: 'main반영완료',
      last_error: null,
      locked_by: null,
      locked_at: null,
      merged_at: db.fn.now(),
      completed_at: db.fn.now(),
      updated_at: db.fn.now(),
    })
    .returning('*');

  for (const row of targetRows) {
    const planId = Number(row.id);
    await createPlanLifecycleSourceWorkHistory(
      planId,
      `${getEnv().PLAN_MAIN_BRANCH} 브랜치 반영을 완료했습니다.`,
      getEnv().PLAN_MAIN_BRANCH,
      `${row.release_target ?? 'release'} -> ${getEnv().PLAN_MAIN_BRANCH}`,
    );
    await createPlanActionHistory(
      planId,
      'main반영완료',
      `${row.release_target ?? 'release'} 브랜치를 ${getEnv().PLAN_MAIN_BRANCH} 브랜치에 일괄 반영 완료했습니다.`,
    );
    await resolveAutomationIssueHistories(planId);
    await syncPlanAutomationUsageSnapshot(planId);
  }

  return {
    mergedRow: rows[0] ?? null,
    notificationRows: targetRows,
  };
}

export async function markPlanMainMergeFailure(releaseTarget: string, workerId: string, errorMessage: string) {
  await ensurePlanTable();

  const rows = await db(PLAN_TABLE)
    .where({ status: '릴리즈완료', release_target: releaseTarget, locked_by: workerId })
    .whereIn('worker_status', ['main반영대기', 'main반영중'] as never[])
    .update({
      worker_status: 'main반영실패',
      last_error: errorMessage,
      locked_by: null,
      locked_at: null,
      updated_at: db.fn.now(),
    })
    .returning('*');

  for (const row of rows) {
    await createPlanActionHistory(
      Number(row.id),
      'main반영실패',
      `${releaseTarget} 브랜치 기준 main 일괄 반영에 실패했습니다.`,
    );
    await syncPlanAutomationUsageSnapshot(Number(row.id));
  }

  return rows;
}

export async function markPlanAutomationFailure(
  id: number,
  workerId: string,
  workerStatus: Extract<PlanWorkerStatus, '브랜치실패' | '자동작업실패' | 'release반영실패' | 'main반영실패'>,
  errorMessage: string,
) {
  const currentRow = await db(PLAN_TABLE).where({ id }).first();

  if (!currentRow) {
    return null;
  }

  const nextStatus =
    workerStatus === '브랜치실패'
      ? '등록'
      : workerStatus === '자동작업실패'
        ? '작업중'
        : workerStatus === 'main반영실패'
          ? '릴리즈완료'
          : '작업완료';
  const rows = await db(PLAN_TABLE)
    .where({ id })
    .where({ locked_by: workerId })
    .update({
      status: nextStatus,
      worker_status: workerStatus,
      last_error: errorMessage,
      locked_by: null,
      locked_at: null,
      updated_at: db.fn.now(),
    })
    .returning('*');

  if (!rows[0]) {
    return null;
  }

  const issueHistory = await createPlanIssueHistory(id, workerStatus, errorMessage);

  if (issueHistory?.action_note) {
    await createPlanActionHistory(
      id,
      '오류재발',
      `동일 오류가 다시 발생했습니다.\n기존 조치내역:\n${issueHistory.action_note}`,
    );
  }

  await syncPlanAutomationUsageSnapshot(id);

  return rows[0];
}

export async function listPlanItems(status?: PlanStatus, options?: Pick<PlanRowOptions, 'maskNote'>) {
  await ensurePlanTable();

  const buildListQuery = () => {
    const builder = db(PLAN_TABLE).select('*').orderBy('id', 'desc');

    if (status) {
      builder.where('status', status);
    }

    return builder;
  };

  let rows = await buildListQuery();
  const missingSnapshotIds = rows
    .filter((row) => row.usage_snapshot === null || row.usage_snapshot === undefined || String(row.usage_snapshot).trim() === '')
    .map((row) => Number(row.id))
    .filter(Number.isFinite);

  if (missingSnapshotIds.length > 0) {
    await Promise.all(missingSnapshotIds.map((planItemId) => syncPlanAutomationUsageSnapshot(planItemId)));
    rows = await buildListQuery();
  }

  const planItemIds = rows.map((row) => Number(row.id));
  const issueSummaryMap = await listPlanIssueSummaries(planItemIds);
  const releaseReviewNoteMap = await listPlanReleaseReviewNoteMap(planItemIds);

  return rows.map((row) =>
    mapPlanRow(row, {
      ...(issueSummaryMap.get(Number(row.id)) ?? { issueTags: [], hasOpenIssues: false }),
      maskNote: options?.maskNote,
      noteMasked: options?.maskNote,
      releaseReviewNote: releaseReviewNoteMap.get(Number(row.id)) ?? '',
      exposeConfiguredAutomationType: true,
    }),
  );
}

export async function getPlanItemById(id: number, options?: Pick<PlanRowOptions, 'maskNote'>) {
  await ensurePlanTable();
  await ensurePlanFailureIssueHistory(id);
  let row = await db(PLAN_TABLE).where({ id }).first();

  if (!row) {
    return null;
  }

  if (row.usage_snapshot === null || row.usage_snapshot === undefined || String(row.usage_snapshot).trim() === '') {
    await syncPlanAutomationUsageSnapshot(id);
    row = await db(PLAN_TABLE).where({ id }).first();
  }

  const issueSummaryMap = await listPlanIssueSummaries([id]);
  const releaseReviewNoteMap = await listPlanReleaseReviewNoteMap([id]);
  return mapPlanRow(row, {
    ...(issueSummaryMap.get(id) ?? { issueTags: [], hasOpenIssues: false }),
    maskNote: options?.maskNote,
    noteMasked: options?.maskNote,
    releaseReviewNote: releaseReviewNoteMap.get(id) ?? '',
    exposeConfiguredAutomationType: true,
  });
}

function normalizePreviewLookupValue(value: string | null | undefined, stripSearch = false) {
  const trimmed = String(value ?? '').trim();

  if (!trimmed) {
    return '';
  }

  try {
    const url = new URL(trimmed);
    url.hash = '';

    if (stripSearch) {
      url.search = '';
    }

    return url.toString().replace(/\/+$/, '');
  } catch {
    const withoutHash = trimmed.replace(/#.*$/, '');
    const withoutSearch = stripSearch ? withoutHash.replace(/\?.*$/, '') : withoutHash;
    return withoutSearch.replace(/\/+$/, '');
  }
}

function buildPreviewLookupCandidates(value: string | null | undefined) {
  return Array.from(
    new Set([
      normalizePreviewLookupValue(value, false),
      normalizePreviewLookupValue(value, true),
    ].filter(Boolean)),
  );
}

export async function findLatestPlanItem() {
  await ensurePlanTable();

  const row = await db(PLAN_TABLE).select('id').orderBy('id', 'desc').first();

  if (!row?.id) {
    return null;
  }

  return getPlanItemById(Number(row.id));
}

export async function findPlanItemByWorkId(workId: string) {
  await ensurePlanTable();

  const normalizedWorkId = normalizePlanWorkId(workId);

  if (normalizedWorkId === '작업ID') {
    return null;
  }

  const rows = await db(PLAN_TABLE)
    .select('id', 'work_id')
    .orderBy('id', 'desc');

  const matchedRow = rows.find((row) => normalizePlanWorkId(String(row.work_id ?? '')) === normalizedWorkId);

  if (!matchedRow?.id) {
    return null;
  }

  return getPlanItemById(Number(matchedRow.id));
}

export async function findPlanItemByPreviewUrl(previewUrl: string) {
  await ensurePlanTable();

  const lookupCandidates = new Set(buildPreviewLookupCandidates(previewUrl));

  if (lookupCandidates.size === 0) {
    return null;
  }

  const rows = await db(PLAN_SOURCE_WORK_TABLE)
    .select('plan_item_id', 'preview_url')
    .whereNotNull('preview_url')
    .orderBy('created_at', 'desc')
    .orderBy('id', 'desc');

  const matchedRow = rows.find((row) =>
    buildPreviewLookupCandidates(String(row.preview_url ?? '')).some((candidate) => lookupCandidates.has(candidate)),
  );

  if (!matchedRow?.plan_item_id) {
    return null;
  }

  return getPlanItemById(Number(matchedRow.plan_item_id));
}
