/**
 * 회의록 AI: OpenAI 음성 전사 + Chat Completions 회의록 생성
 */
const fsSync = require("fs");
const os = require("os");
const path = require("path");
const { execFile } = require("child_process");
const { promisify } = require("util");
const execFileAsync = promisify(execFile);

/** OpenAI audio.transcriptions 요청당 파일 크기 상한(문서 기준 약 25MB) */
const OPENAI_TRANSCRIPTION_MAX_BYTES = 25 * 1024 * 1024;
/** 단일 요청으로 보낼 때 여유를 둔 상한 */
const SAFE_SINGLE_REQUEST_BYTES = 24 * 1024 * 1024;
/** ffmpeg 분할 시 세그먼트 길이(초). 16kHz mono PCM 기준 한 세그먼트가 API 한도를 넘지 않도록 설정 */
const FFMPEG_SEGMENT_SECONDS = 600;
/**
 * gpt-4o-mini-transcribe / gpt-4o-transcribe: 요청당 "instructions + audio" 토큰 상한이 있어
 * 길이·파일 크기가 작아도 한 파일 전체를 한 번에 보내면 400이 날 수 있음 → 짧게 분할 전사.
 * .env OPENAI_TRANSCRIBE_SEGMENT_SEC 로 조정 가능 (초, 기본 120). 한도 초과 시 30·15 등으로 낮춤.
 */
function getGpt4oTranscribeSegmentSeconds() {
  const n = Number(process.env.OPENAI_TRANSCRIBE_SEGMENT_SEC);
  if (Number.isFinite(n) && n >= 15 && n <= 600) return Math.floor(n);
  return 120;
}

/** 환경변수 미지정 시 기본 전사 모델 */
const DEFAULT_TRANSCRIPTION_MODEL = (process.env.OPENAI_WHISPER_MODEL || "gpt-4o-mini-transcribe").trim();

/** @deprecated 하위 호환 — DEFAULT_TRANSCRIPTION_MODEL 과 동일 */
const DEFAULT_WHISPER_MODEL = DEFAULT_TRANSCRIPTION_MODEL;

/** 화면·API 공통: 허용 전사 모델(OpenAI audio.transcriptions.model) */
const TRANSCRIPTION_UI_MODELS = new Set(["gpt-4o-mini-transcribe", "gpt-4o-transcribe"]);

/**
 * @param {string} uiModel
 * @returns {string} OpenAI audio.transcriptions 에 넣을 model
 */
function resolveTranscriptionApiModel(uiModel) {
  const u = (uiModel || DEFAULT_TRANSCRIPTION_MODEL).trim();
  if (TRANSCRIPTION_UI_MODELS.has(u)) return u;
  return DEFAULT_TRANSCRIPTION_MODEL;
}

/**
 * DB에 저장된 옵션으로 시스템 프롬프트 구성
 * @param {object} settings - meeting_ai_prompts 행(카멜 또는 스네이크)
 */
/** 액션 아이템 — 정의·목적·What/Who/When (회의록 생성 시스템 프롬프트용) */
const ACTION_ITEMS_GUIDANCE_LINES = [
  "【액션 아이템(Action Item)】",
  "정의: 회의 중 논의된 내용에 따라 구성원들이 완료해야 하는 구체적인 작업, 활동 또는 조치입니다.",
  "목적: 누가(Who), 언제(When), 무엇을(What), 필요 시 어떻게(How)까지 명확히 하여 후속 조치를 추적·관리합니다.",
  "필수 요소: What(수행해야 할 구체적 작업), Who(작업을 완료할 책임이 있는 특정 개인), When(완료해야 하는 구체적 일시·기한). 원문에 없으면 추측하지 말고 ‘미정’·‘TBD’ 등으로 표기하세요.",
  "작성 예시: \"김대리(Who) - 10월 24일까지(When) - OO 프로젝트 보고서 초안 작성 및 참석자 배포(What)\".",
  "회의록에는 반드시 별도 마크다운 섹션(예: ## 액션 아이템 또는 ## Action Items)으로 번호 목록·하위 항목·표 등으로 정리하세요. 액션 아이템과 회의 체크리스트 섹션은 서로 구분하세요.",
];

/**
 * 회의록·담당자 표기 시 참고할 사내 임직원 성명(쉼표 구분, 중복은 로드 시 제거)
 * 전사 오타·유사 발음 교정용 — 영어·외국어·외부 인명은 원문 유지
 */
