import { spawn } from 'node:child_process';
import { constants as fsConstants } from 'node:fs';
import { access } from 'node:fs/promises';
import type { FastifyBaseLogger } from 'fastify';
import { getEnv } from '../config/env.js';
import { getAppConfigSnapshot } from '../services/app-config-service.js';
import { createErrorLog, ERROR_LOG_VIEW_TOKEN } from '../services/error-log-service.js';
import { sendNotifications } from '../services/notification-service.js';
import { buildPlanNotificationData, shouldSendPlanNotification } from '../services/plan-notification-service.js';
import {
  ensureDailyWorklogFile,
  getKstNowParts,
  hasWorklogAutomationRunForDate,
  isWorklogCreationDue,
  markWorklogAutomationRun,
  normalizeDailyCreateTime,
} from '../services/worklog-automation-service.js';
import {
  claimNextPlanForBranch,
  claimNextPlanForExecution,
  claimNextPlanForMerge,
  claimNextPlanForMainMerge,
  formatPlanNotificationLabel,
  getPlanItemById,
  isPlanLockedByWorker,
  mapPlanRow,
  markPlanAsCompleted,
  markPlanAutomationFailure,
  markPlanMainMergeFailure,
  markPlanBranchReady,
  markPlanReleaseMerged,
  markPlanMerged,
  markPlanWorkCompleted,
  shouldUseLocalMainPlanMode,
  upsertAutoPlanItem,
} from '../services/plan-service.js';
import {
  cleanAutomationWorktree,
  ensureBranchExists,
  mergeBranchToRelease,
  mergeReleaseToMain,
  pullMainProjectBranch,
  pushBranch,
} from '../services/git-service.js';
import { progressBoardPostAutomationByPlanResult } from '../services/board-service.js';
import { registerDuePlanScheduledTasks } from '../services/plan-schedule-service.js';
import { isCurrentWorkServerSlotActive } from '../services/work-server-slot-service.js';

const STREAM_CAPTURE_LIMIT = 256 * 1024;
const FIRST_PROGRESS_NOTIFICATION_MS = 60_000;
const SECOND_PROGRESS_NOTIFICATION_MS = 120_000;
const REPEATED_PROGRESS_NOTIFICATION_MS = 180_000;
const STARTED_REQUEST_SUMMARY_LIMIT = 72;
const ERROR_SUMMARY_MAX_LENGTH = 500;
const ERROR_SUMMARY_LINE_PATTERN =
  /(ERROR:|\bfailed\b|\bfailure\b|capacity|Read-only file system|permission|not permitted|sandbox|os error|ENOENT|EACCES|ECONN|SyntaxError|TypeError|ReferenceError)/i;