const MEETING_EMPLOYEE_NAMES_RAW =
  "강봉조, 강선규, 강성국, 강성준, 강신균, 강인창, 강종덕, 고영철, 곽병우, 구본엽, 구병철, 권기현, 권순영, 권현철, 김광오, 김광용, 김기덕, 김기연, 김기홍, 김다경, 김대환, 김도균, 김동욱, 김상진, 김성빈, 김성희, 김수지, 김승현, 김의수, 김용현, 김재복, 김정섭, 김정훈, 김태식, 김태우, 김하영, 김항래, 김혜정, 김형규, 김형철, 김효규, 김창열, 남서연, 노은식, 노윤규, 노현주, 류덕현, 박경덕, 박기형, 박대희, 박병찬, 박상욱, 박상현, 박용영, 박정관, 박종철, 박현규, 배문우, 배준영, 서민호, 서원민, 설재민, 성필영, 송제웅, 송지연, 송홍규, 신동균, 신극돈, 신에스더, 신우재, 신화섭, 안종석, 양동환, 양성민, 양소라, 양준삼, 오승우, 오주헌, 우현, 유용일, 유서연, 유성호, 유주상, 유철명, 유휘상, 유웨이, 윤도상, 윤비시아, 윤상혁, 윤지은, 윤종석, 은재민, 이가람, 이강열, 이규민, 이길현, 이동명, 이동석, 이리종철, 이민호, 이병훈, 이사우, 이상규, 이상설, 이상윤, 이상훈, 이성희, 이승묵, 이아정, 이영환, 이재국, 이재동, 이정용, 이정열, 이주승, 이태용, 이석제, 임영규, 임창민, 임현도, 임호균, 장병주, 장소라, 전문호, 정관욱, 정광연, 정대진, 정안용, 정일구, 정재형, 정정주, 정진용, 정인선, 정효진, 조익환, 조정숙, 조현우, 지준경, 진현우, 진형민, 채광석, 천지영, 최봉진, 최상현, 최세영, 최연봉, 최연이, 최원녕, 최인환, 최정운, 최철, 최환성, 한수진, 한준희, 한혜연, 허수연, 허수정, 현진섭, 형성복";

function getMeetingEmployeeNamesDeduped() {
  const seen = new Set();
  const out = [];
  for (const part of MEETING_EMPLOYEE_NAMES_RAW.split(/[,\s]+/)) {
    const t = part.trim();
    if (!t || seen.has(t)) continue;
    seen.add(t);
    out.push(t);
  }
  return out;
}

/** @type {string[]|null} */
let _employeeNamesForPromptCache = null;
function getMeetingEmployeeNamesCommaSeparated() {
  if (!_employeeNamesForPromptCache) {
    _employeeNamesForPromptCache = getMeetingEmployeeNamesDeduped().join(", ");
  }
  return _employeeNamesForPromptCache;
}

const EMPLOYEE_NAME_GUIDANCE_LINES = [
  "【임직원 인명 표기】",
  "참석자·발언자·액션 아이템의 담당자(Who)·회의 체크리스트에 언급된 주요 담당자 등, 사람 이름을 쓸 때 원문·전사가 음성 인식 오류·유사 발음으로 틀릴 수 있습니다.",
  "아래는 사내 임직원 성명 참고 목록입니다. 문맥상 동일 인물로 확실할 때만, 목록에서 가장 가까운 표기로 통일해 주세요. 억지로 맞추지 마세요.",
  "영어·외국어 표기, 또는 위 목록과 완전히 다른 고유 인명(외부 인물·고객 등)은 원문 그대로 두어도 됩니다.",
  "임직원 참고 목록: " + getMeetingEmployeeNamesCommaSeparated(),
];

/** 회의 체크리스트 — 정의·목적·전·중·후 + 업무 체크리스트 AI 연동 */
const MEETING_CHECKLIST_GUIDANCE_LINES = [
  "【회의 체크리스트(Meeting Checklist)】",
  "정의: 회의가 원활하게 진행되고 목표를 달성할 수 있도록 사전에 준비하거나, 회의 후 검토해야 할 항목들을 목록화한 것입니다.",
  "목적: 준비 부족으로 인한 시간 낭비를 방지하고, 회의 전·중·후 전 과정을 구조화하여 효율을 높입니다.",
  "원문에서 도출 가능한 범위에서, 회의 전 준비·회의 중 진행 점검·회의 후 확인·후속 등을 [ ] 체크리스트 형태로 나열할 수 있습니다.",
  "업무 체크리스트 AI 연동: 위 체크리스트 섹션(예: ## 후속 확인 체크리스트, ## 회의 체크리스트)을 반드시 포함하고, 각 항목은 완료 여부를 파악·표시할 수 있는 형태(예: [ ] 항목, 체크박스)로 작성하세요. ‘사용자 추가 지시’에 체크리스트 관련 문구가 없더라도 이 요구는 동일하게 적용됩니다.",
];