const ERROR_SUMMARY_NOISE_PATTERN =
  /^(exec|codex|tokens used|succeeded in \d+ms:?|```|[><=]{3,}|[A-Za-z]:\\|\/bin\/bash\b|\d+\s*[:|])/i;
const ERROR_SUMMARY_STRUCTURED_NOISE_PATTERN =
  /^(?:(?:[+\-]\s*)?(?:"(?:failed|failedCount|iosFailed|webFailed)"|'(?:failed|failedCount|iosFailed|webFailed)'|(?:failed|failedCount|iosFailed|webFailed))\s*(?::|=)\s*(?:\[\s*\]|0)\s*[,;]?|[+\-]\s*(?:"(?:failed|failedCount|iosFailed|webFailed)"|'(?:failed|failedCount|iosFailed|webFailed)'|(?:failed|failedCount|iosFailed|webFailed))\s*(?::|=).+|return\s+Promise\.resolve\(\{\s*ok:\s*true,\s*skipped:\s*(?:true|false),[\s\S]*failedCount:\s*0[\s\S]*\}\);?)$/i;
const PLAN_CODEX_RUNNER_MAX_ATTEMPTS = 2;
const PLAN_CODEX_RUNNER_RETRY_DELAY_MS = 5000;
const PLAN_CODEX_RUNNER_TRANSIENT_FAILURE_PATTERN =
  /failed to record rollout items|failed to queue rollout items|channel closed/i;
const PLAN_CODEX_HOME_PERMISSION_PATTERN =
  /failed to record rollout items|failed to queue rollout items|channel closed|read-only file system|eacces|permission/i;
const WEEKLY_DAY_TO_INDEX = {
  sun: 0,
  mon: 1,
  tue: 2,
  wed: 3,
  thu: 4,
  fri: 5,
  sat: 6,
} as const;

type AutomationSnapshot = NonNullable<Awaited<ReturnType<typeof getAppConfigSnapshot>>['automation']>;
type AutoReceiveScheduleEvaluation = {
  due: boolean;
  nextEligibleAt: Date | null;
  scheduleLabel: string;
};

type WorklogAutomationSnapshot = NonNullable<Awaited<ReturnType<typeof getAppConfigSnapshot>>['worklogAutomation']>;

function isValidTimeValue(value: string | undefined): value is string {
  return typeof value === 'string' && /^\d{2}:\d{2}$/.test(value);
}

function withTime(baseDate: Date, timeValue: string) {
  const [hours, minutes] = timeValue.split(':').map((part) => Number(part));
  const nextDate = new Date(baseDate);
  nextDate.setHours(hours ?? 0, minutes ?? 0, 0, 0);
  return nextDate;
}

function getStartOfMinute(date: Date) {
  const nextDate = new Date(date);
  nextDate.setSeconds(0, 0);
  return nextDate;
}

function normalizeRepeatIntervalMinutes(value: number | undefined) {
  if (typeof value !== 'number' || !Number.isFinite(value)) {
    return 60;
  }

  return Math.min(1440, Math.max(1, Math.round(value)));
}

function getNextWeeklyDate(now: Date, targetDay: keyof typeof WEEKLY_DAY_TO_INDEX, timeValue: string) {
  const candidate = withTime(now, timeValue);
  const currentDay = now.getDay();
  const targetDayIndex = WEEKLY_DAY_TO_INDEX[targetDay];
  let dayOffset = targetDayIndex - currentDay;

  if (dayOffset < 0 || (dayOffset === 0 && candidate.getTime() <= now.getTime())) {
    dayOffset += 7;
  }

  candidate.setDate(candidate.getDate() + dayOffset);
  return candidate;
}

function buildAutoWorklogPlanWorkId(dateKey: string) {
  return `auto-worklog-${dateKey}`;
}

function stripAnsi(text: string) {
  return text.replace(/\u001B\[[0-9;]*m/g, '');
}

function normalizeErrorSummaryLine(line: string) {
  return stripAnsi(line)
    .replace(/^\[plan-progress\]\s*/u, '')
    .replace(/^\d{4}-\d{2}-\d{2}T\S+\s+ERROR\s+[^:]+:\s*/u, '')
    .replace(/\s+/g, ' ')
    .trim();
}

export function summarizeFailureOutput(output: string, fallback: string) {
  const normalizedLines = output
    .split('\n')
    .map((line) => normalizeErrorSummaryLine(line))
    .filter(Boolean)
    .filter((line) => !ERROR_SUMMARY_NOISE_PATTERN.test(line))
    .filter((line) => !ERROR_SUMMARY_STRUCTURED_NOISE_PATTERN.test(line));

  const bestLine =
    normalizedLines.filter((line) => ERROR_SUMMARY_LINE_PATTERN.test(line)).at(-1) ??
    normalizedLines.at(-1) ??
    fallback.trim();

  const summary = bestLine.replace(/\s+/g, ' ').trim();
  return summary.slice(0, ERROR_SUMMARY_MAX_LENGTH) || '자동 작업 실행에 실패했습니다.';
}

function isTransientPlanCodexFailure(error: unknown) {
  const message = error instanceof Error ? error.message : String(error ?? '');
  return PLAN_CODEX_RUNNER_TRANSIENT_FAILURE_PATTERN.test(message);
}

async function wait(ms: number) {
  await new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
}

async function canAccessPath(pathValue: string | undefined, mode: number) {
  if (!pathValue) {
    return false;
  }

  try {
    await access(pathValue, mode);
    return true;
  } catch {
    return false;
  }
}

function appendCodexHomeHint(message: string, env: ReturnType<typeof getEnv>) {
  if (!PLAN_CODEX_HOME_PERMISSION_PATTERN.test(message)) {
    return message;
  }

  const codexHome = process.env.CODEX_HOME?.trim() || '(unset)';
  const templateHome = env.PLAN_CODEX_TEMPLATE_HOME?.trim() || process.env.CODEX_HOME_TEMPLATE?.trim() || '(unset)';
  return `${message} [hint: CODEX_HOME=${codexHome}, template=${templateHome}, writable home/volume and permissions를 확인하세요.]`;
}

export class PlanWorker {
  private readonly workerId: string;
  private intervalMs: number;
  private timer: NodeJS.Timeout | null = null;
  private running = false;
  private lastAutoReceiveAt: Date | null = null;
  private lastWorklogAutomationRequestAt: Date | null = null;

  constructor(private readonly logger: FastifyBaseLogger) {
    const env = getEnv();
    this.workerId = env.PLAN_WORKER_ID || `plan-worker-${process.pid}`;
    this.intervalMs = env.PLAN_WORKER_INTERVAL_MS;
  }

  private isLocalMainMode() {
    return Boolean(getEnv().PLAN_LOCAL_MAIN_MODE);
  }

  private shouldUseLocalMainModeForPlan(item: { automationType?: unknown }) {
    return shouldUseLocalMainPlanMode(item.automationType);
  }

  start() {
    const env = getEnv();

    if (!env.PLAN_WORKER_ENABLED) {
      this.logger.info('Plan worker is disabled');
      return;
    }

    if (this.timer) {
      return;
    }

    this.intervalMs = env.PLAN_WORKER_INTERVAL_MS;
    void (async () => {
      const codexHome = process.env.CODEX_HOME?.trim() || '';
      const codexTemplateHome = env.PLAN_CODEX_TEMPLATE_HOME?.trim() || process.env.CODEX_HOME_TEMPLATE?.trim() || '';
      const [codexHomeWritable, codexTemplateReadable] = await Promise.all([
        canAccessPath(codexHome, fsConstants.W_OK),
        canAccessPath(codexTemplateHome, fsConstants.R_OK),
      ]);

      this.logger.info(
        {
          workerId: this.workerId,
          intervalMs: this.intervalMs,
          repoPath: env.PLAN_GIT_REPO_PATH,
          releaseBranch: env.PLAN_RELEASE_BRANCH,
          codexHome: codexHome || null,
          codexHomeWritable,
          codexTemplateHome: codexTemplateHome || null,
          codexTemplateReadable,
        },
        'Plan worker started',
      );
    })();

    void this.tick();
    this.timer = setInterval(() => {
      void this.tick();
    }, this.intervalMs);
  }

  async stop() {
    if (this.timer) {
      clearInterval(this.timer);
      this.timer = null;
    }
  }

  private async persistAutomationErrorLog(args: {
    planId: number;
    workId: string | null | undefined;
    workerStatus: '브랜치실패' | '자동작업실패' | 'release반영실패' | 'main반영실패';
    error: unknown;
    detail: Record<string, unknown>;
  }) {
    const handledError = args.error instanceof Error ? args.error : new Error(String(args.error));

    try {
      await createErrorLog({
        source: 'automation',
        sourceLabel: 'Plan 자동화 워커',
        errorType: args.workerStatus,
        errorName: handledError.name,
        errorMessage: handledError.message || '자동화 처리 중 오류가 발생했습니다.',
        detail: JSON.stringify(args.detail, null, 2),
        stackTrace: handledError.stack ?? null,
        relatedPlanId: args.planId,
        relatedWorkId: args.workId ?? null,
        context: args.detail,
      });
    } catch (loggingError) {
      this.logger.error({ err: loggingError, planId: args.planId }, 'Failed to persist automation error log');
    }
  }

  private async notifyPlan(planId: number, workId: string, title: string, body: string, eventType: string) {
    try {
      if (!(await shouldSendPlanNotification(eventType))) {
        this.logger.info({ planId, workId, eventType, skipped: true, reason: 'event-disabled' }, 'Notification send skipped');
        return;
      }

      const item = await getPlanItemById(planId);
      const disableWebPush = Boolean(item?.suppressWebPush);

      const result = await sendNotifications({
        title,
        body,
        threadId: `plan-${planId}`,
        data: buildPlanNotificationData(planId, workId, eventType),
      }, {
        disableWebPush,
      });

      this.logger.info(
        {
          planId,
          workId,
          eventType,
          disableWebPush,
          ios: {
            skipped: result.ios.skipped,
            sentCount: result.ios.sentCount,
            failedCount: result.ios.failedCount,
            invalidTokens: result.ios.invalidTokens ?? [],
          },
          web: {
            skipped: result.web.skipped,
            sentCount: result.web.sentCount,
            failedCount: result.web.failedCount,
            invalidEndpoints: result.web.invalidEndpoints,
          },
        },
        'Notification send completed',
      );

      if (!result.ios.skipped && result.ios.failedCount > 0) {
        this.logger.warn({ planId, failedCount: result.ios.failedCount }, 'iOS notification send had failures');
      }

      if (!result.web.skipped && result.web.failedCount > 0) {
        this.logger.warn({ planId, failedCount: result.web.failedCount }, 'Web push notification send had failures');
      }
    } catch (error) {
      this.logger.error({ planId, err: error }, 'Notification send failed');
    }
  }

  private summarizeAutomationProgress(output: string) {
    const rawLines = output
      .split(/\r?\n/)
      .map((line) => line.trim());

    const sectionHeaders = [
      '작업 ID',
      '요청 요약',
      '상세 요청',
      '최근 조치 이력',
      '최근 이슈 이력',
      '규칙',
      'Detailed Request',
      'Request Summary',
      'Recent Actions',
      'Recent Issues',
      'Rules',
    ];
    const excludedSections = new Set(['요청 요약', '상세 요청', 'Detailed Request', 'Request Summary']);
    let currentSection = '';
    const lines = rawLines.filter((line) => {
      if (!line) {
        return false;
      }

      const matchedHeader = sectionHeaders.find((header) => line === header || line.startsWith(`${header}:`));

      if (matchedHeader) {
        currentSection = matchedHeader;
        return !excludedSections.has(matchedHeader);
      }

      if (/^\d+\.\s/.test(line) && currentSection === '규칙') {
        return false;
      }

      return !excludedSections.has(currentSection);
    });

    const meaningfulLines = lines.filter((line) => {
      if (line.length < 6) {
        return false;
      }

      if (/^(error|warn|info|debug|trace)\b/i.test(line)) {
        return false;
      }

      if (/^(tokens?|cost|duration|elapsed|reading prompt|thinking)\b/i.test(line)) {
        return false;
      }

      if (/^(작업\s*ID|요청\s*요약|상세\s*요청|최근\s*조치\s*이력|최근\s*이슈\s*이력|규칙)\s*:/u.test(line)) {
        return false;
      }

      if (/^(you are|detailed request|request summary|recent actions|recent issues|rules)\s*:/i.test(line)) {
        return false;
      }

      if (/^[|/\\-]+$/.test(line)) {
        return false;
      }

      return true;
    });

    const recentLines = meaningfulLines.slice(-40);
    const explicitProgressLine = [...recentLines]
      .reverse()
      .find((line) => line.startsWith('[plan-progress]'));

    if (explicitProgressLine) {
      return explicitProgressLine.replace(/^\[plan-progress\]\s*/u, '').trim().slice(0, 160);
    }

    const recentCommand = [...recentLines]
      .reverse()
      .map((line) => this.extractAutomationCommandSummary(line))
      .find(Boolean);
    const recentFile = [...recentLines]
      .reverse()
      .flatMap((line) => {
        const matches = line.match(/(?:[A-Za-z0-9._-]+\/)+[A-Za-z0-9._-]+\.[A-Za-z0-9._-]+/g) ?? [];
        return matches.map((match) => match.replace(/^\.?\//, ''));
      })
      .find((filePath) => !filePath.startsWith('node_modules/'));

    const recentActionLine = [...recentLines].reverse().find((line) => {
      return /(수정|변경|패치|로직|구현|추가|생성|작성|분석|탐색|확인|검증|테스트|edit|modify|update|patch|implement|create|add|write|analy|inspect|search|review|test|verif)/i.test(
        line,
      );
    });

    const actionSummary = this.summarizeAutomationAction(recentActionLine ?? recentFile ?? '');

    if (recentFile && actionSummary && recentCommand) {
      return `${recentFile} ${actionSummary} (${recentCommand})`.slice(0, 160);
    }

    if (recentFile && actionSummary) {
      return `${recentFile} ${actionSummary}`.slice(0, 160);
    }

    if (recentCommand && actionSummary) {
      return `${actionSummary} (${recentCommand})`.slice(0, 160);
    }

    if (recentFile) {
      return recentCommand ? `${recentFile} 확인 중 (${recentCommand})`.slice(0, 160) : `${recentFile} 확인 중`;
    }

    if (actionSummary) {
      return recentCommand ? `${actionSummary} (${recentCommand})`.slice(0, 160) : actionSummary;
    }

    if (recentCommand) {
      return `커맨드 실행 중 (${recentCommand})`.slice(0, 160);
    }

    const latestLine = [...meaningfulLines].reverse()[0];

    if (!latestLine) {
      return '관련 파일과 최근 변경 지점을 확인하는 중입니다.';
    }

    return latestLine.replace(/\s+/g, ' ').slice(0, 160);
  }

  private extractAutomationCommandSummary(line: string) {
    const text = String(line ?? '').trim();

    if (!text) {
      return '';
    }

    const rawCommand =
      text.match(/^Command:\s+(.+)$/i)?.[1] ??
      text.match(/^(?:Running|Execute(?:d|ing)?|Executing)\s*:?\s+(.+)$/i)?.[1] ??
      text.match(/^\$\s+(.+)$/)?.[1] ??
      '';

    if (!rawCommand) {
      return '';
    }

    const normalized = rawCommand
      .replace(/^\/bin\/(?:bash|sh|zsh)\s+-lc\s+/i, '')
      .replace(/^(?:bash|sh|zsh)\s+-lc\s+/i, '')
      .replace(/^['"]|['"]$/g, '')
      .replace(/\s+/g, ' ')
      .trim();

    if (!normalized) {
      return '';
    }

    return normalized.length > 96 ? `${normalized.slice(0, 95).trim()}…` : normalized;
  }

  private summarizeAutomationAction(line: string) {
    const text = String(line ?? '').replace(/\s+/g, ' ').trim();

    if (!text) {
      return '';
    }

    if (this.extractAutomationCommandSummary(text)) {
      if (/\b(git|npm|pnpm|yarn|node|npx|tsx)\b/i.test(text)) {
        return '도구 실행 중';
      }

      return '커맨드 실행 중';
    }

    if (/(테스트|검증|확인|test|verify|check)/i.test(text)) {
      return '검증 중';
    }

    if (/(분석|탐색|조사|읽|review|inspect|search|analy)/i.test(text)) {
      return '코드 분석 중';
    }

    if (/(수정|변경|패치|fix|edit|modify|update|patch)/i.test(text)) {
      return '수정 중';
    }

    if (/(로직|구현|추가|생성|작성|build|implement|create|add|write)/i.test(text)) {
      return '로직 작성 중';
    }

    return text.slice(0, 160);
  }

  private buildProgressNotificationTitle(planId: number, planLabel: string, progressSummary: string) {
    const workSummary = progressSummary || '작업 내용 확인 중';
    const compactSummary = workSummary
      .replace(/^현재 단계:\s*/u, '')
      .replace(/\s+/g, ' ')
      .trim()
      .slice(0, 48);

    const normalizedPlanLabel = String(planLabel ?? '').trim();
    const hasCustomLabel =
      normalizedPlanLabel &&
      normalizedPlanLabel !== `#${planId}` &&
      normalizedPlanLabel !== '작업ID' &&
      normalizedPlanLabel !== '[작업ID]' &&
      normalizedPlanLabel.toLowerCase() !== 'undefined' &&
      normalizedPlanLabel.toLowerCase() !== 'null';
    const labelPrefix = hasCustomLabel ? `#${planId} ${normalizedPlanLabel}` : `#${planId}`;
    return `[${labelPrefix}] ${compactSummary || '자동 작업 진행중'}`;
  }

  private buildExecutionCompletedBody(autoDeployToMain: boolean) {
    return autoDeployToMain
      ? '자동 작업이 완료되었습니다. 이후 release/main 반영이 계속 진행됩니다.'
      : '자동 작업이 완료되었습니다. 이후 release 반영은 별도 요청으로 진행됩니다.';
  }

  private summarizeExecutionRequest(note: unknown, limit = STARTED_REQUEST_SUMMARY_LIMIT) {
    const text = String(note ?? '')
      .replace(/\s+/g, ' ')
      .trim();

    if (!text) {
      return '';
    }

    if (text.length <= limit) {
      return text;
    }

    return `${text.slice(0, Math.max(0, limit - 1)).trim()}…`;
  }

  private buildExecutionStartedBody(note: unknown) {
    const requestSummary = this.summarizeExecutionRequest(note);
    return requestSummary ? `자동 작업을 시작했습니다.\n요청: ${requestSummary}` : '자동 작업을 시작했습니다.';
  }

  private buildAutoWorklogPlanNote(dateKey: string, worklogAutomation: WorklogAutomationSnapshot) {
    const worklogPath = `docs/worklogs/${dateKey}.md`;
    const screenshotDir = `docs/assets/worklogs/${dateKey}/`;
    const templateLabel = worklogAutomation.template === 'simple' ? '간단형' : '상세형';

    return [
      `${dateKey} 기준 업무일지 자동화 Plan입니다.`,
      `${worklogPath} 파일에 금일 작업일지를 정리해 주세요.`,
      `오늘 작업, 이슈 및 해결, 결정 사항, 상세 작업 내역을 최신 상태로 반영해 주세요.`,
      `${worklogPath}의 작업일지 탭 본문과 상세 작업 내역에는 파일 목록, 경로 나열, raw diff, 중복 소스 설명을 쓰지 말고 작업 흐름과 판단만 정리해 주세요.`,
      worklogAutomation.includeScreenshots
        ? `${screenshotDir} 경로에 오늘 작업 기준 전체 화면 스크린샷 1장 이상을 반드시 저장하고, 위젯/컴포넌트 단위 부분 스크린샷이 있으면 함께 연결해 주세요.`
        : `${worklogPath}의 스크린샷 섹션은 현재 상태를 유지해도 됩니다.`,
      worklogAutomation.includeChangedFiles
        ? `${worklogPath}의 소스 탭에서 이전 작업들도 Codex preview 스타일의 전체소스/raw diff를 볼 수 있게 변경/신규 파일 목록과 파일별 raw diff 기준 증적을 남겨 주세요.`
        : `${worklogPath}의 파일 변경 증적은 꼭 추가하지 않아도 됩니다.`,
      worklogAutomation.includeCommandLogs
        ? `${worklogPath}의 실행 커맨드 섹션에 오늘 사용한 주요 명령을 정리해 주세요.`
        : `${worklogPath}의 실행 커맨드 섹션은 비워 두어도 됩니다.`,
      `업무일지 템플릿은 ${templateLabel} 기준으로 정리해 주세요.`,
    ]
      .filter(Boolean)
      .join('\n');
  }

  private buildAutomationFinalStageTitle(planLabel: string) {
    return `[${planLabel}] 자동화 마지막 단계`;
  }

  private async notifyAutomationFinalStage(
    planId: number,
    workId: string,
    planLabel: string,
    body: string,
    eventType: string,
  ) {
    await this.notifyPlan(
      planId,
      workId,
      this.buildAutomationFinalStageTitle(planLabel),
      body,
      eventType,
    );
  }

  private async runCodexCommandWithProgressNotifications(
    planId: number,
    workId: string,
    note: unknown,
    automationType: unknown,
  ) {
    const env = getEnv();
    const useLocalMainMode = shouldUseLocalMainPlanMode(automationType);
    const runCodexCommandAttempt = async (attempt: number) =>
      await new Promise<string>((resolve, reject) => {
        let settled = false;
        let stdout = '';
        let stderr = '';
        let notificationCount = 0;
        let progressTimer: NodeJS.Timeout | null = null;
        const planLabel = formatPlanNotificationLabel(workId, planId);

        const child = spawn('node', [env.PLAN_CODEX_RUNNER_PATH], {
          cwd: env.PLAN_GIT_REPO_PATH,
          stdio: ['ignore', 'pipe', 'pipe'],
          env: {
            ...process.env,
            PLAN_REPO_PATH: useLocalMainMode ? env.PLAN_MAIN_PROJECT_REPO_PATH : env.PLAN_GIT_REPO_PATH,
            PLAN_API_BASE_URL: 'http://127.0.0.1:3100/api',
            PLAN_ACCESS_TOKEN: ERROR_LOG_VIEW_TOKEN,
            PLAN_ITEM_ID: String(planId),
            PLAN_CODEX_BIN: env.PLAN_CODEX_BIN,
            PLAN_CODEX_TEMPLATE_HOME: env.PLAN_CODEX_TEMPLATE_HOME,
            PLAN_GIT_USER_NAME: env.PLAN_GIT_USER_NAME,
            PLAN_GIT_USER_EMAIL: env.PLAN_GIT_USER_EMAIL,
            PLAN_LOCAL_MAIN_MODE: useLocalMainMode ? 'true' : 'false',
            PLAN_SKIP_WORK_COMPLETE: 'true',
          },
        });

        const clearProgressTimer = () => {
          if (progressTimer) {
            clearTimeout(progressTimer);
            progressTimer = null;
          }
        };

        const scheduleProgressNotification = (delayMs: number) => {
          progressTimer = setTimeout(() => {
            void (async () => {
              const stillOwned = await isPlanLockedByWorker(planId, this.workerId);

              if (!stillOwned) {
                clearProgressTimer();
                return;
              }

              const mergedOutput = `${stdout}\n${stderr}`.trim();
              const progressSummary = this.summarizeAutomationProgress(mergedOutput);
              const elapsedMinutes =
                notificationCount === 0 ? 1 : notificationCount === 1 ? 3 : 3 + (notificationCount - 1) * 3;
              notificationCount += 1;

              await this.notifyPlan(
                planId,
                workId,
                this.buildProgressNotificationTitle(planId, planLabel, progressSummary),
                `${elapsedMinutes}분 경과했습니다.\n현재 처리: ${progressSummary}`,
                'work-progress',
              );

              const nextDelayMs =
                notificationCount === 1 ? SECOND_PROGRESS_NOTIFICATION_MS : REPEATED_PROGRESS_NOTIFICATION_MS;
              scheduleProgressNotification(nextDelayMs);
            })();
          }, delayMs);
        };

        child.stdout?.on('data', (chunk) => {
          stdout = (stdout + String(chunk)).slice(-STREAM_CAPTURE_LIMIT);
        });

        child.stderr?.on('data', (chunk) => {
          stderr = (stderr + String(chunk)).slice(-STREAM_CAPTURE_LIMIT);
        });

        child.on('error', (error) => {
          if (settled) {
            return;
          }

          settled = true;
          clearProgressTimer();
          reject(error);
        });

        child.on('close', (code, signal) => {
          if (settled) {
            return;
          }

          settled = true;
          clearProgressTimer();

          if (code === 0) {
            resolve(stdout.trim());
            return;
          }

          const details = summarizeFailureOutput(
            `${stderr}\n${stdout}`,
            `Codex exited with code ${code ?? 'unknown'}${signal ? ` (${signal})` : ''}`,
          );
          reject(new Error(details));
        });

        if (attempt > 1) {
          this.logger.warn(
            {
              planId,
              workId,
              attempt,
              maxAttempts: PLAN_CODEX_RUNNER_MAX_ATTEMPTS,
            },
            'Retrying plan codex runner after transient failure',
          );
        }

        scheduleProgressNotification(FIRST_PROGRESS_NOTIFICATION_MS);
      });

    let lastError: unknown = null;

    for (let attempt = 1; attempt <= PLAN_CODEX_RUNNER_MAX_ATTEMPTS; attempt += 1) {
      try {
        return await runCodexCommandAttempt(attempt);
      } catch (error) {
        lastError = error;

        if (attempt >= PLAN_CODEX_RUNNER_MAX_ATTEMPTS || !isTransientPlanCodexFailure(error)) {
          throw error;
        }

        await this.notifyPlan(
          planId,
          workId,
          `[${formatPlanNotificationLabel(workId, planId)}] 자동화 재시도`,
          `Codex 내부 채널 오류로 자동 작업을 다시 시도합니다. (${attempt + 1}/${PLAN_CODEX_RUNNER_MAX_ATTEMPTS})`,
          'work-progress',
        );

        await wait(PLAN_CODEX_RUNNER_RETRY_DELAY_MS);
      }
    }

    throw lastError instanceof Error ? lastError : new Error(String(lastError ?? '자동 작업 실행에 실패했습니다.'));
  }

  private refreshInterval(env: ReturnType<typeof getEnv>) {
    if (this.intervalMs === env.PLAN_WORKER_INTERVAL_MS) {
      return;
    }

    this.intervalMs = env.PLAN_WORKER_INTERVAL_MS;

    if (this.timer) {
      clearInterval(this.timer);
      this.timer = setInterval(() => {
        void this.tick();
      }, this.intervalMs);
    }
  }

  private evaluateAutoReceiveSchedule(automation: AutomationSnapshot | undefined, now: Date): AutoReceiveScheduleEvaluation {
    const scheduleType = automation?.autoReceiveScheduleType ?? 'interval';

    if (scheduleType === 'daily') {
      const dailyTime = isValidTimeValue(automation?.autoReceiveDailyTime) ? automation.autoReceiveDailyTime : '09:00';
      const todayScheduledAt = withTime(now, dailyTime);
      const nextEligibleAt = now.getTime() >= todayScheduledAt.getTime()
        ? todayScheduledAt
        : todayScheduledAt;
      const alreadyProcessedToday =
        this.lastAutoReceiveAt &&
        this.lastAutoReceiveAt.getFullYear() === now.getFullYear() &&
        this.lastAutoReceiveAt.getMonth() === now.getMonth() &&
        this.lastAutoReceiveAt.getDate() === now.getDate() &&
        this.lastAutoReceiveAt.getTime() >= todayScheduledAt.getTime();

      return {
        due: now.getTime() >= todayScheduledAt.getTime() && !alreadyProcessedToday,
        nextEligibleAt:
          now.getTime() >= todayScheduledAt.getTime()
            ? withTime(new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1), dailyTime)
            : todayScheduledAt,
        scheduleLabel: `daily:${dailyTime}`,
      };
    }

    if (scheduleType === 'weekly') {
      const weeklyDay = automation?.autoReceiveWeeklyDay ?? 'mon';
      const weeklyTime = isValidTimeValue(automation?.autoReceiveWeeklyTime) ? automation.autoReceiveWeeklyTime : '09:00';
      const currentScheduledAt = getNextWeeklyDate(new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000), weeklyDay, weeklyTime);
      const alreadyProcessedThisWeek =
        this.lastAutoReceiveAt !== null && this.lastAutoReceiveAt.getTime() >= currentScheduledAt.getTime();

      return {
        due: now.getTime() >= currentScheduledAt.getTime() && !alreadyProcessedThisWeek,
        nextEligibleAt: getNextWeeklyDate(now, weeklyDay, weeklyTime),
        scheduleLabel: `weekly:${weeklyDay}:${weeklyTime}`,
      };
    }

    const intervalSeconds =
      typeof automation?.autoReceiveIntervalSeconds === 'number' && Number.isFinite(automation.autoReceiveIntervalSeconds)
        ? Math.max(1, Math.round(automation.autoReceiveIntervalSeconds))
        : 30;
    const lastAutoReceiveAt = this.lastAutoReceiveAt;
    const nextEligibleAt = lastAutoReceiveAt
      ? new Date(lastAutoReceiveAt.getTime() + intervalSeconds * 1000)
      : getStartOfMinute(now);

    return {
      due: !lastAutoReceiveAt || now.getTime() >= nextEligibleAt.getTime(),
      nextEligibleAt,
      scheduleLabel: `interval:${intervalSeconds}`,
    };
  }

  private async tick() {
    if (this.running) {
      return;
    }

    this.running = true;

    try {
      if (!(await isCurrentWorkServerSlotActive())) {
        this.logger.info({ workerId: this.workerId }, 'Plan worker skipped on inactive work-server slot');
        return;
      }

      const env = getEnv();
      const appConfig = await getAppConfigSnapshot();
      const autoRefreshEnabled = appConfig.automation?.autoRefreshEnabled ?? true;
      const autoRefreshIntervalSeconds = appConfig.automation?.autoRefreshIntervalSeconds;
      const resolvedIntervalMs =
        typeof autoRefreshIntervalSeconds === 'number' && Number.isFinite(autoRefreshIntervalSeconds)
          ? Math.max(1, Math.round(autoRefreshIntervalSeconds)) * 1000
          : env.PLAN_WORKER_INTERVAL_MS;

      if (!env.PLAN_WORKER_ENABLED || !autoRefreshEnabled) {
        return;
      }

      this.refreshInterval({
        ...env,
        PLAN_WORKER_INTERVAL_MS: resolvedIntervalMs,
      });

      await this.processWorklogAutomation(appConfig);
      await this.processPlanScheduledTasks();
      await this.processRegisteredPlans();
      await this.processExecutablePlans(appConfig);
      await this.processReleaseReadyPlans();
      await this.processMainMergeReadyPlans();
    } finally {
      this.running = false;
    }
  }

  private async processPlanScheduledTasks() {
    try {
      const registeredPlans = await registerDuePlanScheduledTasks();

      if (registeredPlans.length > 0) {
        this.logger.info({ count: registeredPlans.length }, 'Plan scheduled tasks registered');
      }
    } catch (error) {
      const handledError = error instanceof Error ? error : new Error(String(error));
      this.logger.error({ err: handledError }, 'Plan scheduled task processing failed');
      await createErrorLog({
        source: 'automation',
        sourceLabel: 'Plan 스케줄',
        errorType: 'PlanScheduleFailed',
        errorName: handledError.name,
        errorMessage: handledError.message,
        stackTrace: handledError.stack ?? null,
        context: {
          workerId: this.workerId,
        },
      });
    }
  }

  private async processRegisteredPlans() {
    const env = getEnv();
    const row = await claimNextPlanForBranch(this.workerId);

    if (!row) {
      return;
    }

    const item = mapPlanRow(row);
    const planId = Number(item.id);
    const planLabel = formatPlanNotificationLabel(String(item.workId), planId);
    const assignedBranch = String(item.assignedBranch ?? '');
    const releaseTarget = String(item.releaseTarget ?? env.PLAN_RELEASE_BRANCH);

    try {
      if (!this.shouldUseLocalMainModeForPlan(item)) {
        await cleanAutomationWorktree(env.PLAN_GIT_REPO_PATH);
        await ensureBranchExists(
          {
            repoPath: env.PLAN_GIT_REPO_PATH,
            releaseBranch: env.PLAN_RELEASE_BRANCH,
            mainBranch: env.PLAN_MAIN_BRANCH,
          },
          assignedBranch,
          releaseTarget,
        );
      }

      const updatedRow = await markPlanBranchReady(planId, this.workerId);
      if (!updatedRow) {
        this.logger.info({ planId }, 'Plan branch creation result ignored because ownership changed');
        return;
      }
      this.logger.info(
        { planId, branch: assignedBranch, localMainMode: this.shouldUseLocalMainModeForPlan(item) },
        this.shouldUseLocalMainModeForPlan(item) ? 'Plan local main execution prepared' : 'Plan branch created',
      );
    } catch (error) {
      const message = error instanceof Error ? error.message : '브랜치 생성에 실패했습니다.';
      await this.persistAutomationErrorLog({
        planId,
        workId: String(item.workId),
        workerStatus: '브랜치실패',
        error,
        detail: {
          stage: 'branch',
          workerId: this.workerId,
          assignedBranch,
          releaseTarget,
        },
      });
      const failureRow = await markPlanAutomationFailure(planId, this.workerId, '브랜치실패', message);
      if (!failureRow) {
        this.logger.info({ planId }, 'Plan branch failure ignored because ownership changed');
        return;
      }
      await this.notifyAutomationFinalStage(
        planId,
        String(item.workId),
        planLabel,
        `브랜치 생성 실패\n${message}`,
        'branch-failed',
      );
      await progressBoardPostAutomationByPlanResult(planId, 'failed');
      this.logger.error({ planId, err: error }, 'Plan branch creation failed');
    }
  }

  private async processReleaseReadyPlans() {
    const env = getEnv();
    const row = await claimNextPlanForMerge(this.workerId);

    if (!row) {
      return;
    }

    const item = mapPlanRow(row);
    const planId = Number(item.id);
    const planLabel = formatPlanNotificationLabel(String(item.workId), planId);
    const assignedBranch = String(item.assignedBranch ?? '');
    const releaseTarget = String(item.releaseTarget ?? env.PLAN_RELEASE_BRANCH);
    const autoDeployToMain = Boolean(item.autoDeployToMain ?? true);

    try {
      if (this.shouldUseLocalMainModeForPlan(item)) {
        const completedRow = await markPlanAsCompleted(
          planId,
          '로컬 main 직접 작업 모드에서 release/main 반영 단계를 건너뛰고 완료 처리했습니다.',
        );
        if (!completedRow) {
          this.logger.info({ planId }, 'Plan local main completion ignored because ownership changed');
          return;
        }
        await this.notifyAutomationFinalStage(
          planId,
          String(item.workId),
          planLabel,
          '로컬 main 직접 작업이 이미 반영되어 별도 release 반영 없이 완료 처리했습니다.',
          'work-local-main-complete',
        );
        await progressBoardPostAutomationByPlanResult(planId, 'completed');
        this.logger.info({ planId, branch: assignedBranch, releaseTarget }, 'Plan completed in local main mode without release merge');
        return;
      }

      await cleanAutomationWorktree(env.PLAN_GIT_REPO_PATH);
      await mergeBranchToRelease(
        {
          repoPath: env.PLAN_GIT_REPO_PATH,
          releaseBranch: env.PLAN_RELEASE_BRANCH,
          mainBranch: env.PLAN_MAIN_BRANCH,
        },
        assignedBranch,
        releaseTarget,
      );
      await pushBranch(env.PLAN_GIT_REPO_PATH, releaseTarget);
      const updatedRow = await markPlanReleaseMerged(planId, this.workerId);
      if (!updatedRow) {
        this.logger.info({ planId }, 'Plan release merge result ignored because ownership changed');
        return;
      }
      if (!autoDeployToMain) {
        await this.notifyAutomationFinalStage(
          planId,
          String(item.workId),
          planLabel,
          `${releaseTarget} 브랜치 반영이 완료되었습니다.`,
          'release-merged',
        );
        await progressBoardPostAutomationByPlanResult(planId, 'completed');
      }
      this.logger.info(
        { planId, branch: assignedBranch, releaseTarget },
        'Plan branch merged to release',
      );
    } catch (error) {
      const message = error instanceof Error ? error.message : 'release 브랜치 반영에 실패했습니다.';
      await this.persistAutomationErrorLog({
        planId,
        workId: String(item.workId),
        workerStatus: 'release반영실패',
        error,
        detail: {
          stage: 'release-merge',
          workerId: this.workerId,
          assignedBranch,
          releaseTarget,
          autoDeployToMain,
        },
      });
      const failureRow = await markPlanAutomationFailure(planId, this.workerId, 'release반영실패', message);
      if (!failureRow) {
        this.logger.info({ planId }, 'Plan release merge failure ignored because ownership changed');
        return;
      }
      await this.notifyAutomationFinalStage(
        planId,
        String(item.workId),
        planLabel,
        `release 반영 실패\n${message}`,
        'release-failed',
      );
      await progressBoardPostAutomationByPlanResult(planId, 'failed');
      this.logger.error({ planId, err: error }, 'Plan release merge failed');
    }
  }

  private async processMainMergeReadyPlans() {
    const env = getEnv();
    const row = await claimNextPlanForMainMerge(this.workerId);

    if (!row) {
      return;
    }

    const item = mapPlanRow(row);
    const planId = Number(item.id);
    const assignedBranch = String(item.assignedBranch ?? '');
    const releaseTarget = String(item.releaseTarget ?? env.PLAN_RELEASE_BRANCH);
    const workId = String(item.workId);
    const planLabel = formatPlanNotificationLabel(workId, planId);

    try {
      if (this.shouldUseLocalMainModeForPlan(item)) {
        const completedRow = await markPlanAsCompleted(
          planId,
          '로컬 main 직접 작업 모드에서 main 반영 단계를 건너뛰고 완료 처리했습니다.',
          'main반영완료',
        );
        if (!completedRow) {
          this.logger.info({ planId }, 'Plan local main main-merge completion ignored because ownership changed');
          return;
        }
        await this.notifyAutomationFinalStage(
          planId,
          workId,
          planLabel,
          '로컬 main 직접 작업이 이미 반영되어 별도 main merge 없이 완료 처리했습니다.',
          'work-local-main-complete',
        );
        await progressBoardPostAutomationByPlanResult(planId, 'completed');
        this.logger.info({ planId, branch: assignedBranch, releaseTarget }, 'Plan completed in local main mode without main merge');
        return;
      }

      await cleanAutomationWorktree(env.PLAN_GIT_REPO_PATH);
      await mergeReleaseToMain(
        {
          repoPath: env.PLAN_GIT_REPO_PATH,
          releaseBranch: env.PLAN_RELEASE_BRANCH,
          mainBranch: env.PLAN_MAIN_BRANCH,
        },
        releaseTarget,
      );
      await pushBranch(env.PLAN_GIT_REPO_PATH, env.PLAN_MAIN_BRANCH);
      await pullMainProjectBranch(env.PLAN_MAIN_PROJECT_REPO_PATH, env.PLAN_MAIN_BRANCH);
      const mergeResult = await markPlanMerged(planId, this.workerId);
      if (!mergeResult?.mergedRow) {
        this.logger.info({ planId }, 'Plan main merge result ignored because ownership changed');
        return;
      }
      const notificationRows = mergeResult.notificationRows.length > 0 ? mergeResult.notificationRows : [mergeResult.mergedRow];

      for (const notificationRow of notificationRows) {
        const notificationItem = mapPlanRow(notificationRow);
        const notificationPlanId = Number(notificationItem.id);
        const notificationWorkId = String(notificationItem.workId);
        const notificationPlanLabel = formatPlanNotificationLabel(notificationWorkId, notificationPlanId);

        await this.notifyAutomationFinalStage(
          notificationPlanId,
          notificationWorkId,
          notificationPlanLabel,
          `${env.PLAN_MAIN_BRANCH} 브랜치 반영 후 메인 프로젝트 pull까지 완료되었습니다.`,
          'main-merged',
        );
        await progressBoardPostAutomationByPlanResult(notificationPlanId, 'completed');
      }
      this.logger.info(
        {
          planId,
          branch: assignedBranch,
          releaseTarget,
          mainBranch: env.PLAN_MAIN_BRANCH,
          mainProjectRepoPath: env.PLAN_MAIN_PROJECT_REPO_PATH,
          notifiedPlanIds: notificationRows.map((notificationRow) => Number(notificationRow.id)).filter(Number.isFinite),
        },
        'Plan release branch merged to main and main project synced',
      );
    } catch (error) {
      const message = error instanceof Error ? error.message : 'main 브랜치 반영에 실패했습니다.';
      await this.persistAutomationErrorLog({
        planId,
        workId,
        workerStatus: 'main반영실패',
        error,
        detail: {
          stage: 'main-merge',
          workerId: this.workerId,
          assignedBranch,
          releaseTarget,
          mainBranch: env.PLAN_MAIN_BRANCH,
          mainProjectRepoPath: env.PLAN_MAIN_PROJECT_REPO_PATH,
        },
      });
      const failureRows = await markPlanMainMergeFailure(releaseTarget, this.workerId, message);
      if (!failureRows.length) {
        this.logger.info({ planId }, 'Plan main merge failure ignored because ownership changed');
        return;
      }
      await this.notifyAutomationFinalStage(
        planId,
        workId,
        planLabel,
        `main 일괄 반영 실패\n${message}`,
        'main-failed',
      );
      await progressBoardPostAutomationByPlanResult(planId, 'failed');
      this.logger.error({ planId, err: error }, 'Plan main merge failed');
    }
  }

  private async processExecutablePlans(appConfig: Awaited<ReturnType<typeof getAppConfigSnapshot>> | undefined) {
    const env = getEnv();
    if (!env.PLAN_CODEX_ENABLED) {
      return;
    }

    const schedule = this.evaluateAutoReceiveSchedule(appConfig?.automation, new Date());
    if (!schedule.due) {
      return;
    }

    const row = await claimNextPlanForExecution(this.workerId);

    if (!row) {
      return;
    }

    this.lastAutoReceiveAt = new Date();

    const item = mapPlanRow(row);
    const planId = Number(item.id);
    const workId = String(item.workId);
    const planLabel = formatPlanNotificationLabel(workId, planId);
    const autoDeployToMain = Boolean(item.autoDeployToMain ?? true);

    try {
      await this.notifyPlan(
        planId,
        workId,
        `[${planLabel}] 자동화 시작`,
        this.buildExecutionStartedBody(item.note),
        'work-started',
      );

      const output = await this.runCodexCommandWithProgressNotifications(planId, workId, item.note, item.automationType);

      if (output.includes('처리할 Plan 항목이 없습니다.')) {
        throw new Error('자동 작업 대상 Plan 항목을 찾지 못했습니다. 상태 전환 로직을 확인해 주세요.');
      }

      if (output.includes('"outcome": "noop-complete"')) {
        await this.notifyAutomationFinalStage(
          planId,
          workId,
          planLabel,
          '변경 사항 없이 자동 작업이 완료되었습니다.',
          'work-noop-complete',
        );
        return;
      }

      if (output.includes('"outcome": "board-post-complete"')) {
        await this.notifyAutomationFinalStage(
          planId,
          workId,
          planLabel,
          'Plan 게시판 미접수 글 작성이 완료되었습니다.',
          'work-board-post-complete',
        );
        return;
      }

      const finalCompletedRow = this.shouldUseLocalMainModeForPlan(item)
        ? await markPlanAsCompleted(planId, '로컬 main 직접 작업으로 자동 작업을 완료했습니다.')
        : await markPlanWorkCompleted(planId, this.workerId, '자동 작업을 완료했습니다.');
      if (!finalCompletedRow) {
        this.logger.info({ planId }, 'Plan Codex completion ignored because ownership changed');
        return;
      }
      await this.notifyAutomationFinalStage(
        planId,
        workId,
        planLabel,
        this.shouldUseLocalMainModeForPlan(item)
          ? '자동 작업이 로컬 main 작업본에 직접 반영되어 완료되었습니다.'
          : this.buildExecutionCompletedBody(autoDeployToMain),
        'work-completed',
      );
      await progressBoardPostAutomationByPlanResult(planId, 'completed');
      this.logger.info({ planId }, 'Plan Codex execution completed');
    } catch (error) {
      const rawMessage = error instanceof Error ? error.message : '자동 작업 실행에 실패했습니다.';
      const message = appendCodexHomeHint(rawMessage, env);
      await this.persistAutomationErrorLog({
        planId,
        workId,
        workerStatus: '자동작업실패',
        error,
        detail: {
          stage: 'codex-execution',
          workerId: this.workerId,
          assignedBranch: item.assignedBranch ?? null,
          autoDeployToMain,
          releaseTarget: item.releaseTarget ?? env.PLAN_RELEASE_BRANCH,
        },
      });
      const failureRow = await markPlanAutomationFailure(planId, this.workerId, '자동작업실패', message);
      if (!failureRow) {
        this.logger.info({ planId }, 'Plan Codex failure ignored because ownership changed');
        return;
      }
      await this.notifyAutomationFinalStage(
        planId,
        workId,
        planLabel,
        `자동 작업 실패\n${message}`,
        'work-failed',
      );
      await progressBoardPostAutomationByPlanResult(planId, 'failed');
      this.logger.error({ planId, err: error }, 'Plan Codex execution failed');
    }
  }

  private async processWorklogAutomation(appConfig: Awaited<ReturnType<typeof getAppConfigSnapshot>> | undefined) {
    const env = getEnv();
    const worklogAutomation = appConfig?.worklogAutomation;

    if (!worklogAutomation?.autoCreateDailyWorklog) {
      return;
    }

    const now = new Date();
    const { dateKey } = getKstNowParts(now);
    const dailyCreateTime = normalizeDailyCreateTime(worklogAutomation.dailyCreateTime);
    const alreadyExecutedToday = await hasWorklogAutomationRunForDate(dateKey);
    const repeatRequestEnabled = false;
    const repeatIntervalMinutes = normalizeRepeatIntervalMinutes(worklogAutomation.repeatIntervalMinutes);

    if (!isWorklogCreationDue(now, dailyCreateTime, false)) {
      return;
    }

    if (alreadyExecutedToday && !repeatRequestEnabled) {
      return;
    }

    if (alreadyExecutedToday && this.lastWorklogAutomationRequestAt) {
      const nextRepeatAt = new Date(
        this.lastWorklogAutomationRequestAt.getTime() + repeatIntervalMinutes * 60 * 1000,
      );

      if (now.getTime() < nextRepeatAt.getTime()) {
        return;
      }
    }

    try {
      const worklogPath = await ensureDailyWorklogFile(env.PLAN_GIT_REPO_PATH, dateKey);
      const autoWorklogPlanWorkId = buildAutoWorklogPlanWorkId(dateKey);
      const upsertResult = await upsertAutoPlanItem({
        workId: autoWorklogPlanWorkId,
        note: this.buildAutoWorklogPlanNote(dateKey, worklogAutomation),
        releaseTarget: env.PLAN_RELEASE_BRANCH,
        jangsingProcessingRequired: false,
        autoDeployToMain: true,
        requeue: !alreadyExecutedToday,
      });
      this.lastWorklogAutomationRequestAt = now;

      if (upsertResult.action === 'created' || upsertResult.action === 'requeued') {
        this.logger.info(
          { dateKey, workId: autoWorklogPlanWorkId, worklogPath, repeatRequestEnabled, repeatIntervalMinutes },
          'Daily worklog automation plan registered',
        );
      }

      if (!alreadyExecutedToday) {
        await markWorklogAutomationRun({
          runDate: dateKey,
          scheduledTime: dailyCreateTime,
          worklogPath,
        });
      }
      this.logger.info(
        { dateKey, worklogPath, dailyCreateTime, repeatRequestEnabled, repeatIntervalMinutes },
        'Daily worklog automation completed',
      );
    } catch (error) {
      const handledError = error instanceof Error ? error : new Error(String(error));
      this.logger.error({ err: handledError, dateKey, dailyCreateTime }, 'Daily worklog automation failed');
      await createErrorLog({
        source: 'automation',
        sourceLabel: '업무일지 자동화',
        errorType: 'WorklogAutomationFailed',
        errorName: handledError.name,
        errorMessage: handledError.message,
        stackTrace: handledError.stack ?? null,
        context: {
          dateKey,
          dailyCreateTime,
          repeatRequestEnabled,
          repeatIntervalMinutes,
          repoPath: env.PLAN_GIT_REPO_PATH,
        },
      });
    }
  }
}