function buildMeetingMinutesSystemPrompt(settings) {
  if (!settings || typeof settings !== "object") settings = {};
  const includeTitle = settings.includeTitleLine !== false && settings.include_title_line !== false;
  const includeAttendees = settings.includeAttendees !== false && settings.include_attendees !== false;
  const includeSummary = settings.includeSummary !== false && settings.include_summary !== false;
  const includeActionItems = settings.includeActionItems !== false && settings.include_action_items !== false;
  const custom =
    (settings.customInstructions && String(settings.customInstructions)) ||
    (settings.custom_instructions && String(settings.custom_instructions)) ||
    "";

  const lines = [
    "당신은 사내 회의록을 정리하는 전문가입니다. 입력된 회의 원문(또는 음성 전사)을 바탕으로 읽기 쉬운 회의록을 한국어로 작성합니다.",
  ];
  EMPLOYEE_NAME_GUIDANCE_LINES.forEach((line) => lines.push(line));
  lines.push("");
  lines.push("출력 형식 요구:");
  if (includeTitle) lines.push("- 맨 위에 회의 제목 한 줄(입력에 제목이 없으면 내용에서 추정).");
  if (includeAttendees) lines.push("- 참석자·발언자가 드러나면 구분해 정리.");
  if (includeSummary) lines.push("- 핵심 요약(불릿 또는 짧은 단락).");
  lines.push("- 불필요한 장황한 서론 없이, 마크다운 구조(##, ###, -, 표)를 사용해 읽기 쉽게 구분하세요.");
  lines.push("");
  lines.push("【원문·전사와 회의록 분리】");
  lines.push("- 음성 전사·회의 원문 전체를 회의록 본문에 다시 붙여 넣지 마세요. 원문/전사는 시스템에서 별도 필드(전사 기록·회의 원문)로 이미 보관됩니다.");
  lines.push("- ‘스크립트’, ‘스크랩트’(오타), ‘원문 전사’, ‘전사문’, ‘Verbatim’ 등 원문을 통째로 실어 나르는 제목의 섹션을 만들지 마세요. 요약·결정·액션·체크리스트만 회의록에 포함하세요.");
  lines.push("- 회의 제목, 참석자, 요약, 결정 사항, 액션 아이템 등은 반드시 마크다운 제목(예: ## 회의 제목, ### 요약)으로 구분해 주세요.");
  if (includeActionItems) {
    lines.push("");
    ACTION_ITEMS_GUIDANCE_LINES.forEach((line) => lines.push(line));
  }
  lines.push("");
  MEETING_CHECKLIST_GUIDANCE_LINES.forEach((line) => lines.push(line));
  lines.push("");
  lines.push("【말미 섹션 금지】");
  lines.push(
    "- 회의 체크리스트·액션 아이템 이후에 ‘추가 메모’, ‘확인 필요 사항’, ‘추가 메모 / 확인 필요 사항’ 등 제목의 섹션을 두지 마세요. CSV·표 제안, 설문/스크립트 초안, ‘필요하시면’ 안내 등 말미 부가 안내도 포함하지 않습니다."
  );
  lines.push(
    "- 체크리스트 섹션을 마지막으로 두고, 그 아래에 시연·피드백 제출 방식(문서/슬랙/이메일) 회신, 액션 우선순위 재정렬·담당·기한 확정 안내, DRM·후보군 추가 작성 제안 같은 **운영/후속 안내 문단**을 붙이지 마세요."
  );
  lines.push(
    "- ‘추가 권고’, ‘회의록 작성자의 제안’, 엑셀 담당자 배정 템플릿·추적 체크리스트 제안 등 **회의 본문과 무관한 조언·제안 섹션**을 두지 마세요."
  );
  if (custom.trim()) {
    lines.push("");
    lines.push("사용자 추가 지시:");
    lines.push(custom.trim());
  }
  return lines.join("\n");
}

/**
 * 스크립트/스크랩트(오타) 등 원문 통째 반복 섹션의 제목인지 (마크다운 # 제목)
 * @param {string} title
 */
function isVerbatimScriptSectionTitle(title) {
  const t = (title || "").trim();
  if (!t) return false;
  if (/^원문\s*전사/.test(t)) return true;
  if (/^전사문(\s|$)/.test(t)) return true;
  if (/^Verbatim/i.test(t)) return true;
  if (/^Full\s+transcript/i.test(t)) return true;
  if (/^스크랩트/i.test(t)) return scriptSectionRestIsVerbatim(t, /^스크랩트/i);
  if (/^스크립트/i.test(t)) return scriptSectionRestIsVerbatim(t, /^스크립트/i);
  return false;
}

function scriptSectionRestIsVerbatim(fullTitle, prefixRe) {
  const m = prefixRe.exec(fullTitle);
  if (!m) return false;
  const rest = fullTitle.slice(m[0].length).trim();
  if (!rest) return true;
  if (/^[\(:（]/.test(rest)) return true;
  if (/^(검토|논의|요약|개선|작성|확인|점검)(\b|[\s,.])/i.test(rest)) return false;
  return true;
}

/**
 * # 없이 한 줄로만 쓴 스크립트/스크랩트 블록 시작인지
 * @param {string} line
 */
function isPlainScriptSectionStartLine(line) {
  const raw = String(line || "").trim();
  if (!raw) return false;
  if (/^#{1,6}\s/.test(raw)) return false;
  const t = raw.replace(/^\*\*\s*|\s*\*\*$/g, "").trim();
  if (/^스크랩트/i.test(t)) return scriptSectionRestIsVerbatim(t, /^스크랩트/i);
  if (/^스크립트/i.test(t)) return scriptSectionRestIsVerbatim(t, /^스크립트/i);
  return false;
}

/** 스크립트 블록 다음에 오는 일반 회의록 단락(제목 없이 시작하는 경우)에서 스킵 중단 */
function isLikelyMinutesSectionPlainLine(line) {
  const t = String(line || "").trim();
  if (!t) return false;
  if (/^#{1,6}\s/.test(t)) return false;
  return (
    /^회의\s*제목\s*[:：]/.test(t) ||
    /^참석자\s*[\(（:：]/.test(t) ||
    /^요약\s*[\(（]/.test(t) ||
    /^논의\s*안건/.test(t) ||
    /^논의\s*요약/.test(t) ||
    /^결정\s*사항/.test(t) ||
    /^액션\s*아이템/.test(t) ||
    /^회의\s*체크리스트/.test(t) ||
    /^후속\s*확인/.test(t) ||
    /^회의\s*개요/.test(t) ||
    /^회의\s*일시/.test(t) ||
    /^목적\s*[:：]/.test(t)
  );
}

/**
 * 회의록 마크다운에서 원문/전사를 반복하는 섹션 제거 (전사·원문은 별도 필드에 있음)
 * @param {string} markdown
 * @returns {string}
 */
function stripVerbatimScriptSections(markdown) {
  const text = String(markdown || "");
  const lines = text.split("\n");
  const out = [];
  let i = 0;

  while (i < lines.length) {
    const line = lines[i];
    const hm = /^(#{1,6})\s+(.+)$/.exec(line);
    if (hm && isVerbatimScriptSectionTitle(hm[2])) {
      const level = hm[1].length;
      i++;
      while (i < lines.length) {
        const L = lines[i];
        if (isLikelyMinutesSectionPlainLine(L)) break;
        const th = /^(#{1,6})\s+(.+)$/.exec(L);
        if (th) {
          const lv = th[1].length;
          if (lv < level) break;
          if (lv === level && !isVerbatimScriptSectionTitle(th[2])) break;
        }
        i++;
      }
      continue;
    }
    if (isPlainScriptSectionStartLine(line)) {
      i++;
      while (i < lines.length) {
        const L = lines[i];
        if (isLikelyMinutesSectionPlainLine(L)) break;
        const t = L.trim();
        if (/^#{1,6}\s/.test(t)) break;
        i++;
      }
      continue;
    }
    out.push(line);
    i++;
  }
  return out.join("\n").replace(/\n{3,}/g, "\n\n").trim();
}

/**
 * 구형/비마크다운 회의록: '회의 제목:', '참석자', '요약' 등 단락을 ## 제목으로 승격해 뷰에서 제목 크기가 나오게 함
 * @param {string} markdown
 * @returns {string}
 */
function enhanceMeetingMinutesHeadingLines(markdown) {
  const lines = String(markdown || "").split("\n");
  const out = [];
  const headingRes = [
    /^회의\s*제목\s*[:：]/,
    /^참석자\s*[\(（:：]/,
    /^참석자\s*$/i,
    /^요약\s*[\(（]/,
    /^논의\s*안건/,
    /^논의\s*요약/,
    /^결정\s*사항/,
    /^액션\s*아이템/,
    /^회의\s*체크리스트/,
    /^후속\s*확인/,
    /^회의\s*개요/,
    /^회의\s*일시/,
    /^목적\s*[:：]/,
  ];
  for (const line of lines) {
    const trimmedEnd = line.trimEnd();
    const t = trimmedEnd.trim();
    if (!t) {
      out.push(line);
      continue;
    }
    if (/^#{1,6}\s/.test(t)) {
      out.push(line);
      continue;
    }
    if (/^\s*[-*•]|^\s*\d+[.)]\s/.test(line)) {
      out.push(line);
      continue;
    }
    if (/^>\s/.test(t)) {
      out.push(line);
      continue;
    }
    let asHeading = false;
    for (const re of headingRes) {
      if (re.test(t)) {
        asHeading = true;
        break;
      }
    }
    if (asHeading) {
      out.push("## " + t);
    } else {
      out.push(line);
    }
  }
  return out.join("\n");
}

/**
 * 체크리스트 이후 말미에 붙는 제목(추가 메모·추가 권고 등)
 * @param {string} title
 */
function isAdditionalNotesSectionTitle(title) {
  const t = (title || "").trim();
  if (!t) return false;
  if (/추가\s*메모/i.test(t)) return true;
  if (/확인\s*필요\s*사항/i.test(t) && /(추가|메모|\/\s*확인)/i.test(t)) return true;
  return false;
}

function isTrailingJunkSectionTitle(title) {
  if (isAdditionalNotesSectionTitle(title)) return true;
  const t = (title || "").trim();
  if (!t) return false;
  if (/추가\s*권고/i.test(t)) return true;
  if (/회의록\s*작성자의\s*제안/i.test(t)) return true;
  return false;
}

function isPlainTrailingJunkSectionStartLine(line) {
  const t = String(line || "").trim();
  if (!t || /^#{1,6}\s/.test(t)) return false;
  if (/^\s*[-*•\d]/.test(line)) return false;
  if (/추가\s*메모/i.test(t)) return true;
  if (/확인\s*필요\s*사항/i.test(t) && /추가\s*메모|메모\s*\//i.test(t)) return true;
  if (/추가\s*권고/i.test(t)) return true;
  if (/회의록\s*작성자의\s*제안/i.test(t)) return true;
  return false;
}

function stripTrailingJunkSectionsFromStart(markdown) {
  const lines = String(markdown || "").split("\n");
  const out = [];
  for (let i = 0; i < lines.length; i++) {
    const line = lines[i];
    const hm = /^(#{1,6})\s+(.+)$/.exec(line);
    if (hm && isTrailingJunkSectionTitle(hm[2])) {
      break;
    }
    if (isPlainTrailingJunkSectionStartLine(line)) {
      break;
    }
    out.push(line);
  }
  return out.join("\n").replace(/\n{3,}/g, "\n\n").trim();
}

/** ## 회의 체크리스트 등 마지막 체크리스트 제목 */
function isMeetingChecklistSectionTitle(title) {
  const t = (title || "").trim();
  if (!t) return false;
  if (/회의\s*체크리스트|후속\s*확인\s*체크리스트|후속\s*체크리스트/i.test(t)) return true;
  if (/^체크리스트\s*[\(（]/.test(t)) return true;
  if (/^체크리스트\s*$/i.test(t)) return true;
  return false;
}

/** 체크리스트 항목 뒤에 붙는 운영/후속 안내 문단(제거 대상) */
function isPostChecklistBoilerplateLine(t) {
  const s = String(t || "").trim();
  if (!s) return false;
  if (/필요\s*시\s*시연/i.test(s)) return true;
  if (/필요\s*시\s*위\s*액션\s*아이템별/i.test(s)) return true;
  if (/피드백\s*제출\s*방식/i.test(s) && /(문서|슬랙|이메일)/i.test(s)) return true;
  if (/우선순위\s*\(\s*긴급/i.test(s)) return true;
  if (/지금\s*바로\s*준비해/i.test(s)) return true;
  if (/DRM\s*파일\s*리스트/i.test(s) && /(작성|범위)/i.test(s)) return true;
  if (/1\s*~\s*2팀\s*후보군|후보군\s*제안/i.test(s)) return true;
  if (/담당자와\s*구체\s*기한/i.test(s) && /Timeline|타임라인/i.test(s)) return true;
  if (/추가\s*메모/i.test(s) && /확인\s*필요/i.test(s)) return true;
  if (/^추가\s*메모|^추가\s*메모\s*\//i.test(s)) return true;
  if (/^확인\s*필요\s*사항/i.test(s)) return true;
  if (/원하시면\s*각\s*액션\s*아이템/i.test(s)) return true;
  if (/담당자\s*배정\s*템플릿/i.test(s)) return true;
  if (/추적용\s*체크리스트/i.test(s)) return true;
  if (/엑셀\s*형식/i.test(s) && /(템플릿|배정|체크리스트)/i.test(s)) return true;
  if (/어떤\s*항목부터\s*우선\s*정리/i.test(s)) return true;
  if (/추가\s*권고/i.test(s) && /(회의록|제안|작성자)/i.test(s)) return true;
  return false;
}

/**
 * 마지막 「회의 체크리스트」 ## 섹션 이후의 말미(안내 문단·추가 섹션) 제거
 * @param {string} markdown
 * @returns {string}
 */
function stripTrailingAfterMeetingChecklistSection(markdown) {
  const lines = String(markdown || "").split("\n");
  let startIdx = -1;
  for (let i = 0; i < lines.length; i++) {
    const hm = /^(##)\s+(.+)$/.exec(lines[i].trimEnd());
    if (!hm) continue;
    if (isMeetingChecklistSectionTitle(hm[2])) {
      startIdx = i;
    }
  }
  if (startIdx < 0) return markdown;

  const out = lines.slice(0, startIdx + 1);
  let seenListAfterChecklist = false;
  for (let i = startIdx + 1; i < lines.length; i++) {
    const line = lines[i];
    const t = line.trim();
    if (!t) {
      out.push(line);
      continue;
    }
    if (/^#{1,6}\s/.test(t)) {
      const titleOnly = t.replace(/^#+\s+/, "").trim();
      if (isTrailingJunkSectionTitle(titleOnly)) {
        break;
      }
      const level = (t.match(/^(#+)/) || [""])[0].length;
      if (level <= 2) {
        if (isMeetingChecklistSectionTitle(titleOnly)) {
          out.push(line);
          continue;
        }
        break;
      }
      out.push(line);
      continue;
    }
    if (/^\s*[-*•]/.test(line) || /^\s*\d+[.)]\s/.test(line)) {
      seenListAfterChecklist = true;
      out.push(line);
      continue;
    }
    if (/^\s*\|/.test(line)) {
      out.push(line);
      continue;
    }
    if (/^>\s/.test(t)) {
      out.push(line);
      continue;
    }
    if (isPlainTrailingJunkSectionStartLine(line)) {
      break;
    }
    if (seenListAfterChecklist && isPostChecklistBoilerplateLine(t)) {
      break;
    }
    out.push(line);
  }
  return out.join("\n").replace(/\n{3,}/g, "\n\n").trim();
}

/**
 * 문서 전체에서 알려진 말미 안내 한 줄 제거(체크리스트 밖에 남은 경우)
 * @param {string} markdown
 * @returns {string}
 */
function removeKnownBoilerplateLines(markdown) {
  return String(markdown || "")
    .split("\n")
    .filter((line) => {
      const t = line.trim();
      if (!t) return true;
      if (isPostChecklistBoilerplateLine(t)) return false;
      return true;
    })
    .join("\n")
    .replace(/\n{3,}/g, "\n\n")
    .trim();
}

/**
 * API·저장·생성 공통: 스크립트 제거 → 체크리스트까지만 → 말미 안내 제거 → 말미 섹션(추가 메모·추가 권고 등) 제거 → 제목 승격
 * @param {string} markdown
 * @returns {string}
 */
function prepareMeetingMinutesForApi(markdown) {
  let md = stripVerbatimScriptSections(markdown);
  md = stripTrailingAfterMeetingChecklistSection(md);
  md = removeKnownBoilerplateLines(md);
  md = stripTrailingJunkSectionsFromStart(md);
  return enhanceMeetingMinutesHeadingLines(md);
}

/**
 * @param {import("openai").default} openai
 * @param {string} filePath
 * @param {string} [uiModel] - gpt-4o-mini-transcribe | gpt-4o-transcribe
 */
async function transcribeMeetingAudioOnce(openai, filePath, uiModel = DEFAULT_TRANSCRIPTION_MODEL) {
  const apiModel = resolveTranscriptionApiModel(uiModel);
  const stream = fsSync.createReadStream(filePath);
  /** gpt-4o 전사 계열은 문서상 response_format 이 json 제한인 경우가 많음 */
  const isGpt4oStyleTranscribe =
    apiModel.startsWith("gpt-4o") && apiModel.includes("transcribe") && !apiModel.includes("diarize");
  const params = {
    file: stream,
    model: apiModel,
    language: "ko",
  };
  if (isGpt4oStyleTranscribe) {
    params.response_format = "json";
  }
  const transcription = await openai.audio.transcriptions.create(params);
  const raw = transcription?.text;
  return (typeof raw === "string" ? raw : "").trim();
}

/**
 * 16kHz mono PCM WAV로 분할(세그먼트당 OpenAI 한도 이하). 서버에 ffmpeg 필요.
 * @param {string} inputPath
 * @param {number} [segmentSeconds] - 기본 FFMPEG_SEGMENT_SECONDS (gpt-4o 전사는 더 짧게)
 * @returns {{ files: string[], tmpDir: string }}
 */
async function ffmpegSplitAudioForTranscription(inputPath, segmentSeconds) {
  const seg =
    typeof segmentSeconds === "number" && segmentSeconds >= 15 && segmentSeconds <= 3600
      ? segmentSeconds
      : FFMPEG_SEGMENT_SECONDS;
  const tmpDir = fsSync.mkdtempSync(path.join(os.tmpdir(), "mm-tr-"));
  const outPattern = path.join(tmpDir, "seg_%03d.wav");
  try {
    await execFileAsync("ffmpeg", [
      "-hide_banner",
      "-loglevel",
      "error",
      "-y",
      "-i",
      inputPath,
      "-f",
      "segment",
      "-segment_time",
      String(seg),
      "-reset_timestamps",
      "1",
      "-ac",
      "1",
      "-ar",
      "16000",
      "-acodec",
      "pcm_s16le",
      outPattern,
    ]);
  } catch (e) {
    try {
      fsSync.rmSync(tmpDir, { recursive: true, force: true });
    } catch (_) {
      /* ignore */
    }
    const code = e && e.code;
    if (code === "ENOENT") {
      throw new Error(
        "25MB를 초과하는 음성은 ffmpeg로 분할해 전사합니다. 서버에 ffmpeg가 설치되어 PATH에 있어야 합니다. "
      );
    }
    throw new Error((e && e.message) || "ffmpeg 음성 분할에 실패했습니다.");
  }
  const files = fsSync
    .readdirSync(tmpDir)
    .filter((f) => /^seg_\d+\.wav$/.test(f))
    .sort()
    .map((f) => path.join(tmpDir, f));
  if (!files.length) {
    try {
      fsSync.rmSync(tmpDir, { recursive: true, force: true });
    } catch (_) {
      /* ignore */
    }
    throw new Error("ffmpeg 분할 결과가 비어 있습니다.");
  }
  for (const f of files) {
    if (fsSync.statSync(f).size > OPENAI_TRANSCRIPTION_MAX_BYTES) {
      try {
        fsSync.rmSync(tmpDir, { recursive: true, force: true });
      } catch (_) {
        /* ignore */
      }
      throw new Error("분할된 세그먼트가 여전히 너무 큽니다. 관리자에게 문의하세요.");
    }
  }
  return { files, tmpDir };
}

/**
 * @param {import("openai").default} openai
 * @param {string} filePath
 * @param {string} [uiModel]
 * @param {number} [segmentSeconds] - ffmpeg 분할 길이(초)
 */
async function transcribeMeetingAudioChunked(openai, filePath, uiModel, segmentSeconds) {
  const { files, tmpDir } = await ffmpegSplitAudioForTranscription(filePath, segmentSeconds);
  try {
    const parts = [];
    for (const fp of files) {
      const text = await transcribeMeetingAudioOnce(openai, fp, uiModel);
      parts.push(text);
    }
    return parts.join("\n\n").trim();
  } finally {
    try {
      fsSync.rmSync(tmpDir, { recursive: true, force: true });
    } catch (_) {
      /* ignore */
    }
  }
}

/**
 * 단일 파일 전사. OpenAI 요청당 한도(약 25MB)를 넘으면 ffmpeg로 분할 후 순차 전사.
 * gpt-4o 전사 계열은 오디오 토큰 상한으로, 용량이 작아도 전체를 한 요청에 넣으면 400이 날 수 있어 짧은 구간으로 분할한다.
 * @param {import("openai").default} openai
 * @param {string} filePath
 * @param {string} [uiModel] - gpt-4o-mini-transcribe | gpt-4o-transcribe
 */
async function transcribeMeetingAudio(openai, filePath, uiModel = DEFAULT_TRANSCRIPTION_MODEL) {
  const apiModel = resolveTranscriptionApiModel(uiModel);
  const isGpt4oStyleTranscribe =
    apiModel.startsWith("gpt-4o") && apiModel.includes("transcribe") && !apiModel.includes("diarize");
  const size = fsSync.statSync(filePath).size;
  const gpt4oSeg = getGpt4oTranscribeSegmentSeconds();

  if (isGpt4oStyleTranscribe) {
    return transcribeMeetingAudioChunked(openai, filePath, uiModel, gpt4oSeg);
  }

  if (size <= SAFE_SINGLE_REQUEST_BYTES) {
    return transcribeMeetingAudioOnce(openai, filePath, uiModel);
  }
  return transcribeMeetingAudioChunked(openai, filePath, uiModel);
}

/**
 * @param {import("openai").default} openai
 * @param {object} opts
 * @param {string} opts.systemPrompt
 * @param {string} opts.userContent
 * @param {string} opts.uiModel - gpt-5-mini | gpt-5.4
 * @param {(m: string) => string} opts.resolveApiModel
 */
async function generateMeetingMinutes(openai, { systemPrompt, userContent, uiModel, resolveApiModel }) {
  const apiModel = resolveApiModel(uiModel || "gpt-5-mini");
  const completion = await openai.chat.completions.create({
    model: apiModel,
    messages: [
      { role: "system", content: systemPrompt },
      {
        role: "user",
        content: `아래는 회의 원문 또는 전사입니다. 위 지시에 맞게 회의록을 작성해 주세요.\n\n---\n\n${userContent}`,
      },
    ],
  });
  const raw = (completion.choices?.[0]?.message?.content || "").trim();
  return prepareMeetingMinutesForApi(raw);
}

const CHECKLIST_EXTRACT_SYSTEM = `You extract actionable work items from Korean meeting minutes (Markdown).
Return ONLY valid JSON with this exact shape (no markdown fence, no extra keys):
{"items":[{"title":"string","detail":"string or empty","assignee":"string or empty","dueNote":"string or empty"}]}
Priority (Action Items are as important as checklists — they are real work):
1) **Action Items / 액션 아이템** sections: Every numbered line (1. 2. 3. or 1) 2) 3)) MUST become a **separate** item. Title = the numbered line’s main task name; put 담당/기한/할 일 lines into assignee, dueNote, detail as appropriate.
2) **회의 체크리스트 / 후속 확인 체크리스트** sections: Each [ ] or bullet follow-up as one item.
3) If an action item has 담당:, 기한:, 할 일: sub-lines, map them to assignee, dueNote, detail.
- For assignee (담당자): follow the spelling already used in the minutes. Korean person names should match the company roster implied in the meeting minutes system prompt when the minutes corrected them; do not invent new spellings.
- Do not merge multiple numbered actions into one item.
- Deduplicate only exact duplicate titles.
- If nothing found, return {"items":[]}.
- All human-readable text in Korean.`;

/**
 * 회의록 Markdown에서 구조화 체크리스트 추출 (JSON). 업무 체크리스트 DB 자동 반영용.
 * @param {import("openai").default} openai
 * @param {{ minutesMarkdown: string, uiModel: string, resolveApiModel: (m: string) => string }} opts
 * @returns {Promise<Array<{ title: string, detail: string, assignee: string|null, due_note: string|null, completed: boolean }>>}
 */
async function extractChecklistStructured(openai, { minutesMarkdown, uiModel, resolveApiModel }) {
  const apiModel = resolveApiModel(uiModel || "gpt-5-mini");
  const text = (minutesMarkdown || "").trim();
  if (!text) return [];
  const completion = await openai.chat.completions.create({
    model: apiModel,
    response_format: { type: "json_object" },
    messages: [
      { role: "system", content: CHECKLIST_EXTRACT_SYSTEM },
      {
        role: "user",
        content:
          "아래 회의록에서 업무 항목을 JSON으로 추출하세요.\n" +
          "액션 아이템(번호 목록)과 회의 체크리스트 항목을 모두 포함하고, 번호마다 별도 item으로 나누세요.\n\n" +
          "---\n\n" +
          text,
      },
    ],
  });
  const raw = (completion.choices?.[0]?.message?.content || "{}").trim();
  let parsed;
  try {
    parsed = JSON.parse(raw);
  } catch {
    return [];
  }
  const arr = Array.isArray(parsed.items) ? parsed.items : [];
  return arr
    .map((x) => {
      const title = String(x.title || x.Title || "").trim();
      const detail = String(x.detail ?? x.Detail ?? "").trim();
      const assigneeRaw = x.assignee ?? x.Assignee ?? x.담당;
      const dueRaw = x.dueNote ?? x.due_note ?? x.DueNote ?? x.기한;
      return {
        title,
        detail,
        assignee: assigneeRaw != null && String(assigneeRaw).trim() ? String(assigneeRaw).trim() : null,
        due_note: dueRaw != null && String(dueRaw).trim() ? String(dueRaw).trim() : null,
        completed: false,
      };
    })
    .filter((x) => x.title.length > 0);
}

module.exports = {
  buildMeetingMinutesSystemPrompt,
  stripVerbatimScriptSections,
  stripTrailingJunkSectionsFromStart,
  stripAdditionalNotesSection: stripTrailingJunkSectionsFromStart,
  prepareMeetingMinutesForApi,
  enhanceMeetingMinutesHeadingLines,
  transcribeMeetingAudio,
  generateMeetingMinutes,
  extractChecklistStructured,
  DEFAULT_TRANSCRIPTION_MODEL,
  DEFAULT_WHISPER_MODEL,
  TRANSCRIPTION_UI_MODELS,
  resolveTranscriptionApiModel,
};
