require("dotenv").config({ quiet: true });

const express = require("express");
const OpenAI = require("openai").default;
const Anthropic = require("@anthropic-ai/sdk").default;
const { GoogleGenAI } = require("@google/genai");
const path = require("path");
const fs = require("fs/promises");
const fsSync = require("fs");
const { execFile } = require("child_process");
const { promisify } = require("util");
const multer = require("multer");
const cookieParser = require("cookie-parser");
const JSZip = require("jszip");
const { Pool } = require("pg");
const { v4: uuidv4 } = require("uuid");
const meetingMinutesLib = require("./lib/meeting-minutes");
const meetingAiStore = require("./lib/meeting-minutes-store");
const taskChecklistStore = require("./lib/task-checklist-store");
const parseChecklistFromMinutes = require("./lib/parse-checklist-from-minutes");
const { formatMeetingDateOnly } = require("./lib/meeting-date-format");
const {
  normalizeOpsState,
  isOpsStateDev,
  isOpsStateProd,
  isOpsStateSuper,
} = require("./lib/ops-state");
const { fetchOpenGraphImageUrl } = require("./lib/link-preview");
const mgmtPerf = require("./lib/mgmt-perf");
const aiUseCaseSubmissions = require("./lib/ai-use-case-submissions");
const promptLibrary = require("./lib/prompt-library");
const { decodeUploadFilename } = require("./lib/decode-upload-filename");
const {
  isOpsSessionRevoked,
  revokeAllOpsSessionsForEmail,
} = require("./lib/ops-session-revoke");

const app = express();
const PORT = process.env.PORT || 8030;
/** 로컬 전용으로만 열 때: HOST=127.0.0.1 (기본은 모든 인터페이스) */
const HOST = process.env.HOST || "0.0.0.0";
/** 강의 목록 페이지당 개수. `.env`에 PAGE_SIZE가 있으면 그 값이 우선(미설정 시 9) */
const PAGE_SIZE = Number(process.env.PAGE_SIZE || 9);
const ADMIN_TOKEN = (process.env.ADMIN_TOKEN || "ncue-admin").trim();
const ENABLE_PPT_THUMBNAIL = process.env.ENABLE_PPT_THUMBNAIL !== "0";
/** 업로드 동영상 카드 썸네일(ffmpeg 1프레임). `ENABLE_VIDEO_THUMBNAIL=0`이면 비활성화 */
const ENABLE_VIDEO_THUMBNAIL = process.env.ENABLE_VIDEO_THUMBNAIL !== "0";
/** 동영상 썸네일 추출 시점(초). 0에 가까우면 검은 프레임일 수 있어 기본 0.5 */
const VIDEO_THUMB_SEEK_SEC = (() => {
  const n = Number(process.env.VIDEO_THUMB_SEEK_SEC);
  return Number.isFinite(n) && n >= 0 ? Math.min(120, n) : 0.5;
})();
const THUMBNAIL_WIDTH = Number(process.env.THUMBNAIL_WIDTH || 1000);
const THUMBNAIL_MAX_RETRY = Number(process.env.THUMBNAIL_MAX_RETRY || 2);
const THUMBNAIL_RETRY_DELAY_MS = Number(process.env.THUMBNAIL_RETRY_DELAY_MS || 5000);
const THUMBNAIL_EVENT_KEEP = Number(process.env.THUMBNAIL_EVENT_KEEP || 200);
const THUMBNAIL_EVENT_PAGE_SIZE = Number(process.env.THUMBNAIL_EVENT_PAGE_SIZE || 50);
const ENABLE_POSTGRES = process.env.ENABLE_POSTGRES !== "0";
/** 회의 음성 업로드 최대 크기(MB). OpenAI 전사는 요청당 약 25MB이므로 그보다 큰 파일은 lib/meeting-minutes에서 ffmpeg 분할 후 전사 */
const MEETING_AUDIO_MAX_MB_RAW = Number(process.env.MEETING_AUDIO_MAX_MB);
const MEETING_AUDIO_MAX_MB = Number.isFinite(MEETING_AUDIO_MAX_MB_RAW) && MEETING_AUDIO_MAX_MB_RAW > 0
  ? Math.min(500, Math.max(25, MEETING_AUDIO_MAX_MB_RAW))
  : 300;
const MEETING_AUDIO_MAX_BYTES = Math.floor(MEETING_AUDIO_MAX_MB * 1024 * 1024);
const OPENAI_API_KEY = (process.env.OPENAI_API_KEY || "").trim();
const GUIDE_BOT_URL =
  "https://notebooklm.google.com/notebook/f2797467-5078-48be-a8e7-d1e52c8277dd/preview";
const COMPANY_WM_URL =
  "https://notebooklm.google.com/notebook/500d3330-ff71-4be2-9546-fa2f64fee71d/preview";
/** OpenAI Responses API의 내장 `web_search` 도구 사용. `OPENAI_WEB_SEARCH=0`이면 기존 Chat Completions만 사용 */
const OPENAI_WEB_SEARCH = process.env.OPENAI_WEB_SEARCH !== "0";
const CLAUDE_API_KEY = process.env.CLAUDE_API_KEY || "";
const GENAI_API_KEY = process.env.GENAI_API_KEY || "";

const ROOT_DIR = __dirname;
const CONFIG_DIR = path.join(ROOT_DIR, "config");
const DATA_DIR = path.join(ROOT_DIR, "data");
const COMPANY_PROMPTS_PATH = path.join(CONFIG_DIR, "company-prompts.json");

function loadCompanyPrompts() {
  try {
    return JSON.parse(fsSync.readFileSync(COMPANY_PROMPTS_PATH, "utf8"));
  } catch (e) {
    console.error("company-prompts.json load failed:", e.message);
    return [];
  }
}

function loadAiSuccessStoriesMeta() {
  try {
    const raw = fsSync.readFileSync(AI_SUCCESS_META_PATH, "utf8");
    const arr = JSON.parse(raw);
    return Array.isArray(arr) ? arr : [];
  } catch {
    return [];
  }
}

function saveAiSuccessStoriesMeta(list) {
  fsSync.mkdirSync(path.dirname(AI_SUCCESS_META_PATH), { recursive: true });
  fsSync.mkdirSync(AI_SUCCESS_CONTENT_DIR, { recursive: true });
  fsSync.writeFileSync(AI_SUCCESS_META_PATH, JSON.stringify(list, null, 2), "utf8");
}

function loadStoryBodyMarkdown(meta) {
  if (!meta) return "";
  if (typeof meta.bodyMarkdown === "string" && meta.bodyMarkdown.length) return meta.bodyMarkdown;
  if (meta.contentFile) {
    const safeName = path.basename(meta.contentFile);
    try {
      return fsSync.readFileSync(path.join(AI_SUCCESS_CONTENT_DIR, safeName), "utf8");
    } catch {
      return "";
    }
  }
  return "";
}

function enrichAiSuccessStory(meta) {
  return { ...meta, bodyMarkdown: loadStoryBodyMarkdown(meta) };
}

/** 본문 md 파일명: 슬러그 + 생성 시각(ms)으로 파일명 충돌 가능성 완화 */
function buildAiSuccessStoryContentFileName(slug) {
  const s = String(slug || "")
    .trim()
    .toLowerCase()
    .replace(/[^a-z0-9\-]/g, "");
  const base = s || "story";
  return `${base}-${Date.now()}.md`;
}

function filterAiSuccessStories(list, q, tag) {
  let out = [...list];
  const qLower = (q || "").trim().toLowerCase();
  if (qLower) {
    out = out.filter((s) => {
      return (
        (s.title || "").toLowerCase().includes(qLower) ||
        (s.excerpt || "").toLowerCase().includes(qLower) ||
        (s.department || "").toLowerCase().includes(qLower) ||
        (s.tags || []).some((t) => String(t).toLowerCase().includes(qLower))
      );
    });
  }
  const tagTrim = (tag || "").trim();
  if (tagTrim) {
    out = out.filter((s) => (s.tags || []).includes(tagTrim));
  }
  out.sort((a, b) => {
    const da = new Date(a.publishedAt || a.updatedAt || a.createdAt || 0);
    const db = new Date(b.publishedAt || b.updatedAt || b.createdAt || 0);
    return db - da;
  });
  return out;
}

function allAiSuccessStoryTags(list) {
  const set = new Set();
  list.forEach((s) => (s.tags || []).forEach((t) => set.add(t)));
  return Array.from(set).sort();
}

function aiCaseDateSortMs(story) {
  if (story && story._dateSort instanceof Date) return story._dateSort.getTime();
  const d = new Date(
    (story && (story.publishedAt || story.updatedAt || story.createdAt)) || 0
  );
  return Number.isNaN(d.getTime()) ? 0 : d.getTime();
}

/** JSON 메타 + 일반 제출( submission ) 카드 병합 후 검색·태그 */
function filterUnifiedAiCases(list, q, tag) {
  let out = [...list];
  const qLower = (q || "").trim().toLowerCase();
  if (qLower) {
    out = out.filter((s) => {
      return (
        (s.title || "").toLowerCase().includes(qLower) ||
        (s.excerpt || "").toLowerCase().includes(qLower) ||
        (s.department || "").toLowerCase().includes(qLower) ||
        (s.author || "").toLowerCase().includes(qLower) ||
        (s.submitterEmail || "").toLowerCase().includes(qLower) ||
        (s.tags || []).some((t) => String(t).toLowerCase().includes(qLower))
      );
    });
  }
  const tagTrim = (tag || "").trim();
  if (tagTrim) {
    out = out.filter((s) => (s.tags || []).includes(tagTrim));
  }
  out.sort((a, b) => aiCaseDateSortMs(b) - aiCaseDateSortMs(a));
  return out;
}

function allTagsFromUnifiedAiCases(merged) {
  const set = new Set();
  (merged || []).forEach((s) => (s.tags || []).forEach((t) => set.add(t)));
  return Array.from(set).sort();
}

function requireAdminApi(req, res, next) {
  if (!res.locals.adminMode) {
    res.status(403).json({ error: "관리자만 사용할 수 있습니다." });
    return;
  }
  next();
}
const TMP_DIR = path.join(DATA_DIR, "tmp");
const UPLOAD_DIR = path.join(ROOT_DIR, "uploads");
const THUMBNAIL_DIR = path.join(UPLOAD_DIR, "thumbnails");
const SLIDES_DIR = path.join(UPLOAD_DIR, "slides");
const RESOURCES_LECTURE_DIR = path.join(ROOT_DIR, "resources", "lecture");
const RESOURCES_AX_APPLY_DIR = path.join(ROOT_DIR, "resources", "ax-apply");
const AX_APPLY_UPLOAD_DIR = path.join(UPLOAD_DIR, "ax-apply");
const MEETING_MINUTES_UPLOAD_DIR = path.join(UPLOAD_DIR, "meeting-minutes");
/** 음성 job 메타(UUID→파일경로 등). uploads/ 에 두지 않아 정적으로 노출되지 않음(data/ 아래). */
const MEETING_AUDIO_JOB_META_DIR = path.join(DATA_DIR, "meeting-audio-job-meta");
const MEETING_AUDIO_JOB_MAX_AGE_MS = (() => {
  const n = Number(process.env.MEETING_AUDIO_JOB_TTL_MS ?? 3600000);
  if (!Number.isFinite(n)) return 3600000;
  return Math.min(24 * 60 * 60 * 1000, Math.max(60 * 1000, n));
})();
const LECTURE_DB_PATH = path.join(DATA_DIR, "lectures.json");
const AX_ASSIGNMENTS_DB_PATH = path.join(DATA_DIR, "ax-assignments.json");
const THUMBNAIL_JOB_DB_PATH = path.join(DATA_DIR, "thumbnail-jobs.json");
const THUMBNAIL_EVENT_DB_PATH = path.join(DATA_DIR, "thumbnail-events.json");
const thumbnailEventsStore = require("./lib/thumbnail-events-store");
const AI_SUCCESS_META_PATH = path.join(DATA_DIR, "ai-success-stories.json");
const AI_SUCCESS_CONTENT_DIR = path.join(DATA_DIR, "ai-success-stories");
const DB_SCHEMA_PATH = path.join(ROOT_DIR, "db", "schema.sql");
const execFileAsync = promisify(execFile);
const thumbnailQueue = [];
const queuedLectureIds = new Set();
let thumbnailWorkerRunning = false;
let pgPool = null;

const readThumbnailJobDb = async () => {
  try {
    const raw = await fs.readFile(THUMBNAIL_JOB_DB_PATH, "utf-8");
    const parsed = JSON.parse(raw);
    return Array.isArray(parsed) ? parsed : [];
  } catch {
    return [];
  }
};

const writeThumbnailJobDb = async () => {
  const snapshot = thumbnailQueue.map((job) => ({
    lectureId: job.lectureId,
    force: job.force === true,
    reason: job.reason || "manual",
    enqueuedAt: job.enqueuedAt || Date.now(),
  }));
  await fs.writeFile(THUMBNAIL_JOB_DB_PATH, JSON.stringify(snapshot, null, 2), "utf-8");
};

const readThumbnailEventDb = async () =>
  thumbnailEventsStore.readThumbnailEvents(pgPool, THUMBNAIL_EVENT_DB_PATH);

const writeThumbnailEventDb = async (events) => {
  if (Array.isArray(events) && events.length === 0) {
    return thumbnailEventsStore.clearThumbnailEvents(pgPool, THUMBNAIL_EVENT_DB_PATH);
  }
  throw new Error("썸네일 이벤트는 DB/파일 전체 덮어쓰기를 지원하지 않습니다. clear([])만 사용하세요.");
};

const appendThumbnailEvent = async (payload) =>
  thumbnailEventsStore.appendThumbnailEvent(pgPool, THUMBNAIL_EVENT_DB_PATH, THUMBNAIL_EVENT_KEEP, payload);

const getPgConfig = () => ({
  host: process.env.DB_HOST,
  port: Number(process.env.DB_PORT || 5432),
  database: process.env.DB_DATABASE,
  user: process.env.DB_USERNAME,
  password: process.env.DB_PASSWORD,
  ssl: process.env.DB_SSL === "1" ? { rejectUnauthorized: false } : false,
});

const assertPgConfig = () => {
  if (!ENABLE_POSTGRES) return;
  const config = getPgConfig();
  const missing = ["host", "database", "user", "password"].filter((key) => !config[key]);
  if (missing.length) {
    throw new Error(`PostgreSQL env 누락: ${missing.join(", ")}`);
  }
};

const initPostgres = async () => {
  if (!ENABLE_POSTGRES) return;
  try {
    assertPgConfig();
    pgPool = new Pool(getPgConfig());
    await pgPool.query("SELECT 1");
    const schemaSql = await fs.readFile(DB_SCHEMA_PATH, "utf-8");
    await pgPool.query(schemaSql);
    console.log("PostgreSQL 연결 및 스키마 적용 완료.");
  } catch (err) {
    console.warn("PostgreSQL 연결 실패, 파일 저장소로 폴백:", err.message);
    pgPool = null;
  }
};

function getClientIpForAudit(req) {
  const xf = (req.headers["x-forwarded-for"] || "").split(",")[0].trim();
  if (xf) return xf.slice(0, 45);
  const ra = req.socket?.remoteAddress || req.connection?.remoteAddress;
  return typeof ra === "string" ? ra.slice(0, 45) : null;
}

async function recordOpsEmailAuthEvent({ email, eventType, req, returnTo = null }) {
  if (!pgPool || !email) return;
  const e = String(email).trim().toLowerCase().slice(0, 320);
  const ua = (req.headers["user-agent"] || "").slice(0, 4000) || null;
  const ip = getClientIpForAudit(req);
  try {
    await pgPool.query(
      `INSERT INTO ops_email_auth_events (email, event_type, ip_address, user_agent, return_to)
       VALUES ($1, $2, $3, $4, $5)`,
      [e, eventType, ip, ua, returnTo ? String(returnTo).slice(0, 2000) : null]
    );
  } catch (err) {
    console.error("[ops-email-auth] record failed:", err.message);
  }
}

async function upsertOpsEmailUserOnLogin(email) {
  if (!pgPool || !email) return;
  const e = String(email).trim().toLowerCase().slice(0, 320);
  try {
    await pgPool.query(
      `INSERT INTO ops_email_users (email, first_seen_at, last_login_at, login_count)
       VALUES ($1, NOW(), NOW(), 1)
       ON CONFLICT (email) DO UPDATE SET
         last_login_at = NOW(),
         login_count = ops_email_users.login_count + 1`,
      [e]
    );
  } catch (err) {
    console.error("[ops-email-users] upsert failed:", err.message);
  }
}

const opsAuth = require("./ops-auth")(DATA_DIR, {
  onMagicLinkRequested: async ({ email, returnTo, req }) => {
    await recordOpsEmailAuthEvent({
      email,
      eventType: "magic_link_requested",
      req,
      returnTo,
    });
  },
  onLoginSuccess: async ({ email, returnTo, req }) => {
    await recordOpsEmailAuthEvent({
      email,
      eventType: "login_success",
      req,
      returnTo,
    });
    await upsertOpsEmailUserOnLogin(email);
  },
  onLogout: async ({ email, req }) => {
    if (email) {
      await recordOpsEmailAuthEvent({
        email,
        eventType: "logout",
        req,
      });
    }
  },
  isSessionRevoked: async ({ email, iatMs }) =>
    isOpsSessionRevoked(pgPool, DATA_DIR, email, iatMs),
});

const syncLecturesToPostgres = async (lectures) => {
  if (!ENABLE_POSTGRES || !pgPool) return;
  const client = await pgPool.connect();
  try {
    await client.query("BEGIN");
    for (const lecture of lectures) {
      await client.query(
        `INSERT INTO lectures (
          id, type, title, description, tags, youtube_url, file_name, original_name,
          preview_title, slide_count, thumbnail_url, thumbnail_status, thumbnail_retry_count,
          thumbnail_error, thumbnail_updated_at, created_at, list_section, news_url
        ) VALUES (
          $1::uuid, $2, $3, $4, $5::text[], $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18
        )
        ON CONFLICT (id) DO UPDATE SET
          type = EXCLUDED.type,
          title = EXCLUDED.title,
          description = EXCLUDED.description,
          tags = EXCLUDED.tags,
          youtube_url = EXCLUDED.youtube_url,
          file_name = EXCLUDED.file_name,
          original_name = EXCLUDED.original_name,
          preview_title = EXCLUDED.preview_title,
          slide_count = EXCLUDED.slide_count,
          thumbnail_url = EXCLUDED.thumbnail_url,
          thumbnail_status = EXCLUDED.thumbnail_status,
          thumbnail_retry_count = EXCLUDED.thumbnail_retry_count,
          thumbnail_error = EXCLUDED.thumbnail_error,
          thumbnail_updated_at = EXCLUDED.thumbnail_updated_at,
          list_section = EXCLUDED.list_section,
          news_url = EXCLUDED.news_url`,
        [
          lecture.id,
          lecture.type,
          lecture.title,
          lecture.description || "",
          Array.isArray(lecture.tags) ? lecture.tags : [],
          lecture.youtubeUrl || null,
          lecture.fileName || null,
          lecture.originalName || null,
          lecture.previewTitle || null,
          typeof lecture.slideCount === "number" ? lecture.slideCount : 0,
          lecture.thumbnailUrl || null,
          lecture.thumbnailStatus || "pending",
          typeof lecture.thumbnailRetryCount === "number" ? lecture.thumbnailRetryCount : 0,
          lecture.thumbnailError || null,
          lecture.thumbnailUpdatedAt ? new Date(lecture.thumbnailUpdatedAt) : null,
          lecture.createdAt ? new Date(lecture.createdAt) : new Date(),
          lecture.listSection || "learning",
          lecture.newsUrl || null,
        ]
      );
    }

    if (lectures.length > 0) {
      await client.query("DELETE FROM lectures WHERE id <> ALL($1::uuid[])", [lectures.map((lecture) => lecture.id)]);
    } else {
      await client.query("DELETE FROM lectures");
    }
    await client.query("COMMIT");
  } catch (error) {
    await client.query("ROLLBACK");
    throw error;
  } finally {
    client.release();
  }
};

const escapeCsv = (value) => {
  const str = String(value ?? "");
  if (str.includes('"') || str.includes(",") || str.includes("\n")) {
    return `"${str.replace(/"/g, '""')}"`;
  }
  return str;
};

const buildEventFilter = (query) => {
  const eventType = (query.eventType || "all").toString();
  const lectureId = (query.lectureId || "").toString().trim();
  const reason = (query.reason || "").toString().trim();
  const from = (query.from || "").toString().trim();
  const to = (query.to || "").toString().trim();

  const fromMs = from ? new Date(from).getTime() : null;
  const toMs = to ? new Date(`${to}T23:59:59.999`).getTime() : null;

  return {
    eventType,
    lectureId,
    reason,
    from,
    to,
    matches: (evt) => {
      if (eventType !== "all" && evt.type !== eventType) return false;
      if (lectureId && !String(evt.lectureId || "").includes(lectureId)) return false;
      if (reason && !String(evt.reason || "").includes(reason)) return false;
      const atMs = new Date(evt.at || "").getTime();
      if (Number.isFinite(fromMs) && Number.isFinite(atMs) && atMs < fromMs) return false;
      if (Number.isFinite(toMs) && Number.isFinite(atMs) && atMs > toMs) return false;
      return true;
    },
  };
};

const storage = multer.diskStorage({
  destination: (_, __, cb) => cb(null, UPLOAD_DIR),
  filename: (_, file, cb) => {
    const ext = (path.extname(file.originalname) || ".pptx").toLowerCase();
    const allowed = [".pptx", ".pdf"];
    const finalExt = allowed.includes(ext) ? ext : ".pptx";
    cb(null, `${Date.now()}-${uuidv4()}${finalExt}`);
  },
});
const upload = multer({
  storage,
  limits: { fileSize: 50 * 1024 * 1024 },
  fileFilter: (_, file, cb) => {
    const ext = path.extname(file.originalname).toLowerCase();
    if (ext !== ".pptx" && ext !== ".pdf") {
      cb(new Error("PDF(.pdf) 또는 PowerPoint(.pptx) 파일만 업로드할 수 있습니다."));
      return;
    }
    cb(null, true);
  },
});

const LECTURE_VIDEO_ALLOWED_EXT = [".mp4", ".webm", ".mov"];
const lectureVideoMaxBytes =
  Math.max(1, Number(process.env.LECTURE_VIDEO_MAX_MB) || 500) * 1024 * 1024;
const videoStorage = multer.diskStorage({
  destination: (_, __, cb) => cb(null, UPLOAD_DIR),
  filename: (_, file, cb) => {
    const ext = (path.extname(file.originalname) || ".mp4").toLowerCase();
    const finalExt = LECTURE_VIDEO_ALLOWED_EXT.includes(ext) ? ext : ".mp4";
    cb(null, `${Date.now()}-${uuidv4()}${finalExt}`);
  },
});
const uploadVideo = multer({
  storage: videoStorage,
  limits: { fileSize: lectureVideoMaxBytes },
  fileFilter: (_, file, cb) => {
    const ext = path.extname(file.originalname).toLowerCase();
    if (!LECTURE_VIDEO_ALLOWED_EXT.includes(ext)) {
      cb(new Error("동영상은 MP4, WebM, MOV 형식만 업로드할 수 있습니다."));
      return;
    }
    cb(null, true);
  },
});

const axApplyStorage = multer.diskStorage({
  destination: (_, __, cb) => {
    fsSync.mkdirSync(AX_APPLY_UPLOAD_DIR, { recursive: true });
    cb(null, AX_APPLY_UPLOAD_DIR);
  },
  filename: (_, file, cb) => {
    const ext = (path.extname(file.originalname) || ".docx").toLowerCase();
    const finalExt = ext === ".docx" || ext === ".doc" ? ".docx" : ".docx";
    cb(null, `${Date.now()}-${uuidv4()}${finalExt}`);
  },
});
const uploadAxApply = multer({
  storage: axApplyStorage,
  limits: { fileSize: 10 * 1024 * 1024 },
  fileFilter: (_, file, cb) => {
    const ext = path.extname(file.originalname).toLowerCase();
    if (ext !== ".docx" && ext !== ".doc") {
      cb(new Error("Word 문서(.docx, .doc) 파일만 업로드할 수 있습니다."));
      return;
    }
    cb(null, true);
  },
});

const AI_SUCCESS_PUBLIC_PDF_DIR = path.join(ROOT_DIR, "public", "resources", "ai-success");
const aiSuccessPdfStorage = multer.diskStorage({
  destination: (_, __, cb) => {
    fsSync.mkdirSync(AI_SUCCESS_PUBLIC_PDF_DIR, { recursive: true });
    cb(null, AI_SUCCESS_PUBLIC_PDF_DIR);
  },
  filename: (_, __, cb) => {
    cb(null, `${Date.now()}-${uuidv4()}.pdf`);
  },
});
const uploadAiSuccessPdf = multer({
  storage: aiSuccessPdfStorage,
  limits: { fileSize: 50 * 1024 * 1024 },
  fileFilter: (_, file, cb) => {
    const ext = path.extname(file.originalname).toLowerCase();
    if (ext !== ".pdf") {
      cb(new Error("PDF(.pdf) 파일만 업로드할 수 있습니다."));
      return;
    }
    cb(null, true);
  },
});

function meetingMinutesUserUploadDestination(req, _, cb) {
  const email = req.meetingUserEmail || "unknown";
  const safe = String(email).replace(/[^a-zA-Z0-9@._-]/g, "_").slice(0, 200);
  const dir = path.join(MEETING_MINUTES_UPLOAD_DIR, safe);
  fsSync.mkdirSync(dir, { recursive: true });
  cb(null, dir);
}

function meetingMinutesAudioTimestampFilename(req, file, cb) {
  const email = req.meetingUserEmail || "user";
  const safe = String(email).replace(/[^a-zA-Z0-9@._-]/g, "_").slice(0, 200);
  const now = new Date();
  const p = (n) => String(n).padStart(2, "0");
  const yyyyMMdd = `${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}`;
  const HHmmss = `${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
  const ext = (path.extname(file.originalname) || ".webm").toLowerCase();
  cb(null, `${safe}_${yyyyMMdd}_${HHmmss}${ext}`);
}

function meetingMinutesAudioJobPrepareFilename(req, file, cb) {
  const ext = (path.extname(file.originalname) || ".webm").toLowerCase();
  cb(null, `${req.meetingAudioJobId}${ext}`);
}

function meetingMinutesAudioMulterFileFilter(_, file, cb) {
  const ext = path.extname(file.originalname).toLowerCase();
  const allowed = [".mp3", ".mp4", ".mpeg", ".mpga", ".m4a", ".wav", ".webm", ".ogg", ".flac"];
  if (!allowed.includes(ext)) {
    cb(new Error(`지원 음성 형식: ${allowed.join(", ")}`));
    return;
  }
  cb(null, true);
}

function assignMeetingAudioJobId(req, res, next) {
  req.meetingAudioJobId = uuidv4();
  next();
}

function isMeetingAudioJobId(raw) {
  return (
    typeof raw === "string" &&
    /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(raw.trim())
  );
}

function ensureMeetingAudioJobMetaDirSync() {
  fsSync.mkdirSync(MEETING_AUDIO_JOB_META_DIR, { recursive: true });
}

function meetingAudioJobMetaPath(jobId) {
  return path.join(MEETING_AUDIO_JOB_META_DIR, `${jobId}.json`);
}

/** candidate가 parentDir 안의 파일(직계·하위)인지 검사합니다. symbolic link는 고려하지 않습니다. */
function isResolvedPathStrictlyInside(parentDirAbs, candidatePathAbs) {
  const parent = path.resolve(parentDirAbs);
  const child = path.resolve(candidatePathAbs);
  const rel = path.relative(parent, child);
  return rel !== "" && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);
}

function resolvedMeetingMinutesUploadAudioPath(candidatePath) {
  const resolved = path.resolve(candidatePath);
  if (!isResolvedPathStrictlyInside(path.resolve(MEETING_MINUTES_UPLOAD_DIR), resolved)) {
    throw new Error("음성 업로드 경로가 허용 루트 밖입니다.");
  }
  return resolved;
}

const meetingMinutesAudioStorage = multer.diskStorage({
  destination: meetingMinutesUserUploadDestination,
  filename: meetingMinutesAudioTimestampFilename,
});
const uploadMeetingAudio = multer({
  storage: meetingMinutesAudioStorage,
  limits: { fileSize: MEETING_AUDIO_MAX_BYTES },
  fileFilter: meetingMinutesAudioMulterFileFilter,
});
const meetingMinutesAudioPrepStorage = multer.diskStorage({
  destination: meetingMinutesUserUploadDestination,
  filename: meetingMinutesAudioJobPrepareFilename,
});
const uploadMeetingAudioPrepare = multer({
  storage: meetingMinutesAudioPrepStorage,
  limits: { fileSize: MEETING_AUDIO_MAX_BYTES },
  fileFilter: meetingMinutesAudioMulterFileFilter,
});
function writeMeetingMinutesAudioSsePreamble(res) {
  res.setHeader("Content-Type", "text/event-stream");
  res.setHeader("Cache-Control", "no-cache");
  // Nginx 전용 버퍼 비활성화. Apache(mod_proxy_http)는 이 헤더를 해석하지 않음. 장시간 SSE는 ProxyTimeout 등으로 조정.
  res.setHeader("X-Accel-Buffering", "no");
  res.setHeader("Connection", "keep-alive");
  res.flushHeaders();
  for (let i = 0; i < 48; i += 1) {
    res.write(`: mm-sse-padding-${i}-${"#".repeat(40)}\n`);
  }
  res.write("\n");
  if (typeof res.flush === "function") res.flush();
}

/** @typedef {{ email:string, fileAbsPath?:string|null, originalName:string, title:string, model:string, whisperModel:string, meetingDateRaw: unknown, unlinkOnFailure?: boolean }} MeetingAudioSseCtx */

/** @param {import("express").Response} res @param {MeetingAudioSseCtx} ctx */
async function runMeetingMinutesGenerateAudioSsePipeline(res, ctx) {
  let completedOk = false;
  let heartbeatIv = /** @type {ReturnType<typeof setInterval>|null} */ (null);
  function sendEvent(event, data) {
    if (res.writableEnded) return;
    res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
    if (typeof res.flush === "function") res.flush();
  }
  const stopHeartbeat = () => {
    if (heartbeatIv) {
      clearInterval(heartbeatIv);
      heartbeatIv = null;
    }
  };
  const safeUnlinkIfStaged = () => {
    if (!ctx.unlinkOnFailure || !ctx.fileAbsPath) return;
    try {
      if (fsSync.existsSync(ctx.fileAbsPath)) fsSync.unlinkSync(ctx.fileAbsPath);
    } catch (_) {
      /* ignore */
    }
  };

  try {
    if (!OPENAI_API_KEY) {
      sendEvent("error", { message: "OPENAI_API_KEY가 설정되지 않았습니다." });
      res.end();
      return;
    }

    heartbeatIv = setInterval(() => {
      sendEvent("heartbeat", { t: Date.now() });
    }, 9000);

    sendEvent("accepted", {
      stage: "accepted",
      message: "서버에 파일 접수 후 설정을 불러오는 중입니다.",
    });

    await ensureMeetingUserAndDefaultPrompt(ctx.email);
    const title = (ctx.title || "").toString().trim().slice(0, 500);
    const model = (ctx.model || "gpt-5-mini").toString().trim();
    const whisperModel = (ctx.whisperModel || meetingMinutesLib.DEFAULT_TRANSCRIPTION_MODEL).toString().trim();
    const mdParsed = parseMeetingDateIso(ctx.meetingDateRaw);
    if (mdParsed.error) {
      sendEvent("error", { message: mdParsed.error });
      res.end();
      return;
    }
    const filePath = ctx.fileAbsPath;
    if (!filePath || !fsSync.existsSync(filePath)) {
      sendEvent("error", { message: "음성 파일을 찾을 수 없습니다." });
      res.end();
      return;
    }
    if (!MEETING_MINUTES_ALLOWED_MODELS.has(model)) {
      sendEvent("error", { message: "지원 모델: gpt-5-mini, gpt-5.4" });
      res.end();
      return;
    }
    if (!meetingMinutesLib.TRANSCRIPTION_UI_MODELS.has(whisperModel)) {
      sendEvent("error", {
        message: "지원 전사 모델: gpt-4o-mini-transcribe, gpt-4o-transcribe",
      });
      res.end();
      return;
    }
    const relPath = `/uploads/${path.relative(UPLOAD_DIR, filePath).split(path.sep).join("/")}`;
    const pr = await meetingAiStore.getPromptRow(pgPool, ctx.email);
    const promptRow = mapRowToMeetingPrompt(pr);
    const systemPrompt = meetingMinutesLib.buildMeetingMinutesSystemPrompt(promptRow);
    const openai = new OpenAI({ apiKey: OPENAI_API_KEY });

    sendEvent("progress", {
      stage: "prep",
      done: 0,
      total: 1,
      message:
        "전사 준비 중(음성 구간 처리·API 접속 단계입니다. 완료까지 수 분 걸릴 수 있습니다)",
    });

    let transcript = "";
    try {
      transcript = await meetingMinutesLib.transcribeMeetingAudio(
        openai,
        filePath,
        whisperModel,
        (progress) => sendEvent("progress", progress)
      );
    } catch (te) {
      const tmsg = te?.response?.data?.error?.message || te?.message || "전사 실패";
      sendEvent("error", { message: `Whisper 전사 실패: ${tmsg}` });
      res.end();
      return;
    }
    if (!transcript.trim()) {
      sendEvent("error", { message: "전사 결과가 비어 있습니다." });
      res.end();
      return;
    }

    sendEvent("generating", { message: "회의록을 작성하고 있습니다…" });

    const generated = await meetingMinutesLib.generateMeetingMinutes(openai, {
      systemPrompt,
      userContent: transcript,
      uiModel: model,
      resolveApiModel: resolveOpenAiApiModel,
      omitMeetingChecklistSection: promptRow?.includeChecklist !== true,
    });
    const ins = await meetingAiStore.insertMeetingAudio(pgPool, {
      email: ctx.email,
      title,
      transcript,
      generated,
      relPath,
      originalName: ctx.originalName,
      model,
      whisperModel,
      meetingDate: mdParsed.value,
    });
    let checklistSync = { imported: 0 };
    try {
      checklistSync = await syncAutoChecklistFromMeetingMinutes(openai, {
        pgPool,
        email: ctx.email,
        meetingId: ins.id,
        generatedMinutes: generated,
        uiModel: model,
      });
    } catch (e) {
      checklistSync = { imported: 0, extractError: e?.message };
    }
    const meetingOut = mapRowToMeeting(ins);
    if (checklistSync.snapshot) meetingOut.checklistSnapshot = checklistSync.snapshot;
    completedOk = true;
    sendEvent("done", { meeting: meetingOut, checklistSync });
    res.end();
  } catch (err) {
    const msg = err?.response?.data?.error?.message || err?.message || "생성 실패";
    if (!res.writableEnded) {
      sendEvent("error", { message: msg });
      res.end();
    }
  } finally {
    stopHeartbeat();
    if (!completedOk && ctx.unlinkOnFailure) safeUnlinkIfStaged();
  }
}

const MGMT_PERF_UPLOAD_DIR = path.join(ROOT_DIR, "uploads", "mgmt-perf");
const mgmtPerfStorage = multer.diskStorage({
  destination: (_, __, cb) => {
    fsSync.mkdirSync(MGMT_PERF_UPLOAD_DIR, { recursive: true });
    cb(null, MGMT_PERF_UPLOAD_DIR);
  },
  filename: (_, file, cb) => {
    const ext = (path.extname(file.originalname) || ".xlsx").toLowerCase();
    const safeExt = ext === ".xlsx" ? ".xlsx" : ".xlsx";
    cb(null, `${Date.now()}-${uuidv4()}${safeExt}`);
  },
});
const uploadMgmtPerfExcel = multer({
  storage: mgmtPerfStorage,
  limits: { fileSize: 55 * 1024 * 1024 },
  /** 기본(latin1): `filename`을 바이트→U+00xx 문자열로 두고 `decodeUploadFilename`에서 UTF-8 복원. `utf8`이면 이중 디코딩·깨짐이 날 수 있음. */
  fileFilter: (_, file, cb) => {
    const ext = path.extname(file.originalname).toLowerCase();
    if (ext !== ".xlsx") {
      cb(new Error("Excel (.xlsx) 파일만 업로드할 수 있습니다."));
      return;
    }
    cb(null, true);
  },
});

const uploadUseCaseStorage = multer.diskStorage({
  destination: (req, file, cb) => {
    const id = req._useCaseSubmissionId;
    if (!id) {
      cb(new Error("internal: submission id missing"));
      return;
    }
    const sub = file.fieldname === "thumbnail" ? "thumb" : "files";
    const d = path.join(UPLOAD_DIR, "ai-use-case-submissions", id, sub);
    fsSync.mkdirSync(d, { recursive: true });
    cb(null, d);
  },
  filename: (req, file, cb) => {
    const orig = decodeUploadFilename(file.originalname);
    const safe = `${Date.now()}-${String(orig).replace(/[^a-zA-Z0-9._\-\uAC00-\uD7A3]/g, "_")}`;
    cb(null, safe.slice(0, 200));
  },
});
const uploadUseCase = multer({
  storage: uploadUseCaseStorage,
  limits: { fileSize: Math.max(aiUseCaseSubmissions.MAX_ATTACH_BYTES, aiUseCaseSubmissions.MAX_THUMB_BYTES) },
  fileFilter: (req, file, cb) => {
    if (file.fieldname === "thumbnail") {
      if (!/^image\//.test(file.mimetype)) {
        cb(new Error("썸네일은 이미지 파일만 업로드할 수 있습니다."));
        return;
      }
    }
    cb(null, true);
  },
});
const uploadUseCaseFields = uploadUseCase.fields([
  { name: "thumbnail", maxCount: aiUseCaseSubmissions.MAX_THUMB_COUNT },
  { name: "attachments", maxCount: aiUseCaseSubmissions.MAX_ATTACH_COUNT },
]);

const uploadPromptCommunityStorage = multer.diskStorage({
  destination: (req, file, cb) => {
    const id = req._promptBundleId;
    if (!id) {
      cb(new Error("internal: prompt bundle id missing"));
      return;
    }
    const sub = file.fieldname === "resultFiles" ? "result" : "prompt";
    const d = path.join(UPLOAD_DIR, "prompt-community", id, sub);
    fsSync.mkdirSync(d, { recursive: true });
    cb(null, d);
  },
  filename: (req, file, cb) => {
    const orig = decodeUploadFilename(file.originalname);
    const safe = `${Date.now()}-${String(orig).replace(/[^a-zA-Z0-9._\uAC00-\uD7A3]/g, "_")}`;
    cb(null, safe.slice(0, 200));
  },
});
const uploadPromptCommunity = multer({
  storage: uploadPromptCommunityStorage,
  limits: { fileSize: promptLibrary.MAX_ATTACH_BYTES },
  fileFilter: (req, file, cb) => {
    cb(null, true);
  },
});
const uploadPromptCommunityFields = uploadPromptCommunity.fields([
  { name: "promptFiles", maxCount: promptLibrary.MAX_ATTACH_PER_GROUP },
  { name: "resultFiles", maxCount: promptLibrary.MAX_ATTACH_PER_GROUP },
]);

const mapRowToAxAssignment = (row) => ({
  id: row.id,
  department: row.department || "",
  name: row.name || "",
  employeeId: row.employee_id || "",
  position: row.position || "",
  phone: row.phone || "",
  email: row.email || "",
  workProcessDescription: row.work_process_description || "",
  painPoint: row.pain_point || "",
  currentTimeSpent: row.current_time_spent || "",
  errorRateBefore: row.error_rate_before || "",
  collaborationDepts: row.collaboration_depts || "",
  reasonToSolve: row.reason_to_solve || "",
  aiExpectation: row.ai_expectation || "",
  outputType: row.output_type || "",
  automationLevel: row.automation_level || "",
  dataReadiness: row.data_readiness || "",
  dataLocation: row.data_location || "",
  personalInfo: row.personal_info || "",
  dataQuality: row.data_quality || "",
  dataCount: row.data_count || "",
  dataTypes: Array.isArray(row.data_types) ? row.data_types : [],
  timeReduction: row.time_reduction || "",
  errorReduction: row.error_reduction || "",
  volumeIncrease: row.volume_increase || "",
  costReduction: row.cost_reduction || "",
  responseTime: row.response_time || "",
  otherMetrics: row.other_metrics || "",
  annualSavings: row.annual_savings || "",
  laborReplacement: row.labor_replacement || "",
  revenueIncrease: row.revenue_increase || "",
  otherEffects: row.other_effects || "",
  qualitativeEffects: Array.isArray(row.qualitative_effects) ? row.qualitative_effects : [],
  techStack: Array.isArray(row.tech_stack) ? row.tech_stack : [],
  risks: Array.isArray(row.risks) ? row.risks : [],
  riskDetail: row.risk_detail || "",
  participationPledge: row.participation_pledge === true,
  applicationFile: row.application_file || "",
  status: row.status || "신청",
  createdAt: row.created_at ? new Date(row.created_at).toISOString() : new Date().toISOString(),
  updatedAt: row.updated_at ? new Date(row.updated_at).toISOString() : new Date().toISOString(),
});

const readAxAssignmentsDb = async () => {
  if (pgPool) {
    try {
      const res = await pgPool.query(
        "SELECT id, department, name, employee_id, position, phone, email, work_process_description, pain_point, current_time_spent, error_rate_before, collaboration_depts, reason_to_solve, ai_expectation, output_type, automation_level, data_readiness, data_location, personal_info, data_quality, data_count, data_types, time_reduction, error_reduction, volume_increase, cost_reduction, response_time, other_metrics, annual_savings, labor_replacement, revenue_increase, other_effects, qualitative_effects, tech_stack, risks, risk_detail, participation_pledge, application_file, status, created_at, updated_at FROM ax_assignments ORDER BY created_at DESC"
      );
      return (res.rows || []).map(mapRowToAxAssignment);
    } catch (err) {
      console.error("readAxAssignmentsDb from PostgreSQL failed:", err.message);
      return [];
    }
  }
  try {
    const raw = await fs.readFile(AX_ASSIGNMENTS_DB_PATH, "utf-8");
    const parsed = JSON.parse(raw);
    return Array.isArray(parsed) ? parsed : [];
  } catch {
    return [];
  }
};

const readAxAssignmentsByCredentials = async (department, name, email) => {
  const d = (department || "").trim().toLowerCase();
  const n = (name || "").trim().toLowerCase();
  const e = (email || "").trim().toLowerCase();
  if (!d || !n || !e) return [];
  const all = await readAxAssignmentsDb();
  return all.filter(
    (a) =>
      (a.department || "").trim().toLowerCase() === d &&
      (a.name || "").trim().toLowerCase() === n &&
      (a.email || "").trim().toLowerCase() === e
  );
};

const isSampleRecord = (a) =>
  (a?.department || "").trim() === "샘플" && (a?.name || "").trim() === "데이터";

const ensureStringArray = (val) => {
  if (val === "" || val === null || val === undefined) return [];
  if (Array.isArray(val)) return val.filter((x) => typeof x === "string");
  if (typeof val === "string" && val.trim()) return val.split(",").map((s) => s.trim()).filter(Boolean);
  return [];
};

const toPgArray = (arr) => {
  if (arr === "" || arr === null || arr === undefined) return null;
  const a = ensureStringArray(arr);
  return a.length > 0 ? a : null;
};

const readAxAssignmentById = async (id) => {
  if (!id) return null;
  if (pgPool) {
    try {
      const res = await pgPool.query(
        "SELECT id, department, name, employee_id, position, phone, email, work_process_description, pain_point, current_time_spent, error_rate_before, collaboration_depts, reason_to_solve, ai_expectation, output_type, automation_level, data_readiness, data_location, personal_info, data_quality, data_count, data_types, time_reduction, error_reduction, volume_increase, cost_reduction, response_time, other_metrics, annual_savings, labor_replacement, revenue_increase, other_effects, qualitative_effects, tech_stack, risks, risk_detail, participation_pledge, application_file, status, created_at, updated_at FROM ax_assignments WHERE id = $1",
        [id]
      );
      return res.rows?.[0] ? mapRowToAxAssignment(res.rows[0]) : null;
    } catch {
      return null;
    }
  }
  try {
    const raw = await fs.readFile(AX_ASSIGNMENTS_DB_PATH, "utf-8");
    const parsed = JSON.parse(raw);
    const list = Array.isArray(parsed) ? parsed : [];
    const found = list.find((a) => a.id === id);
    return found ? { ...found, applicationFile: found.applicationFile || found.application_file || "" } : null;
  } catch {
    return null;
  }
};

async function updateAxAssignmentDb(id, data) {
  const existing = await readAxAssignmentById(id);
  if (!existing) return null;
  const existingAppFile = (existing.applicationFile || existing.application_file || "").trim();
  const appFile = (data.applicationFile || data.application_file || "").trim();
  const finalAppFile = appFile || existingAppFile;

  const cleanArr = (v) => (v === "" || typeof v === "string" ? undefined : v);
  const dataTypes = cleanArr(data.dataTypes ?? data.data_types) ?? existing.dataTypes;
  const qualitativeEffects = cleanArr(data.qualitativeEffects ?? data.qualitative_effects) ?? existing.qualitativeEffects;
  const techStack = cleanArr(data.techStack ?? data.techStackStr ?? data.tech_stack) ?? existing.techStack;
  const risks = cleanArr(data.risks ?? data.risksStr ?? data.risks) ?? existing.risks;

  const row = {
    id,
    department: ((data.department ?? existing.department) || "").trim(),
    name: ((data.name ?? existing.name) || "").trim(),
    employeeId: ((data.employeeId ?? data.employee_id ?? existing.employeeId) || "").trim(),
    position: ((data.position ?? existing.position) || "").trim(),
    phone: ((data.phone ?? existing.phone) || "").trim(),
    email: ((data.email ?? existing.email) || "").trim(),
    workProcessDescription: ((data.workProcessDescription ?? data.work_process_description ?? existing.workProcessDescription) || "").trim(),
    painPoint: ((data.painPoint ?? data.pain_point ?? existing.painPoint) || "").trim(),
    currentTimeSpent: ((data.currentTimeSpent ?? data.current_time_spent ?? existing.currentTimeSpent) || "").trim(),
    errorRateBefore: ((data.errorRateBefore ?? data.error_rate_before ?? existing.errorRateBefore) || "").trim(),
    collaborationDepts: ((data.collaborationDepts ?? data.collaboration_depts ?? existing.collaborationDepts) || "").trim(),
    reasonToSolve: ((data.reasonToSolve ?? data.reason_to_solve ?? existing.reasonToSolve) || "").trim(),
    aiExpectation: ((data.aiExpectation ?? data.ai_expectation ?? existing.aiExpectation) || "").trim(),
    outputType: ((data.outputType ?? data.output_type ?? existing.outputType) || "").trim(),
    automationLevel: ((data.automationLevel ?? data.automation_level ?? existing.automationLevel) || "").trim(),
    dataReadiness: ((data.dataReadiness ?? data.data_readiness ?? existing.dataReadiness) || "").trim(),
    dataLocation: ((data.dataLocation ?? data.data_location ?? existing.dataLocation) || "").trim(),
    personalInfo: ((data.personalInfo ?? data.personal_info ?? existing.personalInfo) || "").trim(),
    dataQuality: ((data.dataQuality ?? data.data_quality ?? existing.dataQuality) || "").trim(),
    dataCount: ((data.dataCount ?? data.data_count ?? existing.dataCount) || "").trim(),
    dataTypes: toPgArray(dataTypes),
    timeReduction: ((data.timeReduction ?? data.time_reduction ?? existing.timeReduction) || "").trim(),
    errorReduction: ((data.errorReduction ?? data.error_reduction ?? existing.errorReduction) || "").trim(),
    volumeIncrease: ((data.volumeIncrease ?? data.volume_increase ?? existing.volumeIncrease) || "").trim(),
    costReduction: ((data.costReduction ?? data.cost_reduction ?? existing.costReduction) || "").trim(),
    responseTime: ((data.responseTime ?? data.response_time ?? existing.responseTime) || "").trim(),
    otherMetrics: ((data.otherMetrics ?? data.other_metrics ?? existing.otherMetrics) || "").trim(),
    annualSavings: ((data.annualSavings ?? data.annual_savings ?? existing.annualSavings) || "").trim(),
    laborReplacement: ((data.laborReplacement ?? data.labor_replacement ?? existing.laborReplacement) || "").trim(),
    revenueIncrease: ((data.revenueIncrease ?? data.revenue_increase ?? existing.revenueIncrease) || "").trim(),
    otherEffects: ((data.otherEffects ?? data.other_effects ?? existing.otherEffects) || "").trim(),
    qualitativeEffects: toPgArray(qualitativeEffects),
    techStack: toPgArray(techStack),
    risks: toPgArray(risks),
    riskDetail: ((data.riskDetail ?? data.risk_detail ?? existing.riskDetail) || "").trim(),
    participationPledge: data.participationPledge === true || data.participation_pledge === true,
    applicationFile: finalAppFile,
    status: existing.status || "신청",
    createdAt: existing.createdAt,
    updatedAt: new Date().toISOString(),
  };

  if (pgPool) {
    const toArrayLiteralSafe = (v) => {
      if (v === "" || (typeof v === "string") || v == null) return "'{}'";
      const arr = Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
      if (arr.length === 0) return "'{}'";
      const escaped = arr.map((s) => `"${String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`);
      return `'{${escaped.join(",")}}'`;
    };
    const dataTypesLit = toArrayLiteralSafe(row.dataTypes);
    const qualitativeLit = toArrayLiteralSafe(row.qualitativeEffects);
    const techStackLit = toArrayLiteralSafe(row.techStack);
    const risksLit = toArrayLiteralSafe(row.risks);

    try {
      await pgPool.query(
        `UPDATE ax_assignments SET
          department = $2, name = $3, employee_id = $4, position = $5, phone = $6, email = $7,
          work_process_description = $8, pain_point = $9, current_time_spent = $10, error_rate_before = $11,
          collaboration_depts = $12, reason_to_solve = $13, ai_expectation = $14, output_type = $15, automation_level = $16,
          data_readiness = $17, data_location = $18, personal_info = $19, data_quality = $20, data_count = $21, data_types = ${dataTypesLit}::text[],
          time_reduction = $22, error_reduction = $23, volume_increase = $24, cost_reduction = $25, response_time = $26,
          other_metrics = $27, annual_savings = $28, labor_replacement = $29, revenue_increase = $30, other_effects = $31,
          qualitative_effects = ${qualitativeLit}::text[], tech_stack = ${techStackLit}::text[], risks = ${risksLit}::text[],
          risk_detail = $32, participation_pledge = $33, application_file = $34, updated_at = $35
        WHERE id = $1::uuid`,
        [
          row.id, row.department, row.name, row.employeeId, row.position, row.phone, row.email,
          row.workProcessDescription, row.painPoint, row.currentTimeSpent, row.errorRateBefore,
          row.collaborationDepts, row.reasonToSolve, row.aiExpectation, row.outputType, row.automationLevel,
          row.dataReadiness, row.dataLocation, row.personalInfo, row.dataQuality, row.dataCount,
          row.timeReduction, row.errorReduction, row.volumeIncrease, row.costReduction, row.responseTime,
          row.otherMetrics, row.annualSavings, row.laborReplacement, row.revenueIncrease, row.otherEffects,
          row.riskDetail, row.participationPledge, row.applicationFile || null,
          new Date(row.updatedAt),
        ]
      );
    } catch (err) {
      console.error("updateAxAssignmentDb failed:", err.message);
      throw err;
    }
  } else {
    const raw = await fs.readFile(AX_ASSIGNMENTS_DB_PATH, "utf-8");
    const list = Array.isArray(JSON.parse(raw)) ? JSON.parse(raw) : [];
    const idx = list.findIndex((a) => a.id === id);
    if (idx >= 0) {
      list[idx] = { ...list[idx], ...row };
      await fs.writeFile(AX_ASSIGNMENTS_DB_PATH, JSON.stringify(list, null, 2), "utf-8");
    }
  }

  return row;
};

const createAxAssignmentDb = async (data) => {
  const id = uuidv4();
  const now = new Date().toISOString();

  const cleanArr = (v) => (v === "" || typeof v === "string" ? undefined : v);
  const dataTypes = cleanArr(data.dataTypes ?? data.data_types);
  const qualitativeEffects = cleanArr(data.qualitativeEffects ?? data.qualitative_effects);
  const techStack = cleanArr(data.techStack ?? data.techStackStr ?? data.tech_stack);
  const risks = cleanArr(data.risks ?? data.risksStr);

  const row = {
    id,
    department: (data.department || "").trim(),
    name: (data.name || "").trim(),
    employeeId: (data.employeeId || data.employee_id || "").trim(),
    position: (data.position || "").trim(),
    phone: (data.phone || "").trim(),
    email: (data.email || "").trim(),
    workProcessDescription: (data.workProcessDescription || data.work_process_description || "").trim(),
    painPoint: (data.painPoint || data.pain_point || "").trim(),
    currentTimeSpent: (data.currentTimeSpent || data.current_time_spent || "").trim(),
    errorRateBefore: (data.errorRateBefore || data.error_rate_before || "").trim(),
    collaborationDepts: (data.collaborationDepts || data.collaboration_depts || "").trim(),
    reasonToSolve: (data.reasonToSolve || data.reason_to_solve || "").trim(),
    aiExpectation: (data.aiExpectation || data.ai_expectation || "").trim(),
    outputType: (data.outputType || data.output_type || "").trim(),
    automationLevel: (data.automationLevel || data.automation_level || "").trim(),
    dataReadiness: (data.dataReadiness || data.data_readiness || "").trim(),
    dataLocation: (data.dataLocation || data.data_location || "").trim(),
    personalInfo: (data.personalInfo || data.personal_info || "").trim(),
    dataQuality: (data.dataQuality || data.data_quality || "").trim(),
    dataCount: (data.dataCount || data.data_count || "").trim(),
    dataTypes: toPgArray(dataTypes),
    timeReduction: (data.timeReduction || data.time_reduction || "").trim(),
    errorReduction: (data.errorReduction || data.error_reduction || "").trim(),
    volumeIncrease: (data.volumeIncrease || data.volume_increase || "").trim(),
    costReduction: (data.costReduction || data.cost_reduction || "").trim(),
    responseTime: (data.responseTime || data.response_time || "").trim(),
    otherMetrics: (data.otherMetrics || data.other_metrics || "").trim(),
    annualSavings: (data.annualSavings || data.annual_savings || "").trim(),
    laborReplacement: (data.laborReplacement || data.labor_replacement || "").trim(),
    revenueIncrease: (data.revenueIncrease || data.revenue_increase || "").trim(),
    otherEffects: (data.otherEffects || data.other_effects || "").trim(),
    qualitativeEffects: toPgArray(qualitativeEffects),
    techStack: toPgArray(techStack),
    risks: toPgArray(risks),
    riskDetail: (data.riskDetail || data.risk_detail || "").trim(),
    participationPledge: data.participationPledge === true || data.participation_pledge === true,
    applicationFile: (data.applicationFile || data.application_file || "").trim(),
    status: "신청",
    createdAt: now,
    updatedAt: now,
  };
  const toSafeArrayParam = (v) => {
    if (v === "" || (typeof v === "string") || v === undefined) return null;
    if (!Array.isArray(v)) return null;
    const filtered = v.filter((x) => typeof x === "string");
    return filtered.length > 0 ? filtered : null;
  };

  if (pgPool) {
    const toArrayLiteralSafe = (v) => {
      if (v === "" || (typeof v === "string") || v == null) return "'{}'";
      const arr = Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
      if (arr.length === 0) return "'{}'";
      const escaped = arr.map((s) => `"${String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`);
      return `'{${escaped.join(",")}}'`;
    };
    const dataTypesLit = toArrayLiteralSafe(row.dataTypes);
    const qualitativeLit = toArrayLiteralSafe(row.qualitativeEffects);
    const techStackLit = toArrayLiteralSafe(row.techStack);
    const risksLit = toArrayLiteralSafe(row.risks);

    try {
      await pgPool.query(
        `INSERT INTO ax_assignments (
          id, department, name, employee_id, position, phone, email,
          work_process_description, pain_point, current_time_spent, error_rate_before,
          collaboration_depts, reason_to_solve, ai_expectation, output_type, automation_level,
          data_readiness, data_location, personal_info, data_quality, data_count, data_types,
          time_reduction, error_reduction, volume_increase, cost_reduction, response_time,
          other_metrics, annual_savings, labor_replacement, revenue_increase, other_effects,
          qualitative_effects, tech_stack, risks, risk_detail, participation_pledge, application_file, status,
          created_at, updated_at
        ) VALUES (
          $1::uuid, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16,
          $17, $18, $19, $20, $21, ${dataTypesLit}::text[], $22, $23, $24, $25, $26, $27, $28, $29, $30,
          $31, ${qualitativeLit}::text[], ${techStackLit}::text[], ${risksLit}::text[], $32, $33, $34, $35, $36, $37
        )`,
        [
          row.id, row.department, row.name, row.employeeId, row.position, row.phone, row.email,
          row.workProcessDescription, row.painPoint, row.currentTimeSpent, row.errorRateBefore,
          row.collaborationDepts, row.reasonToSolve, row.aiExpectation, row.outputType, row.automationLevel,
          row.dataReadiness, row.dataLocation, row.personalInfo, row.dataQuality, row.dataCount,
          row.timeReduction, row.errorReduction, row.volumeIncrease, row.costReduction, row.responseTime,
          row.otherMetrics, row.annualSavings, row.laborReplacement, row.revenueIncrease, row.otherEffects,
          row.riskDetail, row.participationPledge, row.applicationFile || null, row.status,
          new Date(row.createdAt), new Date(row.updatedAt),
        ]
      );
    } catch (err) {
      console.error("createAxAssignmentDb failed:", err.message);
      throw err;
    }
  } else {
    const list = await readAxAssignmentsDb();
    list.unshift(row);
    await fs.writeFile(AX_ASSIGNMENTS_DB_PATH, JSON.stringify(list, null, 2), "utf-8");
  }
  return row;
};

const deleteAxAssignmentDb = async (id) => {
  if (!id) return false;
  if (pgPool) {
    try {
      const res = await pgPool.query("DELETE FROM ax_assignments WHERE id = $1::uuid", [id]);
      return (res.rowCount || 0) > 0;
    } catch (err) {
      console.error("deleteAxAssignmentDb failed:", err.message);
      throw err;
    }
  }
  try {
    const raw = await fs.readFile(AX_ASSIGNMENTS_DB_PATH, "utf-8");
    const list = Array.isArray(JSON.parse(raw)) ? JSON.parse(raw) : [];
    const idx = list.findIndex((a) => a.id === id);
    if (idx < 0) return false;
    list.splice(idx, 1);
    await fs.writeFile(AX_ASSIGNMENTS_DB_PATH, JSON.stringify(list, null, 2), "utf-8");
    return true;
  } catch {
    return false;
  }
};

app.set("view engine", "ejs");
app.set("views", path.join(ROOT_DIR, "views"));

app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use(cookieParser());

const ADMIN_COOKIE_NAME = "admin_session";
const ADMIN_COOKIE_MAX_AGE = 24 * 60 * 60 * 1000; // 24시간

app.use((req, res, next) => {
  const cookieToken = req.cookies?.[ADMIN_COOKIE_NAME] || "";
  res.locals.adminMode =
    ADMIN_TOKEN.length > 0 && cookieToken.length > 0 && cookieToken === ADMIN_TOKEN;
  next();
});

/** GPT 채팅: SUPER 전면 허용. DEV는 관리자 토큰 세션. PROD는 이메일(OPS) 인증 세션만 */
function isChatGptAllowed(req, res) {
  if (isOpsStateSuper()) return true;
  if (isOpsStateDev() && res.locals.adminMode) return true;
  if (isOpsStateProd() && res.locals.opsUserEmail) return true;
  return false;
}

/** AI 활용 사례 글쓰기: OPS 이메일 로그인, 로컬(DEV)에서는 관리자+MEETING_DEV_EMAIL */
function getAiUseCaseSubmitterEmail(req, res) {
  if (res.locals.opsUserEmail) {
    return String(res.locals.opsUserEmail).trim().toLowerCase();
  }
  if (isOpsStateDev() && res.locals.adminMode) {
    const dev = (process.env.MEETING_DEV_EMAIL || "").trim().toLowerCase();
    if (dev) return dev;
  }
  return null;
}

/** AI 활용 사례 상세: DEV일 때만 관리자 모드로 제한. PROD·SUPER는 상세 열람 가능 */
function isAiSuccessStoryDetailAllowed(req, res) {
  if (!isOpsStateDev()) return true;
  return !!res.locals.adminMode;
}

/** 과제신청: DEV에서만 관리자 제한. PROD·SUPER는 제한 없음 */
function isAxApplyDownloadSubmitAllowed(req, res) {
  if (!isOpsStateDev()) return true;
  return !!res.locals.adminMode;
}

/** AI 탐색(/ai-explore): DEV·비관리자일 때 프롬프트 카드만 사용 가능 */
function isAiExploreDevGuestRestricted(req, res) {
  return isOpsStateDev() && !res.locals.adminMode;
}

/** 프롬프트 라이브러리 API: OPS(또는 DEV 관리자+MEETING_DEV_EMAIL) */
function requirePromptLibEmail(req, res, next) {
  const email = getAiUseCaseSubmitterEmail(req, res);
  if (!email) {
    return res.status(401).json({ error: "이메일(OPS) 로그인이 필요합니다." });
  }
  req.promptLibEmail = email;
  next();
}

/** `.env` DASHBOARD_MENU_ALLOWED_EMAILS (쉼표 구분, 소문자 정규화) */
function parseDashboardMenuAllowlist() {
  return String(process.env.DASHBOARD_MENU_ALLOWED_EMAILS || "")
    .split(",")
    .map((s) => s.trim().toLowerCase())
    .filter(Boolean);
}
const DASHBOARD_MENU_ALLOWLIST = parseDashboardMenuAllowlist();

/**
 * 대시보드 메뉴·경로 허용 여부: 목록에 있고, (OPS 로그인 이메일 또는 DEV에서 관리자+MEETING_DEV_EMAIL 대조)
 */
function getEmailForDashboardAccess(req, res) {
  if (res.locals.opsUserEmail) {
    return String(res.locals.opsUserEmail).trim().toLowerCase();
  }
  if (
    isOpsStateDev() &&
    res.locals.adminMode &&
    process.env.DASHBOARD_MENU_DEV_USE_MEETING_EMAIL === "1"
  ) {
    return (process.env.MEETING_DEV_EMAIL || "").trim().toLowerCase();
  }
  return null;
}

function computeDashboardMenuAllowed(req, res) {
  if (DASHBOARD_MENU_ALLOWLIST.length === 0) return false;
  const email = getEmailForDashboardAccess(req, res);
  if (!email) return false;
  return DASHBOARD_MENU_ALLOWLIST.includes(email);
}

/** 가이드봇·WM 메뉴: 관리자 또는 허용 OPS 이메일(기본 dsyoon@ncue.net) */
function parseGuideBotMenuAllowlist() {
  return String(process.env.GUIDE_BOT_MENU_ALLOWED_EMAILS || "dsyoon@ncue.net")
    .split(",")
    .map((s) => s.trim().toLowerCase())
    .filter(Boolean);
}
const GUIDE_BOT_MENU_ALLOWLIST = parseGuideBotMenuAllowlist();

function computeGuideBotMenuAllowed(req, res) {
  if (res.locals.adminMode) return true;
  const email = getEmailForDashboardAccess(req, res);
  if (!email) return false;
  return GUIDE_BOT_MENU_ALLOWLIST.includes(email);
}

function requireGuideBotMenuAccess(req, res, next) {
  if (computeGuideBotMenuAllowed(req, res)) return next();
  if (String(req.path || "").startsWith("/api/")) {
    return res.status(403).json({ error: "가이드봇·WM 접근 권한이 없습니다." });
  }
  return res
    .status(403)
    .type("html")
    .send(
      `<!DOCTYPE html><html lang="ko"><head><meta charset="utf-8"/><title>권한 없음</title><link rel="icon" href="/favicon.ico" type="image/x-icon"/></head><body style="font-family:sans-serif;padding:24px;"><p>가이드봇·WM은 허용된 계정 또는 관리자만 이용할 수 있습니다.</p><p><a href="/ai-explore">AI 탐색으로</a></p></body></html>`
    );
}

/** 업무 체크리스트 AI: 허용 OPS 이메일만(기본 dsyoon@ncue.net, 관리자 예외 없음) */
function parseTaskChecklistAllowlist() {
  return String(process.env.TASK_CHECKLIST_ALLOWED_EMAILS || "dsyoon@ncue.net")
    .split(",")
    .map((s) => s.trim().toLowerCase())
    .filter(Boolean);
}
const TASK_CHECKLIST_ALLOWLIST = parseTaskChecklistAllowlist();

function computeTaskChecklistMenuAllowed(req, res) {
  const email = getEmailForDashboardAccess(req, res);
  if (!email) return false;
  return TASK_CHECKLIST_ALLOWLIST.includes(email);
}

function requireTaskChecklistAccess(req, res, next) {
  if (computeTaskChecklistMenuAllowed(req, res)) return next();
  if (String(req.path || "").startsWith("/api/")) {
    return res.status(403).json({ error: "업무 체크리스트 AI 접근 권한이 없습니다." });
  }
  return res
    .status(403)
    .type("html")
    .send(
      `<!DOCTYPE html><html lang="ko"><head><meta charset="utf-8"/><title>권한 없음</title><link rel="icon" href="/favicon.ico" type="image/x-icon"/></head><body style="font-family:sans-serif;padding:24px;"><p>업무 체크리스트 AI는 허용된 계정만 이용할 수 있습니다.</p><p><a href="/ai-explore">AI 탐색으로</a></p></body></html>`
    );
}

function requireDashboardAccess(req, res, next) {
  if (computeDashboardMenuAllowed(req, res)) return next();
  if (String(req.path || "").startsWith("/api/")) {
    return res.status(403).json({ error: "대시보드 접근 권한이 없습니다." });
  }
  return res
    .status(403)
    .type("html")
    .send(
      `<!DOCTYPE html><html lang="ko"><head><meta charset="utf-8"/><title>권한 없음</title><link rel="icon" href="/favicon.ico" type="image/x-icon"/></head><body style="font-family:sans-serif;padding:24px;"><p>대시보드 접근 권한이 없습니다.</p><p><a href="/learning">학습센터로</a></p></body></html>`
    );
}

/** OPS 이메일 세션, DEV+관리자(MEETING_DEV_EMAIL), SUPER(데모 이메일) — 회의록 AI */
function getMeetingMinutesUserEmail(req, res) {
  if (res.locals.opsUserEmail) return String(res.locals.opsUserEmail).trim().toLowerCase();
  if (isOpsStateDev() && res.locals.adminMode) {
    return (process.env.MEETING_DEV_EMAIL || "dev@ncue.net").trim().toLowerCase();
  }
  if (isOpsStateSuper()) {
    return (process.env.MEETING_SUPER_EMAIL || process.env.MEETING_DEV_EMAIL || "demo@xavis.local")
      .trim()
      .toLowerCase();
  }
  return null;
}

const MEETING_MINUTES_ALLOWED_MODELS = new Set(["gpt-5-mini", "gpt-5.4"]);

/** YYYY-MM-DD 검증, 실패 시 { error } */
function parseMeetingDateIso(raw) {
  if (raw == null || String(raw).trim() === "") return { value: null };
  const s = String(raw).trim().slice(0, 10);
  if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return { error: "날짜는 YYYY-MM-DD 형식이어야 합니다." };
  const [y, mo, d] = s.split("-").map(Number);
  const dt = new Date(y, mo - 1, d);
  if (dt.getFullYear() !== y || dt.getMonth() !== mo - 1 || dt.getDate() !== d) return { error: "유효하지 않은 날짜입니다." };
  return { value: s };
}

function requireMeetingMinutesEmail(req, res, next) {
  const email = getMeetingMinutesUserEmail(req, res);
  if (!email) {
    res.status(401).json({ error: "이메일 로그인이 필요합니다." });
    return;
  }
  req.meetingUserEmail = email;
  next();
}

async function ensureMeetingUserAndDefaultPrompt(email) {
  await meetingAiStore.ensureUserAndDefaultPrompt(pgPool, email);
}

function mapRowToMeetingPrompt(row) {
  if (!row) return null;
  return {
    id: row.id,
    userEmail: row.user_email,
    includeTitleLine: row.include_title_line !== false,
    includeAttendees: row.include_attendees !== false,
    includeSummary: row.include_summary !== false,
    includeActionItems: row.include_action_items !== false,
    includeChecklist: row.include_checklist === true,
    customInstructions: row.custom_instructions || "",
    createdAt: row.created_at,
    updatedAt: row.updated_at,
  };
}

function mapRowToMeeting(row) {
  if (!row) return null;
  return {
    id: row.id,
    userEmail: row.user_email,
    title: row.title || "",
    sourceText: row.source_text || "",
    transcriptText: row.transcript_text || "",
    generatedMinutes: meetingMinutesLib.prepareMeetingMinutesForApi(row.generated_minutes || ""),
    audioFilePath: row.audio_file_path || "",
    audioOriginalName: row.audio_original_name || "",
    chatModel: row.chat_model || "gpt-5.4",
    transcriptionModel: row.transcription_model || null,
    meetingDate: row.meeting_date != null ? formatMeetingDateOnly(row.meeting_date) : null,
    createdAt: row.created_at ? new Date(row.created_at).toISOString() : null,
    updatedAt: row.updated_at ? new Date(row.updated_at).toISOString() : null,
    checklistSnapshot: row.checklist_snapshot != null ? row.checklist_snapshot : null,
    summaryText: row.summary_text != null && String(row.summary_text).trim() ? String(row.summary_text).trim() : "",
  };
}

/**
 * 규칙 기반 액션(번호)·체크리스트를 우선하고 LLM 추출로 보완 (제목 기준 중복 제거)
 * @param {string} markdown
 * @param {Array<{ title: string, detail?: string, assignee?: string|null, due_note?: string|null, completed?: boolean }>} llmItems
 */
function mergeLlmAndRuleWorkItemsForSync(markdown, llmItems) {
  const rule = parseChecklistFromMinutes.parseAllRuleBasedWorkItems(markdown);
  const seen = new Set();
  /** @type {Array<{ title: string, detail: string, assignee: string|null, due_note: string|null, completed: boolean }>} */
  const out = [];
  function push(arr) {
    for (const it of arr) {
      const t = (it.title || "").trim().toLowerCase();
      if (!t) continue;
      if (seen.has(t)) continue;
      seen.add(t);
      out.push({
        title: String(it.title).trim(),
        detail: String(it.detail != null ? it.detail : "").trim(),
        assignee: it.assignee != null && String(it.assignee).trim() ? String(it.assignee).trim() : null,
        due_note: it.due_note != null && String(it.due_note).trim() ? String(it.due_note).trim() : null,
        completed: !!it.completed,
      });
    }
  }
  push(rule);
  push(llmItems || []);
  return out;
}

/**
 * 회의록 저장 직후: 규칙(액션·체크리스트) + LLM JSON 추출 병합 → 스냅샷 저장 → 업무 체크리스트 행 삽입
 */
async function syncAutoChecklistFromMeetingMinutes(openai, { pgPool, email, meetingId, generatedMinutes, uiModel }) {
  if ((process.env.MEETING_AUTO_CHECKLIST || "1").trim() === "0") {
    return { imported: 0, disabled: true };
  }
  const maxChars = Number(process.env.MEETING_CHECKLIST_EXTRACT_MAX_CHARS || 24000);
  let slice = String(generatedMinutes || "").trim();
  if (!slice) return { imported: 0 };
  if (slice.length > maxChars) slice = slice.slice(slice.length - maxChars);

  /** @type {Array<{ title: string, detail?: string, assignee?: string|null, due_note?: string|null, completed?: boolean }>} */
  let llmItems = [];
  let extractError = null;
  try {
    llmItems = await meetingMinutesLib.extractChecklistStructured(openai, {
      minutesMarkdown: slice,
      uiModel,
      resolveApiModel: resolveOpenAiApiModel,
    });
  } catch (e) {
    extractError = e?.response?.data?.error?.message || e?.message || "extract failed";
    console.warn("[meeting] checklist extract:", extractError);
  }

  const merged = mergeLlmAndRuleWorkItemsForSync(slice, llmItems || []);

  const snapshot = {
    items: merged,
    extractedAt: new Date().toISOString(),
    extractor: "rule-actions-checklist+openai-json",
    llmItemCount: (llmItems || []).length,
    mergedItemCount: merged.length,
    ...(extractError ? { extractError } : {}),
  };
  try {
    await meetingAiStore.updateMeetingChecklistSnapshot(pgPool, meetingId, email, snapshot);
  } catch (e) {
    console.warn("[meeting] checklist_snapshot update:", e?.message);
  }

  if (!merged.length) {
    return { imported: 0, snapshot, ...(extractError ? { extractError } : {}) };
  }

  try {
    const inserted = await taskChecklistStore.insertImportedBatch(pgPool, email, meetingId, merged);
    return { imported: inserted.length, snapshot, ...(extractError ? { extractError } : {}) };
  } catch (e) {
    console.warn("[meeting] checklist insert batch:", e?.message);
    return { imported: 0, snapshot, importError: e?.message, ...(extractError ? { extractError } : {}) };
  }
}

app.use("/public", express.static(path.join(ROOT_DIR, "public")));
/** 경영성과 대시보드 정적 자산 (`/mgmt-perf/*` — 뷰에서 이 경로로 참조) */
app.use("/mgmt-perf", express.static(path.join(ROOT_DIR, "public", "mgmt-perf")));
app.get("/favicon.ico", (req, res) => {
  res.type("image/x-icon");
  res.sendFile(path.join(ROOT_DIR, "public", "favicon.ico"));
});
/** 채팅 마크다운 뷰어(marked + DOMPurify) — node_modules에서만 제공 */
app.use("/vendor/marked", express.static(path.join(ROOT_DIR, "node_modules/marked/lib")));
app.use("/vendor/dompurify", express.static(path.join(ROOT_DIR, "node_modules/dompurify/dist")));
app.use("/uploads", express.static(UPLOAD_DIR));
app.get("/resources/ax-apply/AX_과제_신청서.docx", (req, res) => {
  if (!isAxApplyDownloadSubmitAllowed(req, res)) {
    return res
      .status(403)
      .type("text/plain; charset=utf-8")
      .send("로그인 후 이용 가능합니다.");
  }
  const fp = path.join(RESOURCES_AX_APPLY_DIR, "AX_과제_신청서.docx");
  if (!fsSync.existsSync(fp)) return res.status(404).send("Not found");
  res.download(fp, "AX_과제_신청서.docx");
});
app.use("/resources/ax-apply", express.static(RESOURCES_AX_APPLY_DIR));

app.use(opsAuth.middleware);
app.use((req, res, next) => {
  res.locals.dashboardMenuAllowed = computeDashboardMenuAllowed(req, res);
  res.locals.guideBotMenuAllowed = computeGuideBotMenuAllowed(req, res);
  res.locals.taskChecklistMenuAllowed = computeTaskChecklistMenuAllowed(req, res);
  next();
});
opsAuth.registerRoutes(app);

app.get("/api/mgmt-perf/status", requireDashboardAccess, async (req, res) => {
  try {
    const row = await mgmtPerf.getLatestPayloadRow(pgPool);
    const p = row.payload && typeof row.payload === "object" ? row.payload : {};
    const keys = Object.keys(p);
    res.json({
      ok: true,
      usingPostgres: !!pgPool,
      fiscalYear: row.fiscal_year,
      quarter: row.quarter,
      lastFilename: row.original_filename,
      lastImportedAt: row.created_at,
      payloadKeys: keys,
      uploadMeta: p._uploadMeta || null,
    });
  } catch (err) {
    res.status(500).json({ ok: false, error: err?.message || "조회 실패" });
  }
});

app.post(
  "/api/mgmt-perf/upload",
  requireDashboardAccess,
  uploadMgmtPerfExcel.single("file"),
  async (req, res) => {
  try {
    if (!req.file) return res.status(400).json({ error: "파일이 없습니다." });
    const fiscalYear = Math.min(2100, Math.max(2020, parseInt(req.body.fiscalYear, 10) || new Date().getFullYear()));
    const quarter = Math.min(4, Math.max(1, parseInt(req.body.quarter, 10) || 1));
    const email = res.locals.opsUserEmail ? String(res.locals.opsUserEmail).trim() : null;
    const defaultPayload = mgmtPerf.loadDefaultPayload();
    const buf = fsSync.readFileSync(req.file.path);
    const payload = mgmtPerf.buildPayloadFromWorkbook(buf, defaultPayload);
    await mgmtPerf.saveUploadAndSnapshot(pgPool, {
      userEmail: email,
      originalFilename: decodeUploadFilename(req.file.originalname),
      filePath: req.file.path,
      fiscalYear,
      quarter,
      payload,
    });
    res.json({ ok: true, message: "저장되었습니다. 대시보드가 갱신되었습니다." });
  } catch (err) {
    console.error("mgmt-perf upload:", err);
    res.status(500).json({ error: err.message || "처리 실패" });
  }
  }
);

app.delete("/api/mgmt-perf/upload/:id", requireDashboardAccess, async (req, res) => {
  try {
    const result = await mgmtPerf.deleteUpload(pgPool, req.params.id);
    if (!result.ok) {
      const code = result.error === "항목을 찾을 수 없습니다." ? 404 : 400;
      return res.status(code).json({ ok: false, error: result.error });
    }
    res.json({ ok: true, message: "삭제되었습니다." });
  } catch (err) {
    console.error("mgmt-perf delete:", err);
    res.status(500).json({ ok: false, error: err?.message || "삭제 실패" });
  }
});

function filePathToPublicUploadsUrl(absolutePath) {
  const rel = path.relative(UPLOAD_DIR, absolutePath).split(path.sep).join("/");
  if (rel.startsWith("..") || !rel) throw new Error("잘못된 업로드 경로");
  return `/uploads/${rel}`;
}

app.post(
  "/api/ai-use-case-submissions",
  (req, res, next) => {
    if (!getAiUseCaseSubmitterEmail(req, res)) {
      return res.status(403).json({ error: "이메일 로그인(OPS) 후 이용해 주세요." });
    }
    if (!pgPool) {
      return res.status(503).json({ error: "PostgreSQL 연결이 필요합니다." });
    }
    req._useCaseSubmissionId = uuidv4();
    next();
  },
  (req, res, next) => {
    uploadUseCaseFields(req, res, (err) => {
      if (err) {
        return res.status(400).json({ error: err.message || "파일 업로드에 실패했습니다." });
      }
      next();
    });
  },
  async (req, res) => {
    try {
      const { sanitizeUseCaseBody } = require("./lib/sanitize-use-case-body");
      const email = getAiUseCaseSubmitterEmail(req, res);
      const subId = req._useCaseSubmissionId;
      const title = String(req.body.title || "").trim();
      const situation = sanitizeUseCaseBody(String(req.body.situation || ""));
      const taskGoal = sanitizeUseCaseBody(String(req.body.taskGoal || ""));
      const actionTaken = sanitizeUseCaseBody(String(req.body.actionTaken || ""));
      const resultOutcome = sanitizeUseCaseBody(String(req.body.resultOutcome || ""));
      const tags = String(req.body.tags || "")
        .split(/[,#]/)
        .map((t) => t.trim())
        .filter(Boolean)
        .slice(0, 40);
      if (!title) {
        return res.status(400).json({ error: "제목을 입력해 주세요." });
      }
      const thumbs = (req.files && req.files["thumbnail"]) || [];
      if (!thumbs.length) {
        return res.status(400).json({ error: "썸네일 이미지를 1개 이상 업로드해 주세요. (최대 5개, 개당 5MB)" });
      }
      if (thumbs.length > aiUseCaseSubmissions.MAX_THUMB_COUNT) {
        return res.status(400).json({
          error: `썸네일은 최대 ${aiUseCaseSubmissions.MAX_THUMB_COUNT}개까지 업로드할 수 있습니다.`,
        });
      }
      const thumbMeta = [];
      for (let ti = 0; ti < thumbs.length; ti += 1) {
        const tpath = thumbs[ti].path;
        const tsize = fsSync.statSync(tpath).size;
        if (tsize > aiUseCaseSubmissions.MAX_THUMB_BYTES) {
          return res.status(400).json({ error: `썸네일 ${ti + 1}번째 파일은 5MB 이하여야 합니다.` });
        }
        thumbMeta.push({
          originalName: decodeUploadFilename(thumbs[ti].originalname),
          relativePath: filePathToPublicUploadsUrl(tpath),
          size: tsize,
        });
      }
      const atts = (req.files && req.files["attachments"]) || [];
      if (atts.length > aiUseCaseSubmissions.MAX_ATTACH_COUNT) {
        return res.status(400).json({
          error: `첨부 파일은 최대 ${aiUseCaseSubmissions.MAX_ATTACH_COUNT}개까지 업로드할 수 있습니다.`,
        });
      }
      for (let i = 0; i < atts.length; i += 1) {
        const sz = fsSync.statSync(atts[i].path).size;
        if (sz > aiUseCaseSubmissions.MAX_ATTACH_BYTES) {
          return res.status(400).json({ error: `첨부 파일은 개당 20MB 이하여야 합니다. (${i + 1}번째)` });
        }
      }
      const attachMeta = atts.map((f) => ({
        originalName: decodeUploadFilename(f.originalname),
        relativePath: filePathToPublicUploadsUrl(f.path),
        size: fsSync.statSync(f.path).size,
      }));
      await aiUseCaseSubmissions.insertSubmission(pgPool, {
        id: subId,
        submitterEmail: email,
        title,
        situation,
        taskGoal,
        actionTaken,
        resultOutcome,
        tags,
        thumbnailRelativePath: aiUseCaseSubmissions.primaryThumbnailPath(thumbMeta),
        thumbnailFiles: thumbMeta,
        attachmentFiles: attachMeta,
      });
      return res.json({ ok: true, id: subId, message: "제출이 저장되었습니다." });
    } catch (err) {
      if (err && err.code === "MODULE_NOT_FOUND" && String(err.message || "").includes("sanitize-html")) {
        return res.status(503).json({
          error: "서버에 npm 패키지(sanitize-html)가 없습니다. 프로젝트 루트에서 npm install 후 PM2를 재시작하세요.",
        });
      }
      console.error("[ai-use-case-submissions]", err);
      return res.status(500).json({ error: err.message || "저장에 실패했습니다." });
    }
  }
);

const UUID_RE_SUBMISSION = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

app.put(
  "/api/ai-use-case-submissions/:id",
  (req, res, next) => {
    if (!getAiUseCaseSubmitterEmail(req, res)) {
      return res.status(403).json({ error: "이메일 로그인(OPS) 후 이용해 주세요." });
    }
    if (!pgPool) {
      return res.status(503).json({ error: "PostgreSQL 연결이 필요합니다." });
    }
    const id = (req.params.id || "").trim();
    if (!UUID_RE_SUBMISSION.test(id)) {
      return res.status(400).json({ error: "잘못된 id입니다." });
    }
    req._useCaseSubmissionId = id;
    next();
  },
  (req, res, next) => {
    uploadUseCaseFields(req, res, (err) => {
      if (err) {
        return res.status(400).json({ error: err.message || "파일 업로드에 실패했습니다." });
      }
      next();
    });
  },
  async (req, res) => {
    try {
      const { sanitizeUseCaseBody } = require("./lib/sanitize-use-case-body");
      const id = (req.params.id || "").trim();
      const requestEmail = getAiUseCaseSubmitterEmail(req, res);
      const row = await aiUseCaseSubmissions.getSubmissionById(pgPool, id);
      if (!row) {
        return res.status(404).json({ error: "사례를 찾을 수 없습니다." });
      }
      if (
        !aiUseCaseSubmissions.canUserEditSubmission(
          row.submitter_email,
          requestEmail,
          !!res.locals.adminMode
        )
      ) {
        return res.status(403).json({ error: "이 사례를 수정할 권한이 없습니다." });
      }
      const title = String(req.body.title || "").trim();
      const situation = sanitizeUseCaseBody(String(req.body.situation || ""));
      const taskGoal = sanitizeUseCaseBody(String(req.body.taskGoal || ""));
      const actionTaken = sanitizeUseCaseBody(String(req.body.actionTaken || ""));
      const resultOutcome = sanitizeUseCaseBody(String(req.body.resultOutcome || ""));
      const tags = String(req.body.tags || "")
        .split(/[,#]/)
        .map((t) => t.trim())
        .filter(Boolean)
        .slice(0, 40);
      if (!title) {
        return res.status(400).json({ error: "제목을 입력해 주세요." });
      }
      const thumbs = (req.files && req.files["thumbnail"]) || [];
      const oldThumbs = aiUseCaseSubmissions.parseThumbnailFiles(row);
      let removeThumbPaths = [];
      try {
        const rawThumbR = req.body.removeThumbnailPaths;
        if (rawThumbR) {
          const parsed = typeof rawThumbR === "string" ? JSON.parse(rawThumbR) : rawThumbR;
          if (Array.isArray(parsed)) removeThumbPaths = parsed;
        }
      } catch {
        removeThumbPaths = [];
      }
      const removeThumbRaw = String(req.body.removeThumbnail || "").toLowerCase();
      const wantRemoveAllThumbs =
        removeThumbRaw === "1" || removeThumbRaw === "true" || removeThumbRaw === "on";
      if (wantRemoveAllThumbs && !removeThumbPaths.length) {
        removeThumbPaths = oldThumbs.map((t) => t.relativePath);
      }
      const removeThumbSet = new Set(
        removeThumbPaths.map((x) => String(x || "").trim()).filter(Boolean)
      );
      const oldThumbsFiltered = oldThumbs.filter(
        (t) => t && t.relativePath && !removeThumbSet.has(t.relativePath)
      );
      const newThumbMeta = [];
      for (let ti = 0; ti < thumbs.length; ti += 1) {
        const tpath = thumbs[ti].path;
        const tsize = fsSync.statSync(tpath).size;
        if (tsize > aiUseCaseSubmissions.MAX_THUMB_BYTES) {
          return res.status(400).json({ error: `썸네일 ${ti + 1}번째 파일은 5MB 이하여야 합니다.` });
        }
        newThumbMeta.push({
          originalName: decodeUploadFilename(thumbs[ti].originalname),
          relativePath: filePathToPublicUploadsUrl(tpath),
          size: tsize,
        });
      }
      const mergedThumbs = oldThumbsFiltered.concat(newThumbMeta).slice(0, aiUseCaseSubmissions.MAX_THUMB_COUNT);
      if (oldThumbsFiltered.length + newThumbMeta.length > aiUseCaseSubmissions.MAX_THUMB_COUNT) {
        return res.status(400).json({
          error: `썸네일은 최대 ${aiUseCaseSubmissions.MAX_THUMB_COUNT}개까지 등록할 수 있습니다.`,
        });
      }
      if (!mergedThumbs.length) {
        return res.status(400).json({ error: "썸네일은 1개 이상 필요합니다." });
      }
      const atts = (req.files && req.files["attachments"]) || [];
      if (atts.length > aiUseCaseSubmissions.MAX_ATTACH_COUNT) {
        return res.status(400).json({
          error: `첨부 파일은 최대 ${aiUseCaseSubmissions.MAX_ATTACH_COUNT}개까지 업로드할 수 있습니다.`,
        });
      }
      for (let i = 0; i < atts.length; i += 1) {
        const sz = fsSync.statSync(atts[i].path).size;
        if (sz > aiUseCaseSubmissions.MAX_ATTACH_BYTES) {
          return res.status(400).json({ error: `첨부 파일은 개당 20MB 이하여야 합니다. (${i + 1}번째)` });
        }
      }
      const newAttachMeta = atts.map((f) => ({
        originalName: decodeUploadFilename(f.originalname),
        relativePath: filePathToPublicUploadsUrl(f.path),
        size: fsSync.statSync(f.path).size,
      }));
      let oldFiles = [];
      try {
        const raw = row.attachment_files;
        oldFiles = typeof raw === "string" ? JSON.parse(raw) : raw || [];
        if (!Array.isArray(oldFiles)) oldFiles = [];
      } catch {
        oldFiles = [];
      }
      let removePaths = [];
      try {
        const rawR = req.body.removeAttachmentPaths;
        if (rawR) {
          const parsed = typeof rawR === "string" ? JSON.parse(rawR) : rawR;
          if (Array.isArray(parsed)) removePaths = parsed;
        }
      } catch {
        removePaths = [];
      }
      const removeSet = new Set(
        removePaths.map((x) => String(x || "").trim()).filter(Boolean)
      );
      const oldFiltered = oldFiles.filter((f) => f && f.relativePath && !removeSet.has(f.relativePath));
      const mergedFilesCandidate = oldFiltered.concat(newAttachMeta);
      if (mergedFilesCandidate.length > aiUseCaseSubmissions.MAX_ATTACH_COUNT) {
        return res.status(400).json({
          error: `첨부 파일은 최대 ${aiUseCaseSubmissions.MAX_ATTACH_COUNT}개까지 등록할 수 있습니다. 기존 첨부를 제거한 뒤 추가해 주세요.`,
        });
      }
      const mergedFiles = mergedFilesCandidate;
      await aiUseCaseSubmissions.updateSubmission(pgPool, {
        id,
        title,
        situation,
        taskGoal,
        actionTaken,
        resultOutcome,
        tags,
        thumbnailFilesJson: JSON.stringify(mergedThumbs),
        attachmentFilesJson: JSON.stringify(mergedFiles),
      });
      return res.json({ ok: true, id, message: "수정이 저장되었습니다." });
    } catch (err) {
      if (err && err.code === "MODULE_NOT_FOUND" && String(err.message || "").includes("sanitize-html")) {
        return res.status(503).json({
          error: "서버에 npm 패키지(sanitize-html)가 없습니다. 프로젝트 루트에서 npm install 후 PM2를 재시작하세요.",
        });
      }
      console.error("[ai-use-case-submissions PUT]", err);
      return res.status(500).json({ error: err.message || "저장에 실패했습니다." });
    }
  }
);

app.post(
  "/api/ai-use-case-submissions/:id/like/toggle",
  requirePromptLibEmail,
  express.json(),
  async (req, res) => {
    if (!pgPool) {
      return res.status(503).json({ error: "DB를 사용할 수 없습니다." });
    }
    try {
      const id = String(req.params.id || "").trim();
      const out = await aiUseCaseSubmissions.toggleSubmissionLike(pgPool, id, req.promptLibEmail);
      return res.json({ ok: true, ...out });
    } catch (err) {
      if (err && err.code === "VALIDATION") {
        return res.status(400).json({ error: err.message || "잘못된 요청입니다." });
      }
      if (err && err.code === "NOT_FOUND") {
        return res.status(404).json({ error: err.message || "사례를 찾을 수 없습니다." });
      }
      console.error("[ai-use-case-submissions/like/toggle]", err);
      return res.status(500).json({ error: err?.message || "처리에 실패했습니다." });
    }
  }
);

const pageRouter = express.Router();
pageRouter.get("/guide-bot", requireGuideBotMenuAccess, (req, res) => res.redirect(GUIDE_BOT_URL));
pageRouter.get("/chat", requireGuideBotMenuAccess, (req, res) => res.redirect(GUIDE_BOT_URL));
pageRouter.get("/wm", requireGuideBotMenuAccess, (req, res) => res.redirect(COMPANY_WM_URL));
pageRouter.get("/ai-explore/chat", (req, res) =>
  res.render("chat", {
    activeMenu: "ai-explore",
    chatGptAllowed: isChatGptAllowed(req, res),
    opsState: normalizeOpsState(),
    adminMode: res.locals.adminMode,
    opsUserEmail: !!res.locals.opsUserEmail,
  })
);
pageRouter.get("/ai-explore", (req, res) =>
  res.render("ai-explore", {
    activeMenu: "ai-explore",
    adminMode: res.locals.adminMode,
    opsUserEmail: !!res.locals.opsUserEmail,
    aiExploreDevGuestRestricted: isAiExploreDevGuestRestricted(req, res),
  })
);
pageRouter.get("/ai-explore/prompts", async (req, res, next) => {
  try {
    const officialPrompts = loadCompanyPrompts();
    const uem = getAiUseCaseSubmitterEmail(req, res);
    const libData = await promptLibrary.getLibraryData(pgPool, officialPrompts, uem);
    res.render("ai-prompts", {
      activeMenu: "prompts",
      adminMode: res.locals.adminMode,
      opsUserEmail: !!res.locals.opsUserEmail,
      userEmail: uem || "",
      officialPrompts,
      libData,
      openaiPolishAvailable: OPENAI_API_KEY.length > 0,
    });
  } catch (e) {
    next(e);
  }
});
pageRouter.get("/ai-explore/meeting-minutes", (req, res) =>
  res.render("meeting-minutes", {
    activeMenu: "ai-explore",
    adminMode: res.locals.adminMode,
    meetingUserEmail: getMeetingMinutesUserEmail(req, res) || "",
    opsState: normalizeOpsState(),
  })
);
pageRouter.get("/ai-explore/task-checklist", requireTaskChecklistAccess, (req, res) =>
  res.render("task-checklist", {
    activeMenu: "ai-explore",
    adminMode: res.locals.adminMode,
    meetingUserEmail: getMeetingMinutesUserEmail(req, res) || "",
    opsState: normalizeOpsState(),
  })
);
pageRouter.get("/ai-explore/fscan", (req, res) =>
  res.render("ai-fscan", {
    activeMenu: "ai-explore",
    adminMode: res.locals.adminMode,
    opsUserEmail: !!res.locals.opsUserEmail,
  })
);
pageRouter.get("/dashboard", requireDashboardAccess, (req, res) =>
  res.render("dashboard", {
    activeMenu: "dashboard",
    adminMode: res.locals.adminMode,
    opsUserEmail: !!res.locals.opsUserEmail,
  })
);
function mgmtPerfBuildYearOptions(periods, viewYear) {
  const set = new Set();
  (periods || []).forEach((p) => {
    if (p.fiscal_year != null) set.add(Number(p.fiscal_year));
  });
  const y = Number(viewYear) || new Date().getFullYear();
  set.add(y);
  const cy = new Date().getFullYear();
  for (let i = cy - 3; i <= cy + 2; i++) set.add(i);
  return Array.from(set)
    .filter((n) => n >= 2020 && n <= 2100)
    .sort((a, b) => b - a);
}

pageRouter.get("/dashboard/business-performance", requireDashboardAccess, async (req, res, next) => {
  try {
    const qy = parseInt(req.query.year, 10);
    const qq = parseInt(req.query.quarter, 10);
    const hasPeriodQuery =
      Number.isFinite(qy) && qy >= 2020 && qy <= 2100 && Number.isFinite(qq) && qq >= 1 && qq <= 4;

    const periods = await mgmtPerf.listDistinctPeriods(pgPool);
    let row;
    let viewYear;
    let viewQuarter;

    if (hasPeriodQuery) {
      viewYear = qy;
      viewQuarter = qq;
      row = await mgmtPerf.getPayloadRowForPeriod(pgPool, viewYear, viewQuarter);
    } else {
      const latest = await mgmtPerf.getLatestPayloadRow(pgPool);
      viewYear = latest.fiscal_year || new Date().getFullYear();
      viewQuarter = latest.quarter || 1;
      row = { ...latest, _noSnapshotForPeriod: false };
    }

    const uploadHistory = await mgmtPerf.listUploads(pgPool, 12);
    const y = row.fiscal_year != null ? row.fiscal_year : viewYear;
    const q = row.quarter != null ? row.quarter : viewQuarter;
    const payloadJson = JSON.stringify(row.payload).replace(/</g, "\\u003c");
    const dashboardTitle = `${y} Q${q} 경영성과 대시보드`;
    const quarterLabel = `Q${q}`;
    const yearOptions = mgmtPerfBuildYearOptions(periods, viewYear);
    res.render("dashboard-business-performance", {
      activeMenu: "dashboard",
      adminMode: res.locals.adminMode,
      opsUserEmail: !!res.locals.opsUserEmail,
      defaultYear: viewYear,
      selectedQuarter: viewQuarter,
      viewYear,
      viewQuarter,
      yearOptions,
      noSnapshotForPeriod: !!row._noSnapshotForPeriod,
      uploadHistory,
      payloadJson,
      dashboardTitle,
      quarterLabel,
    });
  } catch (err) {
    next(err);
  }
});
pageRouter.get("/dashboard/business-performance/embed", requireDashboardAccess, async (req, res, next) => {
  try {
    const qy = parseInt(req.query.year, 10);
    const qq = parseInt(req.query.quarter, 10);
    const hasPeriodQuery =
      Number.isFinite(qy) && qy >= 2020 && qy <= 2100 && Number.isFinite(qq) && qq >= 1 && qq <= 4;

    let row;
    if (hasPeriodQuery) {
      row = await mgmtPerf.getPayloadRowForPeriod(pgPool, qy, qq);
    } else {
      row = await mgmtPerf.getLatestPayloadRow(pgPool);
    }
    const fy = row.fiscal_year || new Date().getFullYear();
    const q = row.quarter || 1;
    const payloadJson = JSON.stringify(row.payload).replace(/</g, "\\u003c");
    const dashboardTitle = `${fy} Q${q} 경영성과 대시보드`;
    const quarterLabel = `Q${q}`;
    res.render("mgmt_perf_embed", {
      dashboardTitle,
      quarterLabel,
      payloadJson,
    });
  } catch (err) {
    next(err);
  }
});
const AI_SUCCESS_ADMIN_LIST_PAGE_SIZE = 5;

pageRouter.get("/ai-cases/write", (req, res) => {
  if (!res.locals.adminMode) {
    return res.status(403).send(
      "<!doctype html><html lang=\"ko\"><head><meta charset=\"utf-8\"/><link rel=\"icon\" href=\"/favicon.ico\" type=\"image/x-icon\"/><title>권한 없음</title></head><body><p>관리자 모드가 필요합니다. 좌측 하단 <strong>관리자</strong>에서 토큰을 입력한 뒤 다시 시도하세요.</p><p><a href=\"/ai-cases\">AI 활용 사례 목록으로</a></p></body></html>"
    );
  }
  const editSlug = (req.query.edit || "").trim();
  const meta = loadAiSuccessStoriesMeta();
  let story = null;
  if (editSlug) {
    const m = meta.find((x) => x.slug === editSlug);
    if (m) story = enrichAiSuccessStory(m);
  }
  const sortedStories = [...meta].sort((a, b) => {
    const da = new Date(a.publishedAt || a.updatedAt || a.createdAt || 0);
    const db = new Date(b.publishedAt || b.updatedAt || b.createdAt || 0);
    return db - da;
  });
  const pageRaw = req.query.page;
  const pageNum = Math.max(1, parseInt(Array.isArray(pageRaw) ? pageRaw[0] : pageRaw, 10) || 1);
  const totalCount = sortedStories.length;
  const totalPages = Math.max(1, Math.ceil(totalCount / AI_SUCCESS_ADMIN_LIST_PAGE_SIZE));
  const currentPage = Math.min(pageNum, totalPages);
  const start = (currentPage - 1) * AI_SUCCESS_ADMIN_LIST_PAGE_SIZE;
  const allStories = sortedStories.slice(start, start + AI_SUCCESS_ADMIN_LIST_PAGE_SIZE);
  const listPagination = {
    page: currentPage,
    totalPages,
    totalCount,
    hasPrev: currentPage > 1,
    hasNext: currentPage < totalPages,
  };
  res.render("ai-cases-write", {
    activeMenu: "ai-cases",
    adminMode: true,
    story,
    allStories,
    listPagination,
    editSlug: editSlug || null,
  });
});

pageRouter.get("/ai-cases/compose", async (req, res, next) => {
  if (!getAiUseCaseSubmitterEmail(req, res)) {
    return res
      .status(403)
      .type("html")
      .send(
        "<!doctype html><html lang=\"ko\"><head><meta charset=\"utf-8\"/><link rel=\"icon\" href=\"/favicon.ico\" type=\"image/x-icon\"/><title>권한 없음</title><link rel=\"stylesheet\" href=\"/public/styles.css\" /></head><body><div class=\"app-shell\"><div class=\"content-area\" style=\"padding:24px;max-width:560px;\"><h1>글쓰기</h1><p>이메일 로그인(OPS) 후 이용해 주세요.</p><p><a href=\"/ai-cases\">AI 활용 사례 목록으로</a></p></div></div></body></html>"
      );
  }
  const ph = { ...aiUseCaseSubmissions.PLACEHOLDERS };
  const flatPh = {
    situation: (ph.situation || "").replace(/\s+/g, " ").trim(),
    task: (ph.task || "").replace(/\s+/g, " ").trim(),
    action: (ph.action || "").replace(/\s+/g, " ").trim(),
    result: (ph.result || "").replace(/\s+/g, " ").trim(),
  };
  const editParam = (req.query.edit || "").toString().trim();
  const uuidRe = UUID_RE_SUBMISSION;
  let editPayload = null;
  if (editParam && pgPool) {
    if (!uuidRe.test(editParam)) {
      return res.status(400).type("html").send("<!doctype html><html lang=\"ko\"><head><meta charset=\"utf-8\"/><link rel=\"stylesheet\" href=\"/public/styles.css\" /></head><body><div class=\"app-shell\" style=\"padding:24px;\"><h1>잘못된 id</h1><p><a href=\"/ai-cases\">목록</a></p></div></body></html>");
    }
    try {
      const srow = await aiUseCaseSubmissions.getSubmissionById(pgPool, editParam);
      if (!srow) {
        return res.status(404).type("html").send("<!doctype html><html lang=\"ko\"><head><meta charset=\"utf-8\"/><link rel=\"stylesheet\" href=\"/public/styles.css\" /></head><body><div class=\"app-shell\" style=\"padding:24px;\"><h1>사례를 찾을 수 없습니다</h1><p><a href=\"/ai-cases\">목록</a></p></div></body></html>");
      }
      const can = aiUseCaseSubmissions.canUserEditSubmission(
        srow.submitter_email,
        getAiUseCaseSubmitterEmail(req, res),
        !!res.locals.adminMode
      );
      if (!can) {
        return res
          .status(403)
          .type("html")
          .send(
            "<!doctype html><html lang=\"ko\"><head><meta charset=\"utf-8\"/><link rel=\"stylesheet\" href=\"/public/styles.css\" /></head><body><div class=\"app-shell\" style=\"padding:24px;\"><h1>수정 권한이 없습니다</h1><p>본인이 제출한 사례이거나 관리자만 편집할 수 있습니다.</p><p><a href=\"/ai-cases\">목록</a></p></div></body></html>"
          );
      }
      let attExisting = [];
      try {
        const rawA = srow.attachment_files;
        const p = typeof rawA === "string" ? JSON.parse(rawA) : rawA;
        if (Array.isArray(p)) attExisting = p;
      } catch {
        attExisting = [];
      }
      editPayload = {
        id: String(srow.id),
        title: (srow.title || "").trim(),
        html: aiUseCaseSubmissions.buildComposeEditorHtml(
          srow.situation,
          srow.task_goal,
          srow.action_taken,
          srow.result_outcome
        ),
        tags: (srow.tags || []).join(", "),
        existingThumbnails: aiUseCaseSubmissions.parseThumbnailFiles(srow).map((t) => ({
          originalName: String(t.originalName || "").trim() || "thumbnail",
          relativePath: String(t.relativePath).trim(),
        })),
        existingAttachments: attExisting
          .filter((a) => a && String(a.relativePath || "").trim())
          .map((a) => ({
            originalName: String(a.originalName || "").trim() || "파일",
            relativePath: String(a.relativePath).trim(),
          })),
      };
    } catch (e) {
      return next(e);
    }
  } else if (editParam && !pgPool) {
    return res.status(503).type("html").send("<!doctype html><html lang=\"ko\"><head><meta charset=\"utf-8\"/><link rel=\"stylesheet\" href=\"/public/styles.css\" /></head><body><p>DB를 사용할 수 없습니다.</p><p><a href=\"/ai-cases\">목록</a></p></body></html>");
  }
  res.render("ai-cases-compose", {
    activeMenu: "ai-cases",
    adminMode: res.locals.adminMode,
    submitterEmail: getAiUseCaseSubmitterEmail(req, res),
    placeholders: flatPh,
    maxBodyTotal: aiUseCaseSubmissions.MAX_BODY_TOTAL,
    maxThumbCount: aiUseCaseSubmissions.MAX_THUMB_COUNT,
    maxAttachCount: aiUseCaseSubmissions.MAX_ATTACH_COUNT,
    editPayload: editPayload || null,
  });
});

pageRouter.get("/ai-cases", async (req, res, next) => {
  try {
    const q = (req.query.q || "").trim();
    const tag = (req.query.tag || "").trim();
    const submittedOk = (req.query.submitted || "").toString() === "1";
    const meta = loadAiSuccessStoriesMeta();
    const jsonList = meta.map((m) => {
      const d = new Date(m.publishedAt || m.updatedAt || m.createdAt || 0);
      return {
        ...m,
        _source: "json",
        _dateSort: Number.isNaN(d.getTime()) ? new Date(0) : d,
      };
    });
    let subCards = [];
    if (pgPool) {
      try {
        subCards = await aiUseCaseSubmissions.listForPublicList(pgPool, 500);
      } catch (e) {
        console.warn("[ai-cases] 제출 사례 목록 로드 실패:", e?.message || e);
      }
    }
    const merged = [...subCards, ...jsonList];
    const tags = allTagsFromUnifiedAiCases(merged);
    const filtered = filterUnifiedAiCases(merged, q, tag);
    /** JSON 사례만: 카드 썸네일용 PDF→슬라이드(상세와 동일) */
    await Promise.all(
      filtered
        .filter((m) => m._source === "json")
        .slice(0, 24)
        .map(async (m) => {
          const pdfUrl = (m.pdfUrl || "").trim();
          if (!pdfUrl) return;
          if (getAiSuccessSlideImageUrls(m.slug).length > 0) return;
          try {
            await ensureAiSuccessStorySlides(m.slug, pdfUrl);
          } catch (err) {
            console.warn("[ai-cases] 슬라이드 워밍 실패:", m.slug, err?.message || err);
          }
        })
    );
    const stories = filtered.map((s) => {
      if (s._source === "submission") {
        return { ...s, coverImageUrl: (s.coverImageUrl || "").trim() };
      }
      const slideUrls = getAiSuccessSlideImageUrls(s.slug);
      const coverImageUrl = slideUrls.length > 0 ? slideUrls[0] : "";
      return { ...s, coverImageUrl };
    });
    res.render("ai-cases", {
      activeMenu: "ai-cases",
      adminMode: res.locals.adminMode,
      opsUserEmail: !!res.locals.opsUserEmail,
      canComposeUseCase: !!getAiUseCaseSubmitterEmail(req, res),
      successStoryDetailAllowed: isAiSuccessStoryDetailAllowed(req, res),
      submittedOk,
      stories,
      filters: { q, tag },
      availableTags: tags,
    });
  } catch (err) {
    next(err);
  }
});

pageRouter.get("/ai-cases/submit/:id", async (req, res, next) => {
  try {
    if (!isAiSuccessStoryDetailAllowed(req, res)) {
      return res.status(403).send(
        "<!doctype html><html lang=\"ko\"><head><meta charset=\"utf-8\"/><link rel=\"icon\" href=\"/favicon.ico\" type=\"image/x-icon\"/><title>상세 열람 불가</title><link rel=\"stylesheet\" href=\"/public/styles.css\" /></head><body><div class=\"app-shell\"><div class=\"content-area\" style=\"padding:24px;max-width:560px;\"><h1>상세 열람 불가</h1><p>로그인 후 이용 가능합니다.</p><p><a href=\"/ai-cases\">AI 활용 사례 목록으로</a></p></div></div></body></html>"
      );
    }
    if (!pgPool) {
      return res.status(503).send("서버 DB를 사용할 수 없습니다.");
    }
    const id = (req.params.id || "").trim();
    const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
    if (!uuidRe.test(id)) {
      return res.status(404).send("사례를 찾을 수 없습니다.");
    }
    const { sanitizeUseCaseBody } = require("./lib/sanitize-use-case-body");
    const row = await aiUseCaseSubmissions.getSubmissionById(pgPool, id);
    if (!row) {
      return res.status(404).send("사례를 찾을 수 없습니다.");
    }
    const email = (row.submitter_email || "").trim();
    const author = email && email.indexOf("@") > 0 ? email.split("@")[0] : email || "제출자";
    const created = row.created_at ? new Date(row.created_at) : null;
    const publishedAt = created && !Number.isNaN(created.getTime()) ? created.toISOString().slice(0, 10) : "";
    const attach = (() => {
      try {
        return typeof row.attachment_files === "string"
          ? JSON.parse(row.attachment_files)
          : row.attachment_files || [];
      } catch {
        return [];
      }
    })();
    const requestEm = getAiUseCaseSubmitterEmail(req, res);
    const canEditSubmission = aiUseCaseSubmissions.canUserEditSubmission(
      email,
      requestEm,
      !!res.locals.adminMode
    );
    const viewCount = await aiUseCaseSubmissions.recordViewFromOther(
      pgPool,
      id,
      requestEm,
      email
    );
    const engagement = await aiUseCaseSubmissions.getSubmissionEngagement(pgPool, id, requestEm);
    const thumbFiles = aiUseCaseSubmissions.parseThumbnailFiles(row);
    const updatedOk = (req.query.updated || "").toString() === "1";
    res.render("ai-use-case-submission-detail", {
      activeMenu: "ai-cases",
      adminMode: res.locals.adminMode,
      opsUserEmail: !!res.locals.opsUserEmail,
      submissionId: id,
      title: (row.title || "").trim() || "제목 없음",
      submitterEmail: email,
      author,
      department: aiUseCaseSubmissions.SUBMISSION_DEPT,
      publishedAt,
      tags: Array.isArray(row.tags) ? row.tags : [],
      coverImageUrl: aiUseCaseSubmissions.primaryThumbnailPath(thumbFiles) || "",
      thumbnailImages: thumbFiles,
      situationHtml: sanitizeUseCaseBody(String(row.situation || "")),
      taskHtml: sanitizeUseCaseBody(String(row.task_goal || "")),
      actionHtml: sanitizeUseCaseBody(String(row.action_taken || "")),
      resultHtml: sanitizeUseCaseBody(String(row.result_outcome || "")),
      attachments: Array.isArray(attach) ? attach : [],
      canEditSubmission,
      updatedOk,
      viewCount: typeof viewCount === "number" ? viewCount : engagement.viewCount,
      likeCount: engagement.likeCount,
      myLike: engagement.myLike,
    });
  } catch (err) {
    next(err);
  }
});

pageRouter.get("/ai-cases/:slug", async (req, res, next) => {
  try {
    if (!isAiSuccessStoryDetailAllowed(req, res)) {
      return res.status(403).send(
        "<!doctype html><html lang=\"ko\"><head><meta charset=\"utf-8\"/><link rel=\"icon\" href=\"/favicon.ico\" type=\"image/x-icon\"/><title>상세 열람 불가</title><link rel=\"stylesheet\" href=\"/public/styles.css\" /></head><body><div class=\"app-shell\"><div class=\"content-area\" style=\"padding:24px;max-width:560px;\"><h1>상세 열람 불가</h1><p>로그인 후 이용 가능합니다.</p><p><a href=\"/ai-cases\">AI 활용 사례 목록으로</a></p></div></div></body></html>"
      );
    }
    const slug = (req.params.slug || "").trim();
    const meta = loadAiSuccessStoriesMeta();
    const m = meta.find((x) => x.slug === slug);
    if (!m) return res.status(404).send("사례를 찾을 수 없습니다.");
    const story = enrichAiSuccessStory(m);
    const pdfUrl = (story.pdfUrl || "").trim();
    let slideImageUrls = [];
    let slides = [];
    if (pdfUrl) {
      const out = await ensureAiSuccessStorySlides(slug, pdfUrl);
      slideImageUrls = out.urls || [];
      slides = slideImageUrls.map(() => ({ title: "", lines: [] }));
    }
    res.render("ai-case-detail", {
      activeMenu: "ai-cases",
      adminMode: res.locals.adminMode,
      story,
      slideImageUrls,
      slides,
    });
  } catch (err) {
    next(err);
  }
});
pageRouter.get("/ax-apply", async (req, res) => {
  let assignments = [];
  if (res.locals.adminMode) {
    assignments = await readAxAssignmentsDb();
  } else {
    const all = await readAxAssignmentsDb();
    const sample = all.find(isSampleRecord);
    if (sample) assignments = [sample];
  }
  res.render("ax-apply", {
    activeMenu: "ax-apply",
    assignments,
    adminMode: res.locals.adminMode,
    axApplyDownloadSubmitAllowed: isAxApplyDownloadSubmitAllowed(req, res),
  });
});

app.get("/api/ax-apply-list", async (req, res) => {
  const department = (req.query.department || "").trim();
  const name = (req.query.name || "").trim();
  const email = (req.query.email || "").trim();
  if (res.locals.adminMode) {
    const list = department && name && email
      ? await readAxAssignmentsByCredentials(department, name, email)
      : await readAxAssignmentsDb();
    return res.json(list);
  }
  if (!department || !name || !email) {
    const allEmpty = await readAxAssignmentsDb();
    const sample = allEmpty.find(isSampleRecord);
    return res.json(sample ? [sample] : []);
  }
  const list = await readAxAssignmentsByCredentials(department, name, email);
  const all = await readAxAssignmentsDb();
  const sample = all.find(isSampleRecord);
  if (sample && !list.some((m) => m.id === sample.id)) {
    list.push(sample);
  }
  res.json(list);
});

app.get("/api/ax-apply/:id", async (req, res) => {
  const id = (req.params.id || "").trim();
  if (!id) return res.status(404).json({ error: "잘못된 요청입니다." });
  const department = (req.query.department || "").trim();
  const name = (req.query.name || "").trim();
  const email = (req.query.email || "").trim();
  const one = await readAxAssignmentById(id);
  if (!one) return res.status(404).json({ error: "해당 신청을 찾을 수 없습니다." });
  if (!res.locals.adminMode) {
    if (isSampleRecord(one)) {
      return res.json(one);
    }
    if (!department || !name || !email) return res.status(403).json({ error: "소속 부서, 이름, 이메일을 입력해 조회한 후 수정할 수 있습니다." });
    const matches = await readAxAssignmentsByCredentials(department, name, email);
    if (!matches.some((m) => m.id === id)) return res.status(403).json({ error: "본인의 신청만 수정할 수 있습니다." });
  }
  res.json(one);
});

app.put("/api/ax-apply/:id", (req, res, next) => {
  uploadAxApply.single("applicationFile")(req, res, (err) => {
    if (err) return res.status(400).json({ error: err.message || "파일 업로드 실패" });
    next();
  });
}, async (req, res) => {
  try {
    if (!isAxApplyDownloadSubmitAllowed(req, res)) {
      return res.status(403).json({ error: "로그인 후 이용 가능합니다." });
    }
    const id = (req.params.id || "").trim();
    if (!id) return res.status(404).json({ error: "잘못된 요청입니다." });
    const body = { ...req.body };
    const department = (body.department || "").trim();
    const name = (body.name || "").trim();
    const email = (body.email || "").trim();
    if (!res.locals.adminMode) {
      const existing = await readAxAssignmentById(id);
      if (existing && isSampleRecord(existing)) return res.status(403).json({ error: "샘플 데이터는 참고용으로 수정할 수 없습니다." });
      if (!department || !name || !email) return res.status(403).json({ error: "소속 부서, 이름, 이메일을 모두 입력해 주세요." });
      const matches = await readAxAssignmentsByCredentials(department, name, email);
      if (!matches.some((m) => m.id === id)) return res.status(403).json({ error: "본인의 신청만 수정할 수 있습니다." });
    }
    if (body.techStackStr && typeof body.techStackStr === "string") {
      body.techStack = body.techStackStr.split(",").map((s) => s.trim()).filter(Boolean);
    }
    if (body.risksStr && typeof body.risksStr === "string") {
      body.risks = body.risksStr.split(",").map((s) => s.trim()).filter(Boolean);
    }
    for (const k of ["dataTypes", "data_types", "techStackStr", "tech_stack", "risksStr", "qualitativeEffects", "qualitative_effects"]) {
      if (body[k] === "" || (typeof body[k] === "string" && body[k] != null)) delete body[k];
    }
    if (body.techStack && !Array.isArray(body.techStack)) delete body.techStack;
    if (body.risks && !Array.isArray(body.risks)) delete body.risks;
    if (!email) return res.status(400).json({ error: "이메일을 입력해 주세요." });
    if (req.file) body.applicationFile = `/uploads/ax-apply/${req.file.filename}`;
    body.participationPledge = body.participationPledge === "1" || body.participationPledge === true;
    const row = await updateAxAssignmentDb(id, body);
    if (!row) return res.status(404).json({ error: "해당 신청을 찾을 수 없습니다." });
    res.json({ ok: true, id: row.id });
  } catch (err) {
    const msg = err?.message || "저장 실패";
    if (err instanceof ReferenceError && /updateAxAssignmentDb/.test(String(msg))) {
      return res.status(500).json({ error: "서버를 재시작한 후 다시 시도해 주세요. (코드 반영이 필요할 수 있습니다.)" });
    }
    res.status(500).json({ error: msg });
  }
});

app.delete("/api/ax-apply/:id", async (req, res) => {
  if (!res.locals.adminMode) return res.status(403).json({ error: "관리자만 삭제할 수 있습니다." });
  try {
    const id = (req.params.id || "").trim();
    if (!id) return res.status(404).json({ error: "잘못된 요청입니다." });
    const ok = await deleteAxAssignmentDb(id);
    if (!ok) return res.status(404).json({ error: "해당 신청을 찾을 수 없습니다." });
    res.json({ ok: true });
  } catch (err) {
    res.status(500).json({ error: err?.message || "삭제 실패" });
  }
});

app.post("/api/ax-apply", (req, res, next) => {
  uploadAxApply.single("applicationFile")(req, res, (err) => {
    if (err) return res.status(400).json({ error: err.message || "파일 업로드 실패" });
    next();
  });
}, async (req, res) => {
  try {
    if (!isAxApplyDownloadSubmitAllowed(req, res)) {
      return res.status(403).json({ error: "로그인 후 이용 가능합니다." });
    }
    const body = { ...req.body };
    if (body.techStackStr && typeof body.techStackStr === "string") {
      body.techStack = body.techStackStr.split(",").map((s) => s.trim()).filter(Boolean);
    }
    if (body.risksStr && typeof body.risksStr === "string") {
      body.risks = body.risksStr.split(",").map((s) => s.trim()).filter(Boolean);
    }
    for (const k of ["dataTypes", "data_types", "techStackStr", "tech_stack", "risksStr", "qualitativeEffects", "qualitative_effects"]) {
      if (body[k] === "" || (typeof body[k] === "string" && body[k] != null)) delete body[k];
    }
    if (body.techStack && !Array.isArray(body.techStack)) delete body.techStack;
    if (body.risks && !Array.isArray(body.risks)) delete body.risks;
    if (!req.file) {
      return res.status(400).json({ error: "작성 완료 신청서(.docx) 파일을 업로드해 주세요." });
    }
    const email = (body.email || "").trim();
    if (!email) {
      return res.status(400).json({ error: "이메일을 입력해 주세요." });
    }
    body.applicationFile = `/uploads/ax-apply/${req.file.filename}`;
    body.participationPledge = body.participationPledge === "1" || body.participationPledge === true;
    const row = await createAxAssignmentDb(body);
    res.json({ ok: true, id: row.id });
  } catch (err) {
    const msg = err?.message || "저장 실패";
    if (err instanceof ReferenceError && /updateAxAssignmentDb/.test(String(msg))) {
      return res.status(500).json({ error: "서버를 재시작한 후 다시 시도해 주세요. (코드 반영이 필요할 수 있습니다.)" });
    }
    res.status(500).json({ error: msg });
  }
});

const SYSTEM_PROMPT = [
  "당신은 NCue AI 플랫폼의 도우미입니다. 친절하고 정확하게 답변해주세요.",
  "",
  "웹 검색이나 제공된 검색 결과를 쓸 수 있으면 여러 출처를 종합해 답하세요.",
  "회사·조직의 주소·위치·소재지·오시는 길 등을 묻는 질문에는 본사(또는 등기·공시상 대표 주소)뿐 아니라, 검색·자료에 나오면 공장·연구소·지사·지역 거점 등 주요 부속 시설을 구분해 짧게 정리해 포함하세요. 정보가 본사만 확인되는 경우에도 그 한계를 짧게 밝히고, 추가 거점이 공개되어 있으면 빠짐없이 반영하세요.",
].join("\n");

const getProvider = (model) => {
  const m = (model || "").toString().toLowerCase();
  if (m.startsWith("claude-")) return "anthropic";
  if (m.startsWith("gemini-")) return "google";
  return "openai";
};

const GEMINI_MODEL_MAP = {
  "gemini-3-pro": "gemini-2.5-pro",
  "gemini-3-flash": "gemini-2.5-flash",
};

/** 채팅 UI와 동일하게 허용하는 모델만 처리 */
const CHAT_ALLOWED_MODELS = new Set(["gpt-5.4", "gpt-5-mini"]);

/** UI 선택값 → OpenAI Chat Completions API에 넘길 실제 모델 ID (gpt-5.4 등은 API에 없을 수 있어 기본은 gpt-4o 계열) */
function resolveOpenAiApiModel(uiModel) {
  const map = {
    "gpt-5.4": (process.env.OPENAI_MODEL_DEFAULT || "gpt-4o").trim(),
    "gpt-5-mini": (process.env.OPENAI_MODEL_MINI || "gpt-4o-mini").trim(),
  };
  return map[uiModel] || uiModel;
}

function buildResponsesInputFromChatMessages(normalizedMessages) {
  const input = [];
  for (const m of normalizedMessages) {
    const r = String(m.role || "user").toLowerCase();
    if (r !== "user" && r !== "assistant") continue;
    input.push({
      role: r === "assistant" ? "assistant" : "user",
      content: m.content,
    });
  }
  return input;
}

function buildOpenAiWebSearchTool() {
  const tool = { type: "web_search" };
  const country = (process.env.OPENAI_WEB_SEARCH_COUNTRY || "KR").trim();
  if (country) {
    tool.user_location = {
      type: "approximate",
      country,
    };
    const city = (process.env.OPENAI_WEB_SEARCH_CITY || "").trim();
    const region = (process.env.OPENAI_WEB_SEARCH_REGION || "").trim();
    const timezone = (process.env.OPENAI_WEB_SEARCH_TIMEZONE || "Asia/Seoul").trim();
    if (city) tool.user_location.city = city;
    if (region) tool.user_location.region = region;
    if (timezone) tool.user_location.timezone = timezone;
  }
  return tool;
}

function extractSourcesFromResponse(response) {
  const out = [];
  const seen = new Set();
  if (!response?.output) return out;
  for (const item of response.output) {
    if (item.type !== "message") continue;
    for (const part of item.content || []) {
      if (part.type !== "output_text" || !part.annotations) continue;
      for (const ann of part.annotations) {
        if (ann.type === "url_citation" && ann.url && !seen.has(ann.url)) {
          seen.add(ann.url);
          out.push({ url: ann.url, title: ann.title || ann.url });
        }
      }
    }
  }
  return out;
}

/** 회의록 본문 수동 저장 — PATCH/PUT/POST(프록시·구버전 호환) */
async function saveMeetingGeneratedMinutesApi(req, res) {
  try {
    await ensureMeetingUserAndDefaultPrompt(req.meetingUserEmail);
    const id = (req.params.id || "").trim();
    const b = req.body || {};
    const generatedMinutes = meetingMinutesLib.prepareMeetingMinutesForApi((b.generatedMinutes ?? "").toString());
    if (!id) return res.status(400).json({ error: "회의 ID가 필요합니다." });
    const prev = await meetingAiStore.getMeeting(pgPool, id, req.meetingUserEmail);
    if (!prev) return res.status(404).json({ error: "회의록을 찾을 수 없습니다." });
    const model = (prev.chat_model || "gpt-5-mini").toString().trim();
    const fields = { generatedMinutes };
    if (Object.prototype.hasOwnProperty.call(b, "transcriptText")) {
      fields.transcriptText = (b.transcriptText ?? "").toString();
    }
    if (Object.prototype.hasOwnProperty.call(b, "sourceText")) {
      fields.sourceText = (b.sourceText ?? "").toString();
    }
    const updated = await meetingAiStore.updateMeetingContent(pgPool, id, req.meetingUserEmail, fields);
    if (!updated) return res.status(404).json({ error: "저장할 수 없습니다." });
    let checklistSync = { imported: 0 };
    if (OPENAI_API_KEY) {
      try {
        const openai = new OpenAI({ apiKey: OPENAI_API_KEY });
        checklistSync = await syncAutoChecklistFromMeetingMinutes(openai, {
          pgPool,
          email: req.meetingUserEmail,
          meetingId: id,
          generatedMinutes,
          uiModel: model,
        });
      } catch (e) {
        checklistSync = { imported: 0, extractError: e?.message };
      }
    } else {
      checklistSync = { imported: 0, disabled: true };
    }
    const fresh = await meetingAiStore.getMeeting(pgPool, id, req.meetingUserEmail);
    const meetingOut = mapRowToMeeting(fresh);
    if (checklistSync.snapshot) meetingOut.checklistSnapshot = checklistSync.snapshot;
    res.json({ meeting: meetingOut, checklistSync });
  } catch (err) {
    res.status(500).json({ error: err?.message || "저장 실패" });
  }
}

const meetingMinutesSaveJson = express.json({ limit: "2mb" });

app.get("/api/meeting-minutes/prompt", requireMeetingMinutesEmail, async (req, res) => {
  try {
    await ensureMeetingUserAndDefaultPrompt(req.meetingUserEmail);
    const row = await meetingAiStore.getPromptRow(pgPool, req.meetingUserEmail);
    res.json({ prompt: mapRowToMeetingPrompt(row) });
  } catch (err) {
    res.status(500).json({ error: err?.message || "조회 실패" });
  }
});

app.put("/api/meeting-minutes/prompt", requireMeetingMinutesEmail, express.json(), async (req, res) => {
  try {
    await ensureMeetingUserAndDefaultPrompt(req.meetingUserEmail);
    const b = req.body || {};
    const includeTitleLine = b.includeTitleLine !== false;
    const includeAttendees = b.includeAttendees !== false;
    const includeSummary = b.includeSummary !== false;
    const includeActionItems = b.includeActionItems !== false;
    const includeChecklist = b.includeChecklist === true;
    const customInstructions = (b.customInstructions || "").toString().slice(0, 8000);
    const row = await meetingAiStore.upsertPrompt(pgPool, req.meetingUserEmail, {
      includeTitleLine,
      includeAttendees,
      includeSummary,
      includeActionItems,
      includeChecklist,
      customInstructions,
    });
    res.json({ prompt: mapRowToMeetingPrompt(row) });
  } catch (err) {
    res.status(500).json({ error: err?.message || "저장 실패" });
  }
});

app.get("/api/meeting-minutes/meetings", requireMeetingMinutesEmail, async (req, res) => {
  try {
    await ensureMeetingUserAndDefaultPrompt(req.meetingUserEmail);
    const rows = await meetingAiStore.listMeetings(pgPool, req.meetingUserEmail);
    res.json({ meetings: (rows || []).map(mapRowToMeeting) });
  } catch (err) {
    res.status(500).json({ error: err?.message || "목록 조회 실패" });
  }
});

app.get("/api/meeting-minutes/meetings/:id", requireMeetingMinutesEmail, async (req, res) => {
  try {
    const id = (req.params.id || "").trim();
    const row = await meetingAiStore.getMeeting(pgPool, id, req.meetingUserEmail);
    if (!row) return res.status(404).json({ error: "회의록을 찾을 수 없습니다." });
    res.json({ meeting: mapRowToMeeting(row) });
  } catch (err) {
    res.status(500).json({ error: err?.message || "조회 실패" });
  }
});

app.patch("/api/meeting-minutes/meetings/:id", requireMeetingMinutesEmail, meetingMinutesSaveJson, saveMeetingGeneratedMinutesApi);
app.put("/api/meeting-minutes/meetings/:id", requireMeetingMinutesEmail, meetingMinutesSaveJson, saveMeetingGeneratedMinutesApi);
app.post("/api/meeting-minutes/meetings/:id/save", requireMeetingMinutesEmail, meetingMinutesSaveJson, saveMeetingGeneratedMinutesApi);

app.delete("/api/meeting-minutes/meetings/:id", requireMeetingMinutesEmail, async (req, res) => {
  try {
    const id = (req.params.id || "").trim();
    const result = await meetingAiStore.deleteMeeting(pgPool, id, req.meetingUserEmail);
    if (!result.deleted) return res.status(404).json({ error: "삭제할 항목이 없습니다." });
    if (result.audio_file_path) {
      const abs = path.join(ROOT_DIR, result.audio_file_path.replace(/^\//, ""));
      const norm = path.normalize(abs);
      if (norm.startsWith(MEETING_MINUTES_UPLOAD_DIR) && fsSync.existsSync(norm)) {
        fsSync.unlinkSync(norm);
      }
    }
    res.json({ ok: true });
  } catch (err) {
    res.status(500).json({ error: err?.message || "삭제 실패" });
  }
});

app.post("/api/meeting-minutes/generate-text", requireMeetingMinutesEmail, express.json({ limit: "2mb" }), async (req, res) => {
  if (!OPENAI_API_KEY) return res.status(503).json({ error: "OPENAI_API_KEY가 설정되지 않았습니다." });
  try {
    await ensureMeetingUserAndDefaultPrompt(req.meetingUserEmail);
    const title = (req.body?.title || "").toString().trim().slice(0, 500);
    const sourceText = (req.body?.sourceText || "").toString().trim();
    const model = (req.body?.model || "gpt-5-mini").toString().trim();
    const mdParsed = parseMeetingDateIso(req.body?.meetingDate);
    if (mdParsed.error) return res.status(400).json({ error: mdParsed.error });
    if (!sourceText) return res.status(400).json({ error: "회의 내용을 입력해 주세요." });
    if (!MEETING_MINUTES_ALLOWED_MODELS.has(model)) {
      return res.status(400).json({ error: "지원 모델: gpt-5-mini, gpt-5.4" });
    }
    const pr = await meetingAiStore.getPromptRow(pgPool, req.meetingUserEmail);
    const promptRow = mapRowToMeetingPrompt(pr);
    const systemPrompt = meetingMinutesLib.buildMeetingMinutesSystemPrompt(promptRow);
    const openai = new OpenAI({ apiKey: OPENAI_API_KEY });
    const generated = await meetingMinutesLib.generateMeetingMinutes(openai, {
      systemPrompt,
      userContent: sourceText,
      uiModel: model,
      resolveApiModel: resolveOpenAiApiModel,
      omitMeetingChecklistSection: promptRow?.includeChecklist !== true,
    });
    const ins = await meetingAiStore.insertMeetingText(pgPool, {
      email: req.meetingUserEmail,
      title,
      sourceText,
      generated,
      model,
      meetingDate: mdParsed.value,
    });
    let checklistSync = { imported: 0 };
    try {
      checklistSync = await syncAutoChecklistFromMeetingMinutes(openai, {
        pgPool,
        email: req.meetingUserEmail,
        meetingId: ins.id,
        generatedMinutes: generated,
        uiModel: model,
      });
    } catch (e) {
      checklistSync = { imported: 0, extractError: e?.message };
    }
    const meetingOut = mapRowToMeeting(ins);
    if (checklistSync.snapshot) meetingOut.checklistSnapshot = checklistSync.snapshot;
    res.json({ meeting: meetingOut, checklistSync });
  } catch (err) {
    const msg = err?.response?.data?.error?.message || err?.message || "생성 실패";
    res.status(500).json({ error: msg });
  }
});



/** 음성: (1) prepare-audio = multipart 업로드 후 즉시 { jobId } JSON (2) stream-audio = GET SSE 실시간 진행 — 업로드+SSE 단일 연결(generate-audio)은 프록시/브라우저가 SSE 본문을 버퍼링하면 UI가 멈춘 것처럼 보일 수 있음 */
app.post(
  "/api/meeting-minutes/prepare-audio",
  requireMeetingMinutesEmail,
  assignMeetingAudioJobId,
  (req, res, next) => {
    uploadMeetingAudioPrepare.single("audio")(req, res, (err) => {
      if (err) {
        return res.status(400).json({ error: err.message || "파일 업로드 실패" });
      }
      next();
    });
  },
  async (req, res) => {
    try {
      if (!req.file || !req.file.path) {
        res.status(400).json({ error: "음성 파일이 없습니다." });
        return;
      }
      const jobId = req.meetingAudioJobId;
      if (!jobId) {
        res.status(500).json({ error: "jobId 할당 오류" });
        return;
      }
      let fileAbs;
      try {
        fileAbs = resolvedMeetingMinutesUploadAudioPath(req.file.path);
      } catch (pe) {
        res.status(500).json({ error: pe?.message || "경로 검증 실패" });
        try {
          if (req.file?.path && fsSync.existsSync(req.file.path)) fsSync.unlinkSync(req.file.path);
        } catch (_) {
          /* ignore */
        }
        return;
      }
      ensureMeetingAudioJobMetaDirSync();
      const mp = meetingAudioJobMetaPath(jobId);
      const meta = {
        email: req.meetingUserEmail,
        fileAbsPath: fileAbs,
        originalName: decodeUploadFilename(req.file.originalname || ""),
        title: (req.body?.title || "").toString().trim().slice(0, 500),
        model: (req.body?.model || "gpt-5-mini").toString().trim(),
        whisperModel: (
          req.body?.whisperModel || meetingMinutesLib.DEFAULT_TRANSCRIPTION_MODEL
        ).toString().trim(),
        meetingDateRaw: req.body?.meetingDate,
        createdAtMs: Date.now(),
      };
      const tmpMp = `${mp}.tmp`;
      await fs.writeFile(tmpMp, JSON.stringify(meta), "utf-8");
      await fs.rename(tmpMp, mp);
      res.json({ jobId });
    } catch (e) {
      try {
        if (req.file?.path && fsSync.existsSync(req.file.path)) fsSync.unlinkSync(req.file.path);
      } catch (_) {
        /* ignore */
      }
      res.status(500).json({ error: e?.message || "준비 저장 실패" });
    }
  }
);

app.get("/api/meeting-minutes/stream-audio/:jobId", requireMeetingMinutesEmail, async (req, res) => {
  const rawParam = req.params.jobId != null ? String(req.params.jobId).trim() : "";
  let unlinkMetaAfterRun = /** @type {string|null} */ (null);
  try {
    if (!isMeetingAudioJobId(rawParam)) {
      res.status(400).json({ error: "유효하지 않은 작업 번호입니다." });
      return;
    }
    const metaPath = meetingAudioJobMetaPath(rawParam);
    if (!fsSync.existsSync(metaPath)) {
      res.status(404).json({ error: "작업 정보를 찾을 수 없거나 만료되었습니다." });
      return;
    }
    let meta;
    try {
      meta = JSON.parse(fsSync.readFileSync(metaPath, "utf-8"));
    } catch (_parseErr) {
      try {
        fsSync.unlinkSync(metaPath);
      } catch (_) {
        /* ignore */
      }
      res.status(400).json({ error: "손상된 작업 정보입니다." });
      return;
    }
    if (!meta || typeof meta.email !== "string") {
      res.status(404).json({ error: "손상된 작업 정보입니다." });
      return;
    }
    if (meta.email !== req.meetingUserEmail) {
      res.status(403).json({ error: "이 작업에 접근할 권한이 없습니다." });
      return;
    }
    const created = typeof meta.createdAtMs === "number" ? meta.createdAtMs : 0;
    const age = Date.now() - created;
    if (age > MEETING_AUDIO_JOB_MAX_AGE_MS) {
      try {
        fsSync.unlinkSync(metaPath);
      } catch (_) {
        /* ignore */
      }
      try {
        if (meta.fileAbsPath && fsSync.existsSync(meta.fileAbsPath)) fsSync.unlinkSync(meta.fileAbsPath);
      } catch (_) {
        /* ignore */
      }
      res.status(410).json({ error: "작업이 너무 오래되어 만료되었습니다." });
      return;
    }
    let fileAbs;
    try {
      fileAbs = resolvedMeetingMinutesUploadAudioPath(meta.fileAbsPath);
    } catch (pathErr) {
      try {
        fsSync.unlinkSync(metaPath);
      } catch (_) {
        /* ignore */
      }
      res.status(500).json({ error: pathErr?.message || "파일 경로 오류" });
      return;
    }
    if (!fsSync.existsSync(fileAbs)) {
      try {
        fsSync.unlinkSync(metaPath);
      } catch (_) {
        /* ignore */
      }
      res.status(404).json({ error: "음성 파일이 없습니다." });
      return;
    }

    unlinkMetaAfterRun = metaPath;
    if (req.socket) req.socket.setNoDelay(true);
    writeMeetingMinutesAudioSsePreamble(res);
    await runMeetingMinutesGenerateAudioSsePipeline(res, {
      email: meta.email,
      fileAbsPath: fileAbs,
      originalName: (meta.originalName || "").toString().trim().slice(0, 500),
      title: (meta.title || "").toString().trim().slice(0, 500),
      model: (meta.model || "gpt-5-mini").toString().trim(),
      whisperModel: (meta.whisperModel || meetingMinutesLib.DEFAULT_TRANSCRIPTION_MODEL).toString().trim(),
      meetingDateRaw: meta.meetingDateRaw,
      unlinkOnFailure: false,
    });
  } catch (err) {
    const msg = err?.response?.data?.error?.message || err?.message || "생성 실패";
    if (!res.headersSent) {
      res.status(500).json({ error: msg });
    }
  } finally {
    if (unlinkMetaAfterRun && fsSync.existsSync(unlinkMetaAfterRun)) {
      try {
        fsSync.unlinkSync(unlinkMetaAfterRun);
      } catch (_) {
        /* ignore */
      }
    }
  }
});

app.post(
  "/api/meeting-minutes/generate-audio",
  requireMeetingMinutesEmail,
  (req, res, next) => {
    uploadMeetingAudio.single("audio")(req, res, (err) => {
      if (err) {
        res.setHeader("Content-Type", "text/event-stream");
        res.setHeader("Cache-Control", "no-cache");
        res.setHeader("X-Accel-Buffering", "no");
        res.flushHeaders();
        res.write(`event: error\ndata: ${JSON.stringify({ message: err.message || "파일 업로드 실패" })}\n\n`);
        return res.end();
      }
      next();
    });
  },
  async (req, res) => {
    if (req.socket) req.socket.setNoDelay(true);
    writeMeetingMinutesAudioSsePreamble(res);
    await runMeetingMinutesGenerateAudioSsePipeline(res, {
      email: req.meetingUserEmail,
      fileAbsPath: req.file && req.file.path,
      originalName: (req.file && req.file.originalname) || "",
      title: (req.body?.title || "").toString().trim().slice(0, 500),
      model: (req.body?.model || "gpt-5-mini").toString().trim(),
      whisperModel: (
        req.body?.whisperModel || meetingMinutesLib.DEFAULT_TRANSCRIPTION_MODEL
      ).toString().trim(),
      meetingDateRaw: req.body?.meetingDate,
      unlinkOnFailure: false,
    });
  }
);

app.get("/api/task-checklist/items", requireTaskChecklistAccess, requireMeetingMinutesEmail, async (req, res) => {
  try {
    await ensureMeetingUserAndDefaultPrompt(req.meetingUserEmail);
    const completed = req.query.completed;
    let cf = null;
    if (completed === "true") cf = true;
    else if (completed === "false") cf = false;
    const midRaw = (req.query.meetingId || "").trim();
    const meetingId = midRaw && midRaw !== "__all__" ? midRaw : null;
    const items = await taskChecklistStore.listItems(pgPool, req.meetingUserEmail, {
      completed: cf,
      meetingId: meetingId || undefined,
    });
    const mids = items.map((it) => (it.meetingId != null ? String(it.meetingId) : "")).filter(Boolean);
    const metaById = await meetingAiStore.getMeetingMetaForIds(pgPool, req.meetingUserEmail, mids);
    const enriched = items.map((it) => {
      const mid = it.meetingId != null ? String(it.meetingId) : "";
      const meta = mid && metaById.has(mid) ? metaById.get(mid) : null;
      return {
        ...it,
        meetingTitle: meta ? meta.meetingTitle : null,
        meetingDate: meta ? meta.meetingDate : null,
        meetingSummary: meta ? meta.meetingSummary : null,
      };
    });
    res.json({ items: enriched });
  } catch (err) {
    res.status(500).json({ error: err?.message || "목록 조회 실패" });
  }
});

app.post("/api/task-checklist/items", requireTaskChecklistAccess, requireMeetingMinutesEmail, express.json(), async (req, res) => {
  try {
    await ensureMeetingUserAndDefaultPrompt(req.meetingUserEmail);
    const item = await taskChecklistStore.insertItem(pgPool, req.meetingUserEmail, {
      title: req.body?.title,
      detail: req.body?.detail,
      assignee: req.body?.assignee,
      dueNote: req.body?.dueNote,
      meetingId: req.body?.meetingId || null,
      source: "manual",
      completed: !!req.body?.completed,
    });
    res.json({ item });
  } catch (err) {
    const msg = err?.message || "저장 실패";
    res.status(400).json({ error: msg });
  }
});

app.patch("/api/task-checklist/items/:id", requireTaskChecklistAccess, requireMeetingMinutesEmail, express.json(), async (req, res) => {
  try {
    const id = (req.params.id || "").trim();
    const item = await taskChecklistStore.updateItem(pgPool, id, req.meetingUserEmail, req.body || {});
    if (!item) return res.status(404).json({ error: "항목을 찾을 수 없습니다." });
    res.json({ item });
  } catch (err) {
    res.status(500).json({ error: err?.message || "수정 실패" });
  }
});

app.delete("/api/task-checklist/items/:id", requireTaskChecklistAccess, requireMeetingMinutesEmail, async (req, res) => {
  try {
    const id = (req.params.id || "").trim();
    const result = await taskChecklistStore.deleteItem(pgPool, id, req.meetingUserEmail);
    if (!result.deleted) return res.status(404).json({ error: "삭제할 항목이 없습니다." });
    res.json({ ok: true });
  } catch (err) {
    res.status(500).json({ error: err?.message || "삭제 실패" });
  }
});

app.post("/api/task-checklist/import/:meetingId", requireTaskChecklistAccess, requireMeetingMinutesEmail, async (req, res) => {
  try {
    await ensureMeetingUserAndDefaultPrompt(req.meetingUserEmail);
    const meetingId = (req.params.meetingId || "").trim();
    const mode = (req.query.mode || "checklist").toString().trim() === "actions" ? "actions" : "checklist";
    const row = await meetingAiStore.getMeeting(pgPool, meetingId, req.meetingUserEmail);
    if (!row) return res.status(404).json({ error: "회의록을 찾을 수 없습니다." });
    const text = row.generated_minutes || "";
    if (!text.trim()) return res.status(400).json({ error: "회의록 본문이 비어 있습니다." });
    const parsed = parseChecklistFromMinutes.parseItemsFromMinutes(text, mode);
    if (!parsed.length) {
      return res.status(400).json({
        error:
          mode === "actions"
            ? "‘액션 아이템’ 섹션에서 항목을 찾지 못했습니다. 제목 형식(번호 목록)을 확인해 주세요."
            : "‘후속 확인 체크리스트’ 등 체크리스트 섹션에서 항목을 찾지 못했습니다.",
        parsedCount: 0,
      });
    }
    const inserted = await taskChecklistStore.insertImportedBatch(pgPool, req.meetingUserEmail, meetingId, parsed);
    res.json({ imported: inserted.length, items: inserted });
  } catch (err) {
    res.status(500).json({ error: err?.message || "가져오기 실패" });
  }
});

/** 모든 회의록에 대해 규칙 기반 가져오기(회의별 중복은 기존과 동일하게 스킵) */
app.post("/api/task-checklist/import-all", requireTaskChecklistAccess, requireMeetingMinutesEmail, async (req, res) => {
  try {
    await ensureMeetingUserAndDefaultPrompt(req.meetingUserEmail);
    const mode = (req.query.mode || "checklist").toString().trim() === "actions" ? "actions" : "checklist";
    const rows = await meetingAiStore.listMeetings(pgPool, req.meetingUserEmail);
    if (!rows.length) {
      return res.status(400).json({ error: "가져올 회의록이 없습니다." });
    }
    let imported = 0;
    /** @type {Array<{ meetingId: string, title: string, imported: number, skip?: string }>} */
    const details = [];
    for (const row of rows) {
      const meetingId = row.id != null ? String(row.id) : "";
      const title = (row.title && String(row.title).trim()) || "제목 없음";
      if (!meetingId) continue;
      const text = row.generated_minutes || "";
      if (!String(text).trim()) {
        details.push({ meetingId, title, imported: 0, skip: "empty_body" });
        continue;
      }
      const parsed = parseChecklistFromMinutes.parseItemsFromMinutes(text, mode);
      if (!parsed.length) {
        details.push({ meetingId, title, imported: 0, skip: "no_parsed_items" });
        continue;
      }
      const inserted = await taskChecklistStore.insertImportedBatch(pgPool, req.meetingUserEmail, meetingId, parsed);
      const n = inserted.length;
      imported += n;
      details.push({ meetingId, title, imported: n });
    }
    const meetingsWithImports = details.filter((d) => d.imported > 0).length;
    res.json({
      imported,
      meetingCount: rows.length,
      meetingsWithImports,
      details,
    });
  } catch (err) {
    res.status(500).json({ error: err?.message || "가져오기 실패" });
  }
});

app.get("/api/chat/config", (req, res) => {
  res.json({
    configured: OPENAI_API_KEY.length > 0,
    webSearch: OPENAI_WEB_SEARCH,
    chatGptAllowed: isChatGptAllowed(req, res),
  });
});

app.post(
  "/api/prompts/likes/toggle",
  requirePromptLibEmail,
  express.json(),
  async (req, res) => {
    if (!pgPool) {
      return res.status(503).json({ error: "DB를 사용할 수 없습니다." });
    }
    try {
      const officialList = loadCompanyPrompts();
      const officialIdSet = new Set((officialList || []).map((p) => p && p.id).filter(Boolean));
      const kind = String(req.body?.kind || req.body?.targetKind || "").trim();
      const id = String(req.body?.id || req.body?.targetId || "").trim();
      const out = await promptLibrary.toggleLike(
        pgPool,
        req.promptLibEmail,
        kind,
        id,
        officialIdSet
      );
      return res.json({ ok: true, ...out });
    } catch (err) {
      if (err && err.code === "VALIDATION") {
        return res.status(400).json({ error: err.message || "잘못된 요청입니다." });
      }
      if (err && err.code === "NOT_FOUND") {
        return res.status(404).json({ error: err.message || "없습니다." });
      }
      console.error("[prompt-likes/toggle]", err);
      return res.status(500).json({ error: err?.message || "처리에 실패했습니다." });
    }
  }
);

app.post(
  "/api/prompts/community",
  requirePromptLibEmail,
  (req, res, next) => {
    if (!pgPool) {
      return res.status(503).json({ error: "DB를 사용할 수 없습니다." });
    }
    req._promptBundleId = uuidv4();
    next();
  },
  (req, res, next) => {
    uploadPromptCommunityFields(req, res, (err) => {
      if (err) {
        return res.status(400).json({ error: err.message || "파일 업로드에 실패했습니다." });
      }
      next();
    });
  },
  async (req, res) => {
    try {
      const { title, description, body, tag } = req.body || {};
      const promptF = (req.files && req.files["promptFiles"]) || [];
      const resultF = (req.files && req.files["resultFiles"]) || [];
      for (let i = 0; i < promptF.length; i += 1) {
        const sz = fsSync.statSync(promptF[i].path).size;
        if (sz > promptLibrary.MAX_ATTACH_BYTES) {
          return res.status(400).json({ error: `프롬프트 첨부는 개당 ${Math.round(promptLibrary.MAX_ATTACH_BYTES / 1024 / 1024)}MB 이하입니다. (${i + 1}번째)` });
        }
      }
      for (let i = 0; i < resultF.length; i += 1) {
        const sz = fsSync.statSync(resultF[i].path).size;
        if (sz > promptLibrary.MAX_ATTACH_BYTES) {
          return res.status(400).json({ error: `결과(샘플) 첨부는 개당 ${Math.round(promptLibrary.MAX_ATTACH_BYTES / 1024 / 1024)}MB 이하입니다. (${i + 1}번째)` });
        }
      }
      const promptAttachments = promptF.map((f) => ({
        originalName: decodeUploadFilename(f.originalname),
        relativePath: filePathToPublicUploadsUrl(f.path),
        size: fsSync.statSync(f.path).size,
      }));
      const resultSampleAttachments = resultF.map((f) => ({
        originalName: decodeUploadFilename(f.originalname),
        relativePath: filePathToPublicUploadsUrl(f.path),
        size: fsSync.statSync(f.path).size,
      }));
      const created = await promptLibrary.createCommunityEntry(pgPool, {
        authorEmail: req.promptLibEmail,
        title,
        description,
        body,
        tag,
        promptAttachments,
        resultSampleAttachments,
      });
      return res.json({
        ok: true,
        id: created.id,
        createdAt: created.createdAt,
        promptFiles: created.promptFiles,
        resultFiles: created.resultFiles,
      });
    } catch (err) {
      if (err && err.code === "VALIDATION") {
        return res.status(400).json({ error: err.message || "입력을 확인해 주세요." });
      }
      if (err && err.code === "23505") {
        return res.status(409).json({ error: "이미 등록되었을 수 있습니다." });
      }
      console.error("[prompts/community]", err);
      return res.status(500).json({ error: err?.message || "저장에 실패했습니다." });
    }
  }
);

app.delete(
  "/api/prompts/community/:id",
  requirePromptLibEmail,
  async (req, res) => {
    if (!pgPool) {
      return res.status(503).json({ error: "DB를 사용할 수 없습니다." });
    }
    try {
      await promptLibrary.softDeleteCommunityEntry(pgPool, String(req.params.id || "").trim(), req.promptLibEmail);
      return res.json({ ok: true });
    } catch (err) {
      if (err && err.code === "VALIDATION") {
        return res.status(400).json({ error: err.message || "잘못된 요청입니다." });
      }
      if (err && err.code === "NOT_FOUND") {
        return res.status(404).json({ error: err.message || "없습니다." });
      }
      console.error("[prompts/community delete]", err);
      return res.status(500).json({ error: err?.message || "삭제에 실패했습니다." });
    }
  }
);

app.post(
  "/api/prompts/polish-workflow",
  requirePromptLibEmail,
  express.json({ limit: "128kb" }),
  async (req, res) => {
    if (!OPENAI_API_KEY) {
      return res.status(503).json({ error: "OpenAI API 키가 설정되지 않았습니다." });
    }
    const draft = String((req.body && req.body.draft) || "").trim();
    if (draft.length < 5) {
      return res.status(400).json({ error: "워크플로·요구사항을 조금 더 입력해 주세요." });
    }
    if (draft.length > 14_000) {
      return res.status(400).json({ error: "입력이 너무 깁니다." });
    }
    try {
      const openai = new OpenAI({ apiKey: OPENAI_API_KEY });
      const apiModel = resolveOpenAiApiModel("gpt-5-mini");
      const completion = await openai.chat.completions.create({
        model: apiModel,
        messages: [
          {
            role: "system",
            content:
              "너는 사내 직장인이 AI에 넣을 '프롬프트(지시문)'을 완성하도록 돕는 어시스턴트다. 사용자의 초안(업무 맥락, 입·출력, 제약)을 한 편의 실행 가능한 프롬프트로 정리한다. 채워야 할 항목은 [대괄호]로 남겨 둬도 된다. 한국어. 서론·잡담 없이 프롬프트 본문만 출력해도 좋다.",
          },
          { role: "user", content: draft },
        ],
        max_tokens: 2048,
      });
      const text = completion.choices?.[0]?.message?.content || "";
      return res.json({ text: String(text).trim() });
    } catch (err) {
      const apiMsg =
        err?.error?.message || err?.response?.data?.error?.message || err?.message || "OpenAI 오류";
      return res.status(500).json({ error: apiMsg });
    }
  }
);

app.post("/api/chat", async (req, res) => {
  if (!isChatGptAllowed(req, res)) {
    res.status(403).json({ error: "허용된 사용자가 아닙니다." });
    return;
  }
  const { messages, model } = req.body;
  if (!Array.isArray(messages) || messages.length === 0) {
    res.status(400).json({ error: "messages 배열이 필요합니다." });
    return;
  }
  const chatModel = (model || "gpt-5-mini").toString().trim() || "gpt-5-mini";
  if (!CHAT_ALLOWED_MODELS.has(chatModel)) {
    res.status(400).json({ error: "지원하지 않는 모델입니다. gpt-5.4 또는 gpt-5-mini만 사용할 수 있습니다." });
    return;
  }
  const provider = getProvider(chatModel);

  const normalizedMessages = messages.map((m) => ({
    role: m.role || "user",
    content: String(m.content || ""),
  }));

  try {
    let reply = "";
    let sources;

    if (provider === "openai") {
      if (!OPENAI_API_KEY) {
        res.status(503).json({ error: "OPENAI_API_KEY가 설정되지 않았습니다. .env 파일을 확인한 뒤 서버를 재시작해주세요." });
        return;
      }
      const apiModel = resolveOpenAiApiModel(chatModel);
      const openai = new OpenAI({ apiKey: OPENAI_API_KEY });
      if (OPENAI_WEB_SEARCH) {
        const resp = await openai.responses.create({
          model: apiModel,
          instructions: SYSTEM_PROMPT,
          input: buildResponsesInputFromChatMessages(normalizedMessages),
          tools: [buildOpenAiWebSearchTool()],
          include: ["web_search_call.action.sources"],
        });
        reply = resp.output_text || "";
        sources = extractSourcesFromResponse(resp);
      } else {
        const completion = await openai.chat.completions.create({
          model: apiModel,
          messages: [
            { role: "system", content: SYSTEM_PROMPT },
            ...normalizedMessages,
          ],
        });
        reply = completion.choices?.[0]?.message?.content || "";
      }
    } else if (provider === "anthropic") {
      if (!CLAUDE_API_KEY) {
        res.status(503).json({ error: "CLAUDE_API_KEY가 설정되지 않았습니다. .env 파일을 확인해주세요." });
        return;
      }
      const anthropic = new Anthropic({ apiKey: CLAUDE_API_KEY });
      const claudeMessages = normalizedMessages
        .filter((m) => m.role === "user" || m.role === "assistant")
        .map((m) => ({ role: m.role, content: m.content }));
      const msg = await anthropic.messages.create({
        model: chatModel,
        max_tokens: 4096,
        system: SYSTEM_PROMPT,
        messages: claudeMessages,
      });
      const textBlock = msg.content?.find((b) => b.type === "text");
      reply = textBlock?.text || "";
    } else if (provider === "google") {
      if (!GENAI_API_KEY) {
        res.status(503).json({ error: "GENAI_API_KEY가 설정되지 않았습니다. .env 파일을 확인해주세요." });
        return;
      }
      const geminiModel = GEMINI_MODEL_MAP[chatModel] || chatModel;
      const ai = new GoogleGenAI({ apiKey: GENAI_API_KEY });
      const geminiContents = normalizedMessages.map((m) => ({
        role: m.role === "assistant" ? "model" : "user",
        parts: [{ text: m.content }],
      }));
      const response = await ai.models.generateContent({
        model: geminiModel,
        contents: geminiContents,
        config: { systemInstruction: SYSTEM_PROMPT },
      });
      reply = response?.text || "";
    }

    const payload = { message: reply };
    if (sources && sources.length) payload.sources = sources;
    res.json(payload);
  } catch (err) {
    const apiMsg =
      err?.error?.message ||
      err?.response?.data?.error?.message ||
      err?.message ||
      `${provider} API 오류`;
    const status =
      typeof err?.status === "number"
        ? err.status
        : err?.response?.status && err.response.status >= 400 && err.response.status < 600
          ? err.response.status
          : 500;
    res.status(status >= 400 && status < 600 ? status : 500).json({ error: apiMsg });
  }
});

function writeChatSse(res, obj) {
  res.write(`data: ${JSON.stringify(obj)}\n\n`);
}

/** OpenAI Chat Completions 스트리밍 (SSE). 클라이언트는 fetch + ReadableStream으로 수신 */
app.post("/api/chat/stream", async (req, res) => {
  const { messages, model } = req.body;
  if (!Array.isArray(messages) || messages.length === 0) {
    res.status(400).json({ error: "messages 배열이 필요합니다." });
    return;
  }
  if (!isChatGptAllowed(req, res)) {
    res.status(403).json({ error: "허용된 사용자가 아닙니다." });
    return;
  }
  const chatModel = (model || "gpt-5-mini").toString().trim() || "gpt-5-mini";
  if (!CHAT_ALLOWED_MODELS.has(chatModel)) {
    res.status(400).json({ error: "지원하지 않는 모델입니다. gpt-5.4 또는 gpt-5-mini만 사용할 수 있습니다." });
    return;
  }
  const provider = getProvider(chatModel);
  if (provider !== "openai") {
    res.status(400).json({ error: "스트리밍은 OpenAI 모델만 지원합니다." });
    return;
  }
  if (!OPENAI_API_KEY) {
    res.status(503).json({ error: "OPENAI_API_KEY가 설정되지 않았습니다. .env 파일을 확인한 뒤 서버를 재시작해주세요." });
    return;
  }

  const normalizedMessages = messages.map((m) => ({
    role: m.role || "user",
    content: String(m.content || ""),
  }));

  res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
  res.setHeader("Cache-Control", "no-cache, no-transform");
  res.setHeader("Connection", "keep-alive");
  res.setHeader("X-Accel-Buffering", "no");

  const apiModel = resolveOpenAiApiModel(chatModel);
  const openai = new OpenAI({ apiKey: OPENAI_API_KEY });

  try {
    if (OPENAI_WEB_SEARCH) {
      const stream = await openai.responses.create({
        model: apiModel,
        instructions: SYSTEM_PROMPT,
        input: buildResponsesInputFromChatMessages(normalizedMessages),
        tools: [buildOpenAiWebSearchTool()],
        stream: true,
        include: ["web_search_call.action.sources"],
      });

      for await (const event of stream) {
        if (event.type === "response.output_text.delta") {
          const d = event.delta;
          if (d) writeChatSse(res, { type: "delta", text: d });
        } else if (
          event.type === "response.web_search_call.searching" ||
          event.type === "response.web_search_call.in_progress"
        ) {
          writeChatSse(res, { type: "status", phase: "web_search" });
        } else if (event.type === "response.completed") {
          const items = extractSourcesFromResponse(event.response);
          if (items.length) writeChatSse(res, { type: "sources", items });
        } else if (event.type === "error") {
          throw new Error(event.message || "Responses API 오류");
        } else if (event.type === "response.failed") {
          const msg = event.response?.error?.message || "응답 생성 실패";
          throw new Error(msg);
        }
      }
      writeChatSse(res, { type: "done" });
      res.end();
      return;
    }

    const stream = await openai.chat.completions.create({
      model: apiModel,
      messages: [{ role: "system", content: SYSTEM_PROMPT }, ...normalizedMessages],
      stream: true,
    });

    for await (const chunk of stream) {
      const delta = chunk.choices?.[0]?.delta?.content ?? "";
      if (delta) writeChatSse(res, { type: "delta", text: delta });
    }
    writeChatSse(res, { type: "done" });
    res.end();
  } catch (err) {
    const apiMsg =
      err?.error?.message ||
      err?.response?.data?.error?.message ||
      err?.message ||
      "OpenAI API 오류";
    try {
      writeChatSse(res, { type: "error", error: apiMsg });
      res.end();
    } catch {
      if (!res.headersSent) {
        res.status(500).json({ error: apiMsg });
      }
    }
  }
});

app.post(
  "/api/ai-success-stories/upload-pdf",
  requireAdminApi,
  (req, res, next) => {
    uploadAiSuccessPdf.single("pdfFile")(req, res, (err) => {
      if (err) {
        res.status(400).json({ error: err.message || "업로드 실패" });
        return;
      }
      next();
    });
  },
  (req, res) => {
    try {
      if (!req.file) {
        res.status(400).json({ error: "PDF 파일을 선택해 주세요." });
        return;
      }
      const rel = path.relative(path.join(ROOT_DIR, "public"), req.file.path).replace(/\\/g, "/");
      const pdfUrl = `/public/${rel}`;
      res.json({ ok: true, pdfUrl, filename: req.file.filename });
    } catch (err) {
      res.status(500).json({ error: err?.message || "저장 실패" });
    }
  }
);

app.post("/api/ai-success-stories", requireAdminApi, (req, res) => {
  try {
    const body = req.body || {};
    const title = String(body.title || "").trim();
    let slug = String(body.slug || "")
      .trim()
      .toLowerCase()
      .replace(/[^a-z0-9\-]/g, "");
    const bodyMd = String(body.bodyMarkdown || "").trim();
    const pdfUrlTrim = String(body.pdfUrl || "").trim();
    if (!title || !slug) {
      res.status(400).json({ error: "제목과 슬러그(영문·숫자·하이픈)는 필수입니다." });
      return;
    }
    if (!bodyMd && !pdfUrlTrim) {
      res.status(400).json({ error: "본문(Markdown) 또는 원문 PDF 중 하나는 필수입니다." });
      return;
    }
    const meta = loadAiSuccessStoriesMeta();
    if (meta.some((m) => m.slug === slug)) {
      res.status(400).json({ error: "같은 슬러그가 이미 있습니다." });
      return;
    }
    const id = `story-${Date.now()}`;
    const tags = String(body.tags || "")
      .split(",")
      .map((t) => t.trim())
      .filter(Boolean);
    const fileName = buildAiSuccessStoryContentFileName(slug);
    fsSync.mkdirSync(AI_SUCCESS_CONTENT_DIR, { recursive: true });
    fsSync.writeFileSync(path.join(AI_SUCCESS_CONTENT_DIR, fileName), bodyMd, "utf8");
    const row = {
      id,
      slug,
      title,
      excerpt: String(body.excerpt || "").trim() || title.slice(0, 140),
      author: String(body.author || "").trim(),
      department: String(body.department || "").trim(),
      publishedAt: String(body.publishedAt || "").trim() || new Date().toISOString().slice(0, 10),
      tags,
      contentFile: fileName,
      pdfUrl: String(body.pdfUrl || "").trim(),
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
    };
    meta.push(row);
    saveAiSuccessStoriesMeta(meta);
    res.json({ ok: true, story: row });
  } catch (err) {
    res.status(500).json({ error: err?.message || "저장 실패" });
  }
});

app.put("/api/ai-success-stories/:id", requireAdminApi, (req, res) => {
  try {
    const id = req.params.id;
    const meta = loadAiSuccessStoriesMeta();
    const idx = meta.findIndex((m) => m.id === id);
    if (idx === -1) {
      res.status(404).json({ error: "해당 사례가 없습니다." });
      return;
    }
    const body = req.body || {};
    const cur = meta[idx];
    const title = body.title !== undefined ? String(body.title).trim() : cur.title;
    let slug = body.slug !== undefined
      ? String(body.slug)
          .trim()
          .toLowerCase()
          .replace(/[^a-z0-9\-]/g, "")
      : cur.slug;
    const bodyMd =
      body.bodyMarkdown !== undefined ? String(body.bodyMarkdown) : loadStoryBodyMarkdown(cur);
    const nextPdf = body.pdfUrl !== undefined ? String(body.pdfUrl).trim() : (cur.pdfUrl || "").trim();
    if (!title || !slug) {
      res.status(400).json({ error: "제목과 슬러그는 필수입니다." });
      return;
    }
    if (!bodyMd.trim() && !nextPdf) {
      res.status(400).json({ error: "본문(Markdown) 또는 원문 PDF 중 하나는 필수입니다." });
      return;
    }
    if (meta.some((m, i) => m.slug === slug && i !== idx)) {
      res.status(400).json({ error: "같은 슬러그가 이미 있습니다." });
      return;
    }
    const oldSlug = cur.slug;
    const oldFileBase = cur.contentFile ? path.basename(cur.contentFile) : `${oldSlug}.md`;
    let nextFile;
    if (slug !== oldSlug) {
      nextFile = buildAiSuccessStoryContentFileName(slug);
    } else {
      nextFile = cur.contentFile ? path.basename(cur.contentFile) : buildAiSuccessStoryContentFileName(slug);
    }
    const safeFile = path.basename(nextFile);
    fsSync.mkdirSync(AI_SUCCESS_CONTENT_DIR, { recursive: true });
    fsSync.writeFileSync(path.join(AI_SUCCESS_CONTENT_DIR, safeFile), bodyMd, "utf8");
    if (oldFileBase !== safeFile) {
      try {
        fsSync.unlinkSync(path.join(AI_SUCCESS_CONTENT_DIR, oldFileBase));
      } catch {
        /* ignore */
      }
    }
    meta[idx] = {
      ...cur,
      title,
      slug,
      excerpt: body.excerpt !== undefined ? String(body.excerpt).trim() : cur.excerpt,
      author: body.author !== undefined ? String(body.author).trim() : cur.author,
      department: body.department !== undefined ? String(body.department).trim() : cur.department,
      publishedAt: body.publishedAt !== undefined ? String(body.publishedAt).trim() : cur.publishedAt,
      tags:
        body.tags !== undefined
          ? String(body.tags)
              .split(",")
              .map((t) => t.trim())
              .filter(Boolean)
          : cur.tags,
      contentFile: safeFile,
      pdfUrl: body.pdfUrl !== undefined ? String(body.pdfUrl).trim() : cur.pdfUrl || "",
      updatedAt: new Date().toISOString(),
    };
    saveAiSuccessStoriesMeta(meta);
    res.json({ ok: true, story: meta[idx] });
  } catch (err) {
    res.status(500).json({ error: err?.message || "저장 실패" });
  }
});

app.delete("/api/ai-success-stories/:id", requireAdminApi, (req, res) => {
  try {
    const id = req.params.id;
    const meta = loadAiSuccessStoriesMeta();
    const idx = meta.findIndex((m) => m.id === id);
    if (idx === -1) {
      res.status(404).json({ error: "해당 사례가 없습니다." });
      return;
    }
    const cur = meta[idx];
    const cf = cur.contentFile;
    if (cf) {
      try {
        fsSync.unlinkSync(path.join(AI_SUCCESS_CONTENT_DIR, path.basename(cf)));
      } catch {
        /* ignore */
      }
    }
    meta.splice(idx, 1);
    saveAiSuccessStoriesMeta(meta);
    res.json({ ok: true });
  } catch (err) {
    res.status(500).json({ error: err?.message || "삭제 실패" });
  }
});

app.use("/", pageRouter);

const decodeXmlText = (value) =>
  value
    .replace(/&amp;/g, "&")
    .replace(/&lt;/g, "<")
    .replace(/&gt;/g, ">")
    .replace(/&quot;/g, '"')
    .replace(/&#39;/g, "'");

const escapeHtml = (value) =>
  (value || "")
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#39;");

const isYoutubeUrl = (url) =>
  /^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.be)\//i.test((url || "").trim());

/** http(s) 외부 링크 (뉴스 URL 등) */
const isHttpUrl = (url) => {
  const s = (url || "").trim();
  if (!s) return false;
  try {
    const u = new URL(s);
    return u.protocol === "http:" || u.protocol === "https:";
  } catch {
    return false;
  }
};

const normalizeLecture = (lecture) => {
  if (!lecture) return lecture;
  if (!lecture.listSection) lecture.listSection = "learning";
  return lecture;
};

const toYoutubeEmbedUrl = (url) => {
  const value = (url || "").trim();
  try {
    const parsed = new URL(value);
    if (parsed.hostname.includes("youtu.be")) {
      const videoId = parsed.pathname.replace("/", "");
      return `https://www.youtube.com/embed/${videoId}`;
    }
    if (parsed.hostname.includes("youtube.com")) {
      const videoId = parsed.searchParams.get("v");
      if (videoId) return `https://www.youtube.com/embed/${videoId}`;
      if (parsed.pathname.startsWith("/embed/")) return value;
    }
    return value;
  } catch {
    return value;
  }
};

const getYoutubeThumbnailUrl = (url) => {
  if (!url || !isYoutubeUrl(url)) return null;
  try {
    const parsed = new URL((url || "").trim());
    let videoId = null;
    if (parsed.hostname.includes("youtu.be")) {
      videoId = parsed.pathname.replace(/^\/+/, "").split("/")[0];
    } else if (parsed.hostname.includes("youtube.com")) {
      videoId = parsed.searchParams.get("v");
      if (!videoId && parsed.pathname.startsWith("/embed/")) {
        videoId = (parsed.pathname.match(/\/embed\/([^/?]+)/) || [])[1];
      }
    }
    if (videoId) return `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`;
  } catch {}
  return null;
};

app.locals.getYoutubeThumbnailUrl = getYoutubeThumbnailUrl;

const mapRowToLecture = (row) =>
  normalizeLecture({
    id: row.id,
    type: row.type,
    title: row.title,
    description: row.description || "",
    tags: Array.isArray(row.tags) ? row.tags : [],
    youtubeUrl: row.youtube_url || null,
    fileName: row.file_name || null,
    originalName: row.original_name || null,
    previewTitle: row.preview_title || null,
    slideCount: typeof row.slide_count === "number" ? row.slide_count : 0,
    thumbnailUrl: row.thumbnail_url || null,
    thumbnailStatus: row.thumbnail_status || "pending",
    thumbnailRetryCount: typeof row.thumbnail_retry_count === "number" ? row.thumbnail_retry_count : 0,
    thumbnailError: row.thumbnail_error || null,
    thumbnailUpdatedAt: row.thumbnail_updated_at ? new Date(row.thumbnail_updated_at).toISOString() : null,
    createdAt: row.created_at ? new Date(row.created_at).toISOString() : new Date().toISOString(),
    listSection: row.list_section || "learning",
    newsUrl: row.news_url || null,
  });

const readLectureDb = async () => {
  if (pgPool) {
    try {
      const res = await pgPool.query(
        "SELECT id, type, title, description, tags, youtube_url, file_name, original_name, preview_title, slide_count, thumbnail_url, thumbnail_status, thumbnail_retry_count, thumbnail_error, thumbnail_updated_at, created_at, list_section, news_url FROM lectures ORDER BY created_at DESC"
      );
      return (res.rows || []).map(mapRowToLecture);
    } catch (err) {
      console.error("readLectureDb from PostgreSQL failed:", err.message);
      throw err;
    }
  }
  try {
    const raw = await fs.readFile(LECTURE_DB_PATH, "utf-8");
    const parsed = JSON.parse(raw);
    const list = Array.isArray(parsed) ? parsed : [];
    return list.map((item) => normalizeLecture({ ...item }));
  } catch {
    return [];
  }
};

const writeLectureDb = async (lectures) => {
  if (pgPool) {
    await syncLecturesToPostgres(lectures);
    return;
  }
  await fs.writeFile(LECTURE_DB_PATH, JSON.stringify(lectures, null, 2), "utf-8");
};

/** link·news: og:image URL을 thumbnailUrl(절대 URL)에 저장 */
const applyLinkOgPreviewToLecture = async (lectureId, pageUrl) => {
  const img = await fetchOpenGraphImageUrl(pageUrl);
  const lectures = await readLectureDb();
  const lec = lectures.find((l) => l.id === lectureId);
  if (!lec || (lec.type !== "link" && lec.type !== "news")) return;
  lec.thumbnailUrl = img || null;
  lec.thumbnailStatus = img ? "ready" : "failed";
  lec.thumbnailError = img ? null : "og:image 없음";
  lec.thumbnailUpdatedAt = new Date().toISOString();
  if (typeof lec.thumbnailRetryCount !== "number") lec.thumbnailRetryCount = 0;
  await writeLectureDb(lectures);
};

const scheduleLinkOgPreviewFetch = (lectureId, pageUrl) => {
  setImmediate(() => {
    applyLinkOgPreviewToLecture(lectureId, pageUrl).catch((e) =>
      console.error("[link-preview]", lectureId, e?.message || e)
    );
  });
};

/** 목록에 표시 중인 pending 링크에 대해 서버당 1회 OG fetch 예약(기존 등록 건 보정) */
const linkOgPreviewScheduledIds = new Set();

const parseTags = (value) =>
  (value || "")
    .split(",")
    .map((tag) => tag.trim())
    .filter(Boolean)
    .filter((tag, idx, arr) => arr.indexOf(tag) === idx);

const LECTURE_CATEGORIES = ["AX 사고 전환", "AI 툴 활용", "AI Agent", "바이브 코딩"];

const mergeTagsWithCategory = (tagsStr, category) => {
  const tags = parseTags(tagsStr);
  const cat = (category || "").toString().trim();
  if (cat && LECTURE_CATEGORIES.includes(cat) && !tags.includes(cat)) {
    tags.push(cat);
  }
  return tags;
};

const parsePptxSlides = async (filePath) => {
  const buffer = await fs.readFile(filePath);
  const zip = await JSZip.loadAsync(buffer);
  const slideFiles = Object.keys(zip.files)
    .filter((name) => /^ppt\/slides\/slide\d+\.xml$/.test(name))
    .sort((a, b) => {
      const aNum = Number((a.match(/slide(\d+)\.xml/) || [])[1] || 0);
      const bNum = Number((b.match(/slide(\d+)\.xml/) || [])[1] || 0);
      return aNum - bNum;
    });

  const slides = [];
  for (const slideFile of slideFiles) {
    const xml = await zip.files[slideFile].async("string");
    const texts = [];
    const matcher = xml.matchAll(/<a:t[^>]*>([\s\S]*?)<\/a:t>/g);
    for (const matched of matcher) {
      const plain = decodeXmlText(matched[1] || "").trim();
      if (plain) texts.push(plain);
    }
    slides.push({
      title: texts[0] || "",
      lines: texts,
    });
  }
  return slides;
};

const getPptxMeta = async (filePath) => {
  const slides = await parsePptxSlides(filePath);
  const first = slides[0] || { title: "" };
  return {
    slideCount: slides.length,
    previewTitle: first.title || "",
  };
};

const getPdfMeta = async (filePath) => {
  try {
    const { stdout } = await execFileAsync("pdfinfo", [filePath], { timeout: 10000 });
    const match = (stdout || "").match(/Pages:\s*(\d+)/);
    const slideCount = match ? Math.max(1, parseInt(match[1], 10)) : 0;
    return { slideCount, previewTitle: "" };
  } catch {
    return { slideCount: 0, previewTitle: "" };
  }
};

const parsePdfSlides = async (filePath) => {
  const meta = await getPdfMeta(filePath);
  return Array.from({ length: meta.slideCount }, (_, i) => ({
    title: "",
    lines: [],
  }));
};

const tryExec = async (command, args, timeout = 20000) => {
  try {
    await execFileAsync(command, args, {
      timeout,
      windowsHide: true,
    });
    return true;
  } catch (err) {
    return false;
  }
};

const tryExecWithLog = async (command, args, timeout, logContext) => {
  try {
    await execFileAsync(command, args, {
      timeout,
      windowsHide: true,
    });
    return { ok: true };
  } catch (err) {
    console.error(`[slide-images] ${logContext}: ${command} failed:`, err.message || err);
    return { ok: false, error: err.message };
  }
};

const safeRemove = async (targetPath) => {
  try {
    await fs.rm(targetPath, { recursive: true, force: true });
  } catch {
    // Ignore cleanup errors.
  }
};

const generateWithQuickLook = async (filePath, targetKey) => {
  if (process.platform !== "darwin") return null;
  const qlmanagePath = "/usr/bin/qlmanage";
  if (!fsSync.existsSync(qlmanagePath)) return null;

  const before = new Set(await fs.readdir(THUMBNAIL_DIR));
  const ok = await tryExec(qlmanagePath, ["-t", "-s", String(THUMBNAIL_WIDTH), "-o", THUMBNAIL_DIR, filePath]);
  if (!ok) return null;

  const after = await fs.readdir(THUMBNAIL_DIR);
  const createdCandidates = after.filter((name) => !before.has(name) && /\.(png|jpg|jpeg)$/i.test(name));
  const base = path.basename(filePath);
  const matched = createdCandidates.find((name) => name.includes(base)) || createdCandidates[0];
  if (!matched) return null;

  const sourcePath = path.join(THUMBNAIL_DIR, matched);
  const ext = path.extname(matched) || ".png";
  const finalName = `${targetKey}${ext.toLowerCase()}`;
  const finalPath = path.join(THUMBNAIL_DIR, finalName);

  try {
    await fs.rename(sourcePath, finalPath);
  } catch {
    return null;
  }
  return `/uploads/thumbnails/${finalName}`;
};

const generateWithLibreOffice = async (filePath, targetKey) => {
  const tmpWorkDir = path.join(TMP_DIR, `thumb-${targetKey}-${Date.now()}`);
  await fs.mkdir(tmpWorkDir, { recursive: true });

  const sofficeCandidates = ["soffice", "libreoffice"];
  let converted = false;
  for (const cmd of sofficeCandidates) {
    // soffice --headless --convert-to pdf --outdir <tmp> <file.pptx>
    const ok = await tryExec(cmd, ["--headless", "--convert-to", "pdf", "--outdir", tmpWorkDir, filePath], 60000);
    if (ok) {
      converted = true;
      break;
    }
  }
  if (!converted) {
    await safeRemove(tmpWorkDir);
    return null;
  }

  const pdfPath = path.join(tmpWorkDir, `${path.basename(filePath, path.extname(filePath))}.pdf`);
  if (!fsSync.existsSync(pdfPath)) {
    await safeRemove(tmpWorkDir);
    return null;
  }

  const outPrefix = path.join(tmpWorkDir, "preview");
  const okPpm = await tryExec("pdftoppm", ["-f", "1", "-singlefile", "-png", pdfPath, outPrefix], 30000);
  if (!okPpm) {
    await safeRemove(tmpWorkDir);
    return null;
  }

  const generatedPng = `${outPrefix}.png`;
  if (!fsSync.existsSync(generatedPng)) {
    await safeRemove(tmpWorkDir);
    return null;
  }

  const finalName = `${targetKey}.png`;
  const finalPath = path.join(THUMBNAIL_DIR, finalName);
  try {
    await fs.copyFile(generatedPng, finalPath);
  } catch {
    await safeRemove(tmpWorkDir);
    return null;
  }
  await safeRemove(tmpWorkDir);
  return `/uploads/thumbnails/${finalName}`;
};

const generateWithPdfDirect = async (filePath, targetKey) => {
  const ext = path.extname(filePath).toLowerCase();
  if (ext !== ".pdf") return null;

  const tmpWorkDir = path.join(TMP_DIR, `thumb-pdf-${targetKey}-${Date.now()}`);
  await fs.mkdir(tmpWorkDir, { recursive: true });

  const outPrefix = path.join(tmpWorkDir, "preview");
  const okPpm = await tryExec("pdftoppm", ["-f", "1", "-singlefile", "-png", "-r", "150", filePath, outPrefix], 30000);
  if (!okPpm) {
    await safeRemove(tmpWorkDir);
    return null;
  }

  const generatedPng = `${outPrefix}.png`;
  if (!fsSync.existsSync(generatedPng)) {
    await safeRemove(tmpWorkDir);
    return null;
  }

  const finalName = `${targetKey}.png`;
  const finalPath = path.join(THUMBNAIL_DIR, finalName);
  try {
    await fs.copyFile(generatedPng, finalPath);
  } catch {
    await safeRemove(tmpWorkDir);
    return null;
  }
  await safeRemove(tmpWorkDir);
  return `/uploads/thumbnails/${finalName}`;
};

const generatePptThumbnail = async (filePath, targetKey) => {
  if (!ENABLE_PPT_THUMBNAIL) return null;

  await fs.mkdir(THUMBNAIL_DIR, { recursive: true });
  const pdfDirect = await generateWithPdfDirect(filePath, targetKey);
  if (pdfDirect) return pdfDirect;
  const quickLook = await generateWithQuickLook(filePath, targetKey);
  if (quickLook) return quickLook;
  const libre = await generateWithLibreOffice(filePath, targetKey);
  if (libre) return libre;
  return null;
};

/** 업로드 영상: ffmpeg로 지정 시각의 1프레임을 PNG로 저장 (서버에 ffmpeg 필요) */
const generateVideoThumbnail = async (filePath, targetKey) => {
  if (!ENABLE_VIDEO_THUMBNAIL) return null;
  if (!filePath || !targetKey || !fsSync.existsSync(filePath)) return null;

  await fs.mkdir(THUMBNAIL_DIR, { recursive: true });
  const finalName = `${targetKey}.png`;
  const finalPath = path.join(THUMBNAIL_DIR, finalName);
  const seek = String(VIDEO_THUMB_SEEK_SEC);
  const ok = await tryExec(
    "ffmpeg",
    [
      "-hide_banner",
      "-loglevel",
      "error",
      "-y",
      "-ss",
      seek,
      "-i",
      filePath,
      "-frames:v",
      "1",
      "-vf",
      `scale=${THUMBNAIL_WIDTH}:-1`,
      finalPath,
    ],
    90000
  );
  if (!ok || !fsSync.existsSync(finalPath)) return null;
  return `/uploads/thumbnails/${finalName}`;
};

const generateSlideImages = async (filePath, lectureId) => {
  const outDir = path.join(SLIDES_DIR, lectureId);
  await fs.mkdir(outDir, { recursive: true });

  const ext = path.extname(filePath).toLowerCase();
  const tmpWorkDir = path.join(TMP_DIR, `slides-${lectureId}-${Date.now()}`);
  await fs.mkdir(tmpWorkDir, { recursive: true });

  let pdfPath = filePath;
  if (ext === ".pptx") {
    const libreOfficeCandidates = [
      "soffice",
      "libreoffice",
      ...(process.platform === "darwin"
        ? ["/Applications/LibreOffice.app/Contents/MacOS/soffice"]
        : []),
    ];
    let converted = false;
    for (const cmd of libreOfficeCandidates) {
      const { ok } = await tryExecWithLog(
        cmd,
        ["--headless", "--convert-to", "pdf", "--outdir", tmpWorkDir, filePath],
        90000,
        `lecture ${lectureId} PPTX→PDF`
      );
      if (ok) {
        converted = true;
        break;
      }
    }
    if (!converted) {
      console.error(`[slide-images] lecture ${lectureId}: PPTX 변환 실패. LibreOffice(soffice/libreoffice) 설치 필요. brew install --cask libreoffice`);
      await safeRemove(tmpWorkDir);
      return null;
    }

    pdfPath = path.join(tmpWorkDir, `${path.basename(filePath, path.extname(filePath))}.pdf`);
    if (!fsSync.existsSync(pdfPath)) {
      console.error(`[slide-images] lecture ${lectureId}: 변환된 PDF 파일을 찾을 수 없음: ${pdfPath}`);
      await safeRemove(tmpWorkDir);
      return null;
    }
  } else if (ext !== ".pdf") {
    await safeRemove(tmpWorkDir);
    return null;
  }

  const outPrefix = path.join(tmpWorkDir, "slide");
  const { ok: okPpm } = await tryExecWithLog(
    "pdftoppm",
    ["-png", "-r", "150", pdfPath, outPrefix],
    60000,
    `lecture ${lectureId} pdftoppm`
  );
  if (!okPpm) {
    await safeRemove(tmpWorkDir);
    return null;
  }

  const slidePngs = fsSync.readdirSync(tmpWorkDir)
    .filter((n) => /^slide-\d+\.png$/.test(n))
    .sort((a, b) => {
      const na = Number((a.match(/slide-(\d+)\.png/) || [])[1] || 0);
      const nb = Number((b.match(/slide-(\d+)\.png/) || [])[1] || 0);
      return na - nb;
    });

  if (slidePngs.length === 0) {
    console.error(`[slide-images] lecture ${lectureId}: pdftoppm 출력 파일 없음. tmpWorkDir=${tmpWorkDir}`);
  }

  const urls = [];
  for (let i = 0; i < slidePngs.length; i++) {
    const srcPath = path.join(tmpWorkDir, slidePngs[i]);
    const destName = `slide-${i + 1}.png`;
    const destPath = path.join(outDir, destName);
    try {
      await fs.copyFile(srcPath, destPath);
      urls.push(`/uploads/slides/${lectureId}/${destName}`);
    } catch (err) {
      console.error(`[slide-images] lecture ${lectureId}: 파일 복사 실패 ${slidePngs[i]}:`, err.message);
      break;
    }
  }

  await safeRemove(tmpWorkDir);
  if (urls.length > 0) {
    console.log(`[slide-images] lecture ${lectureId}: ${urls.length}장 슬라이드 이미지 생성 완료`);
  }
  return urls.length > 0 ? urls : null;
};

const getSlideImageUrls = (lectureId) => {
  const dir = path.join(SLIDES_DIR, lectureId);
  if (!fsSync.existsSync(dir)) return [];
  const files = fsSync.readdirSync(dir)
    .filter((n) => /^slide-\d+\.png$/.test(n))
    .sort((a, b) => {
      const na = Number((a.match(/slide-(\d+)\.png/) || [])[1] || 0);
      const nb = Number((b.match(/slide-(\d+)\.png/) || [])[1] || 0);
      return na - nb;
    });
  return files.map((f) => `/uploads/slides/${lectureId}/${f}`);
};

const AI_SUCCESS_SLIDES_SUBDIR = "ai-success-slides";

function safeAiSuccessSlug(slug) {
  return (
    String(slug || "")
      .replace(/[^a-zA-Z0-9\-_]/g, "-")
      .replace(/-+/g, "-")
      .replace(/^-|-$/g, "") || "story"
  );
}

/** `/public/.../file.pdf` 또는 절대 URL의 `/public/...` 경로 → 절대 경로 (public 하위 PDF만) */
function resolvePublicPdfPath(pdfUrl) {
  const raw = (pdfUrl || "").trim();
  if (!raw) return null;
  let pathname = raw;
  if (/^https?:\/\//i.test(raw)) {
    try {
      pathname = new URL(raw).pathname;
    } catch {
      return null;
    }
  } else if (!raw.startsWith("/")) {
    if (raw.startsWith("public/")) pathname = `/${raw}`;
    else return null;
  }
  if (!pathname.startsWith("/public/")) return null;
  let rel;
  try {
    rel = decodeURIComponent(pathname).replace(/^\/+/, "");
  } catch {
    rel = pathname.replace(/^\/+/, "");
  }
  const abs = path.join(ROOT_DIR, rel);
  const normalized = path.normalize(abs);
  const publicRoot = path.join(ROOT_DIR, "public");
  if (!normalized.startsWith(publicRoot) || !fsSync.existsSync(normalized)) return null;
  if (path.extname(normalized).toLowerCase() !== ".pdf") return null;
  return normalized;
}

function getAiSuccessSlideImageUrls(slug) {
  const safeSlug = safeAiSuccessSlug(slug);
  const dir = path.join(UPLOAD_DIR, AI_SUCCESS_SLIDES_SUBDIR, safeSlug);
  if (!fsSync.existsSync(dir)) return [];
  const files = fsSync
    .readdirSync(dir)
    .filter((n) => /^slide-\d+\.png$/.test(n))
    .sort((a, b) => {
      const na = Number((a.match(/slide-(\d+)\.png/) || [])[1] || 0);
      const nb = Number((b.match(/slide-(\d+)\.png/) || [])[1] || 0);
      return na - nb;
    });
  return files.map((f) => `/uploads/${AI_SUCCESS_SLIDES_SUBDIR}/${safeSlug}/${f}`);
}

async function generateAiSuccessPdfSlides(pdfPath, slug) {
  const safeSlug = safeAiSuccessSlug(slug);
  const outDir = path.join(UPLOAD_DIR, AI_SUCCESS_SLIDES_SUBDIR, safeSlug);
  await fs.mkdir(outDir, { recursive: true });
  const tmpWorkDir = path.join(TMP_DIR, `ai-success-${safeSlug}-${Date.now()}`);
  await fs.mkdir(tmpWorkDir, { recursive: true });
  const outPrefix = path.join(tmpWorkDir, "slide");
  const { ok: okPpm } = await tryExecWithLog(
    "pdftoppm",
    ["-png", "-r", "150", pdfPath, outPrefix],
    60000,
    `ai-success ${safeSlug} pdftoppm`
  );
  if (!okPpm) {
    await safeRemove(tmpWorkDir);
    return null;
  }
  const slidePngs = fsSync
    .readdirSync(tmpWorkDir)
    .filter((n) => /^slide-\d+\.png$/.test(n))
    .sort((a, b) => {
      const na = Number((a.match(/slide-(\d+)\.png/) || [])[1] || 0);
      const nb = Number((b.match(/slide-(\d+)\.png/) || [])[1] || 0);
      return na - nb;
    });
  const urls = [];
  for (let i = 0; i < slidePngs.length; i++) {
    const srcPath = path.join(tmpWorkDir, slidePngs[i]);
    const destName = `slide-${i + 1}.png`;
    const destPath = path.join(outDir, destName);
    try {
      await fs.copyFile(srcPath, destPath);
      urls.push(`/uploads/${AI_SUCCESS_SLIDES_SUBDIR}/${safeSlug}/${destName}`);
    } catch (err) {
      console.error(`[ai-success-slides] 복사 실패 ${slidePngs[i]}:`, err?.message || err);
      break;
    }
  }
  await safeRemove(tmpWorkDir);
  if (urls.length > 0) {
    console.log(`[ai-success-slides] ${safeSlug}: ${urls.length}장 생성`);
  }
  return urls.length > 0 ? urls : null;
}

async function ensureAiSuccessStorySlides(slug, pdfUrl) {
  const pdfPath = resolvePublicPdfPath(pdfUrl);
  if (!pdfPath) {
    return { urls: [], slidesError: true };
  }
  const existing = getAiSuccessSlideImageUrls(slug);
  if (existing.length > 0) {
    return { urls: existing, slidesError: false };
  }
  const generated = await generateAiSuccessPdfSlides(pdfPath, slug);
  if (!generated || generated.length === 0) {
    return { urls: [], slidesError: true };
  }
  return { urls: generated, slidesError: false };
}

const ensurePptThumbnailFields = (lecture) => {
  if (!lecture || lecture.type !== "ppt") return false;
  let changed = false;
  if (!lecture.thumbnailStatus) {
    lecture.thumbnailStatus = lecture.thumbnailUrl ? "ready" : "pending";
    changed = true;
  }
  if (typeof lecture.thumbnailRetryCount !== "number") {
    lecture.thumbnailRetryCount = 0;
    changed = true;
  }
  if (!lecture.thumbnailError) {
    lecture.thumbnailError = null;
    changed = true;
  }
  if (!lecture.thumbnailUpdatedAt) {
    lecture.thumbnailUpdatedAt = lecture.createdAt || new Date().toISOString();
    changed = true;
  }
  return changed;
};

const ensureVideoThumbnailFields = (lecture) => {
  if (!lecture || lecture.type !== "video") return false;
  let changed = false;
  if (!lecture.thumbnailStatus) {
    lecture.thumbnailStatus = lecture.thumbnailUrl ? "ready" : "pending";
    changed = true;
  }
  if (typeof lecture.thumbnailRetryCount !== "number") {
    lecture.thumbnailRetryCount = 0;
    changed = true;
  }
  if (!lecture.thumbnailError) {
    lecture.thumbnailError = null;
    changed = true;
  }
  if (!lecture.thumbnailUpdatedAt) {
    lecture.thumbnailUpdatedAt = lecture.createdAt || new Date().toISOString();
    changed = true;
  }
  return changed;
};

const enqueueThumbnailJob = (lectureId, options = {}) => {
  const force = options.force === true;
  const reason = options.reason || "manual";
  const persist = options.persist !== false;
  if (!lectureId) return false;
  if (queuedLectureIds.has(lectureId)) return false;
  thumbnailQueue.push({ lectureId, force, reason, enqueuedAt: Date.now() });
  queuedLectureIds.add(lectureId);
  appendThumbnailEvent({
    type: "enqueue",
    lectureId,
    reason,
    force,
    queueSizeAfter: thumbnailQueue.length,
  }).catch(() => {
    // Ignore event logging failure.
  });
  if (persist) {
    writeThumbnailJobDb().catch(() => {
      // Ignore queue persistence failure.
    });
  }
  processThumbnailQueue();
  return true;
};

const processThumbnailQueue = async () => {
  if (thumbnailWorkerRunning) return;
  thumbnailWorkerRunning = true;
  while (thumbnailQueue.length > 0) {
    const job = thumbnailQueue.shift();
    queuedLectureIds.delete(job.lectureId);
    await writeThumbnailJobDb().catch(() => {
      // Ignore queue persistence failure.
    });
    try {
      const startedAt = Date.now();
      const lectures = await readLectureDb();
      const lecture = lectures.find((item) => item.id === job.lectureId);
      if (!lecture || !lecture.fileName) continue;

      const isPpt = lecture.type === "ppt";
      const isVideo = lecture.type === "video";
      if (!isPpt && !isVideo) continue;

      if (isPpt) {
        ensurePptThumbnailFields(lecture);
      } else {
        ensureVideoThumbnailFields(lecture);
      }
      if (!job.force && lecture.thumbnailStatus === "ready" && lecture.thumbnailUrl) {
        continue;
      }

      lecture.thumbnailStatus = "processing";
      lecture.thumbnailError = null;
      lecture.thumbnailUpdatedAt = new Date().toISOString();
      await writeLectureDb(lectures);
      await appendThumbnailEvent({
        type: "start",
        lectureId: lecture.id,
        lectureTitle: lecture.title,
        reason: job.reason,
        force: job.force,
        queueSizeAfter: thumbnailQueue.length,
      }).catch(() => {
        // Ignore event logging failure.
      });

      const filePath = path.join(UPLOAD_DIR, lecture.fileName);
      const newThumb = isPpt
        ? await generatePptThumbnail(filePath, lecture.id)
        : await generateVideoThumbnail(filePath, lecture.id);

      if (newThumb) {
        lecture.thumbnailUrl = newThumb;
        lecture.thumbnailStatus = "ready";
        lecture.thumbnailError = null;
      } else {
        lecture.thumbnailStatus = "failed";
        lecture.thumbnailError = isVideo
          ? "동영상 썸네일 생성 실패(ffmpeg 미설치 또는 코덱/파일 문제)"
          : "썸네일 생성 도구 실행 실패 또는 미설치";
        lecture.thumbnailRetryCount = (lecture.thumbnailRetryCount || 0) + 1;
      }
      lecture.thumbnailUpdatedAt = new Date().toISOString();
      await writeLectureDb(lectures);
      await appendThumbnailEvent({
        type: lecture.thumbnailStatus === "ready" ? "success" : "failed",
        lectureId: lecture.id,
        lectureTitle: lecture.title,
        reason: job.reason,
        force: job.force,
        retryCount: lecture.thumbnailRetryCount || 0,
        error: lecture.thumbnailError || null,
        durationMs: Date.now() - startedAt,
      }).catch(() => {
        // Ignore event logging failure.
      });

      const allowRetry =
        lecture.thumbnailStatus === "failed" &&
        lecture.thumbnailRetryCount <= THUMBNAIL_MAX_RETRY &&
        ((isPpt && ENABLE_PPT_THUMBNAIL) || (isVideo && ENABLE_VIDEO_THUMBNAIL));
      if (allowRetry) {
        setTimeout(() => {
          enqueueThumbnailJob(lecture.id, { force: true, reason: "auto-retry" });
        }, THUMBNAIL_RETRY_DELAY_MS);
      }
    } catch (error) {
      await appendThumbnailEvent({
        type: "worker-error",
        lectureId: job.lectureId,
        reason: job.reason,
        force: job.force,
        error: error?.message || "unknown worker error",
      }).catch(() => {
        // Ignore event logging failure.
      });
      // Keep queue worker alive for next jobs.
    }
  }
  thumbnailWorkerRunning = false;
};

const removeQueuedThumbnailJobs = async (lectureId) => {
  if (!lectureId) return;
  let changed = false;
  for (let i = thumbnailQueue.length - 1; i >= 0; i -= 1) {
    if (thumbnailQueue[i].lectureId === lectureId) {
      thumbnailQueue.splice(i, 1);
      changed = true;
    }
  }
  if (changed) {
    queuedLectureIds.delete(lectureId);
    await writeThumbnailJobDb().catch(() => {
      // Ignore queue persistence failure.
    });
  }
};

const seedResourceLectures = async () => {
  let lectures = await readLectureDb();
  if (lectures.length > 0) return;

  if (!fsSync.existsSync(RESOURCES_LECTURE_DIR)) return;
  const files = await fs.readdir(RESOURCES_LECTURE_DIR);
  const pptxFiles = files.filter((name) => path.extname(name).toLowerCase() === ".pptx");

  for (const fileName of pptxFiles) {
    const source = path.join(RESOURCES_LECTURE_DIR, fileName);
    const targetName = `${Date.now()}-${uuidv4()}${path.extname(fileName)}`;
    const target = path.join(UPLOAD_DIR, targetName);
    await fs.copyFile(source, target);
    lectures.push({
      id: uuidv4(),
      type: "ppt",
      listSection: "learning",
      title: path.basename(fileName, path.extname(fileName)),
      description: "초기 샘플 PPT 강의",
      tags: ["샘플", "PPT"],
      fileName: targetName,
      originalName: fileName,
      createdAt: new Date().toISOString(),
    });
    const inserted = lectures[lectures.length - 1];
    const meta = await getPptxMeta(target);
    inserted.previewTitle = meta.previewTitle;
    inserted.slideCount = meta.slideCount;
    inserted.thumbnailUrl = null;
    inserted.thumbnailStatus = "pending";
    inserted.thumbnailRetryCount = 0;
    inserted.thumbnailError = null;
    inserted.thumbnailUpdatedAt = new Date().toISOString();
    enqueueThumbnailJob(inserted.id, { force: false, reason: "seed" });
  }
  await writeLectureDb(lectures);
};

const ensureBootstrap = async () => {
  await initPostgres();
  await thumbnailEventsStore
    .migrateThumbnailEventsFromJson(pgPool, THUMBNAIL_EVENT_DB_PATH, THUMBNAIL_EVENT_KEEP)
    .catch((e) => console.warn("thumbnail-events migration:", e?.message || e));
  await fs.mkdir(DATA_DIR, { recursive: true });
  await fs.mkdir(TMP_DIR, { recursive: true });
  await fs.mkdir(UPLOAD_DIR, { recursive: true });
  await fs.mkdir(THUMBNAIL_DIR, { recursive: true });
  if (!pgPool && !fsSync.existsSync(LECTURE_DB_PATH)) {
    await writeLectureDb([]);
  }
  if (!pgPool && !fsSync.existsSync(AX_ASSIGNMENTS_DB_PATH)) {
    await fs.writeFile(AX_ASSIGNMENTS_DB_PATH, "[]", "utf-8");
  }
  if (!fsSync.existsSync(THUMBNAIL_JOB_DB_PATH)) {
    await fs.writeFile(THUMBNAIL_JOB_DB_PATH, "[]", "utf-8");
  }
  if (!pgPool && !fsSync.existsSync(THUMBNAIL_EVENT_DB_PATH)) {
    await fs.writeFile(THUMBNAIL_EVENT_DB_PATH, "[]", "utf-8");
  }
  await seedResourceLectures();

  const lectures = await readLectureDb();
  let changed = false;
  for (const lecture of lectures) {
    if (lecture.type === "ppt") {
      if (ensurePptThumbnailFields(lecture)) changed = true;
    } else if (lecture.type === "video") {
      if (ensureVideoThumbnailFields(lecture)) changed = true;
      if (lecture.fileName && !lecture.thumbnailUrl && lecture.thumbnailStatus === "ready") {
        lecture.thumbnailStatus = "pending";
        lecture.thumbnailUpdatedAt = new Date().toISOString();
        changed = true;
      }
    } else {
      continue;
    }

    if (lecture.thumbnailStatus === "processing") {
      lecture.thumbnailStatus = "pending";
      lecture.thumbnailUpdatedAt = new Date().toISOString();
      changed = true;
    }

    const thumbEnabled = lecture.type === "ppt" ? ENABLE_PPT_THUMBNAIL : ENABLE_VIDEO_THUMBNAIL;
    if (lecture.thumbnailStatus === "pending" && lecture.fileName && thumbEnabled) {
      enqueueThumbnailJob(lecture.id, { force: false, reason: "bootstrap" });
    }
    if (
      lecture.thumbnailStatus === "failed" &&
      lecture.thumbnailRetryCount < THUMBNAIL_MAX_RETRY &&
      thumbEnabled
    ) {
      lecture.thumbnailStatus = "pending";
      lecture.thumbnailError = null;
      lecture.thumbnailUpdatedAt = new Date().toISOString();
      changed = true;
      enqueueThumbnailJob(lecture.id, { force: true, reason: "bootstrap-retry" });
    }
  }

  const persistedJobs = await readThumbnailJobDb();
  for (const job of persistedJobs) {
    if (!job?.lectureId) continue;
    enqueueThumbnailJob(job.lectureId, {
      force: job.force === true,
      reason: job.reason || "restored",
      persist: false,
    });
  }
  await writeThumbnailJobDb().catch(() => {
    // Ignore queue persistence failure.
  });

  if (changed) await writeLectureDb(lectures);
};

function normalizeLectureSearchText(s) {
  try {
    return String(s || "")
      .normalize("NFC")
      .toLowerCase();
  } catch {
    return String(s || "").toLowerCase();
  }
}

/** "클로드" 검색 시 본문의 "Claude", "claude" 등과도 매칭되도록 보강 */
function lectureSearchQueryVariants(qNorm) {
  if (!qNorm) return [""];
  const v = new Set([qNorm]);
  if (qNorm.includes("클로드")) v.add(qNorm.replace(/클로드/g, "claude"));
  if (qNorm.includes("claude")) v.add(qNorm.replace(/claude/g, "클로드"));
  return [...v];
}

/**
 * 강의 검색어(q) 매칭: title, description, previewTitle, PPT/PDF는 originalName(확장자 제외).
 * 한글 NFC 정규화 + 클로드/claude 상호 보강.
 */
function lectureMatchesSearchQuery(lecture, qNorm) {
  if (!qNorm) return true;
  const parts = [lecture.title || "", lecture.description || ""];
  const pt = (lecture.previewTitle || "").trim();
  if (pt && pt !== "제목 없음" && pt !== "미리보기 생성 실패") {
    parts.push(pt);
  }
  if (lecture.type === "ppt" || lecture.type === "video") {
    const orig = (lecture.originalName || "").replace(/\.[^.]+$/, "");
    if (orig) parts.push(orig);
  }
  if (lecture.type === "link" || lecture.type === "news") {
    const u = (lecture.newsUrl || "").trim();
    if (u) parts.push(u);
  }
  const haystack = normalizeLectureSearchText(parts.join(" "));
  return lectureSearchQueryVariants(qNorm).some((variant) => haystack.includes(variant));
}

const buildLectureListContext = async (req, options = {}) => {
  const { basePath = "/learning", forAdmin = false } = options;
  const page = Math.max(Number(req.query.page) || 1, 1);
  const q = (req.query.q || "").toString().trim();
  const type = (req.query.type || "all").toString();
  const tag = (req.query.tag || "").toString().trim();
  const category = (req.query.category || "").toString().trim();
  let tokenVal = req.query.token;
  if (Array.isArray(tokenVal)) tokenVal = tokenVal[0];
  const token = (tokenVal != null ? String(tokenVal) : "").trim();
  const retryQueued = Math.max(Number(req.query.retryQueued) || 0, 0);
  const eventsCleared = req.query.eventsCleared === "1" || req.query.eventsCleared === "true";

  let lectures = await readLectureDb();

  let hasMutated = false;
  for (const lecture of lectures) {
    if (!Array.isArray(lecture.tags)) {
      lecture.tags = [];
      hasMutated = true;
    }
    const needsMeta =
      lecture.type === "ppt" &&
      (lecture.previewTitle === "미리보기 생성 실패" ||
        !lecture.previewTitle ||
        typeof lecture.slideCount !== "number");
    if (needsMeta) {
      try {
        const filePath = path.join(UPLOAD_DIR, lecture.fileName);
        const ext = path.extname(lecture.fileName || "").toLowerCase();
        const meta = ext === ".pdf" ? await getPdfMeta(filePath) : await getPptxMeta(filePath);
        lecture.previewTitle = meta.previewTitle || (ext === ".pdf" ? "PDF 문서" : "");
        lecture.slideCount = meta.slideCount;
        hasMutated = true;
      } catch {
        lecture.previewTitle = lecture.previewTitle || "미리보기 생성 실패";
        lecture.slideCount = typeof lecture.slideCount === "number" ? lecture.slideCount : 0;
        hasMutated = true;
      }
    }
    if (lecture.type === "ppt") {
      if (ensurePptThumbnailFields(lecture)) hasMutated = true;
      if (lecture.thumbnailStatus === "pending" && lecture.fileName) {
        enqueueThumbnailJob(lecture.id, { force: false, reason: "list-view" });
      }
    }
    if (lecture.type === "video") {
      if (ensureVideoThumbnailFields(lecture)) hasMutated = true;
      if (
        lecture.thumbnailStatus === "pending" &&
        lecture.fileName &&
        ENABLE_VIDEO_THUMBNAIL
      ) {
        enqueueThumbnailJob(lecture.id, { force: false, reason: "list-view" });
      }
    }
  }
  if (hasMutated) await writeLectureDb(lectures);

  /** 학습센터 단일 목록: learning + (구)뉴스 섹션으로 등록된 항목 포함 */
  const inSection = (lec) => lec.listSection === "learning" || lec.listSection === "news";

  const sectionLectures = lectures.filter(inSection);
  const availableTags = [...new Set(sectionLectures.flatMap((lecture) => lecture.tags || []))].sort((a, b) =>
    a.localeCompare(b, "ko-KR")
  );

  const ordered = [...sectionLectures].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
  const qNorm = q ? normalizeLectureSearchText(q) : "";
  const filtered = ordered.filter((lecture) => {
    if (type !== "all" && lecture.type !== type) return false;
    if (tag && !(lecture.tags || []).includes(tag)) return false;
    if (category && !(lecture.tags || []).includes(category)) return false;
    if (!q) return true;
    return lectureMatchesSearchQuery(lecture, qNorm);
  });

  const totalCount = filtered.length;
  const totalPages = Math.max(Math.ceil(totalCount / PAGE_SIZE), 1);
  const currentPage = Math.min(page, totalPages);
  const start = (currentPage - 1) * PAGE_SIZE;
  const pageItems = filtered.slice(start, start + PAGE_SIZE);

  for (const lec of pageItems) {
    const st = lec.thumbnailStatus || "pending";
    if (
      (lec.type === "link" || lec.type === "news") &&
      lec.newsUrl &&
      !lec.thumbnailUrl &&
      st !== "failed" &&
      st !== "ready" &&
      !linkOgPreviewScheduledIds.has(lec.id)
    ) {
      linkOgPreviewScheduledIds.add(lec.id);
      scheduleLinkOgPreviewFetch(lec.id, lec.newsUrl);
    }
  }

  const isValidToken =
    forAdmin && ADMIN_TOKEN.length > 0 && token.length > 0 && token === ADMIN_TOKEN;
  const tokenForQuery = isValidToken ? token : "";

  const makeQueryForPath = (nextPage) => {
    const params = new URLSearchParams();
    if (q) params.set("q", q);
    if (type && type !== "all") params.set("type", type);
    if (tag) params.set("tag", tag);
    if (category) params.set("category", category);
    if (forAdmin && tokenForQuery) params.set("token", tokenForQuery);
    if (nextPage && nextPage > 1) params.set("page", String(nextPage));
    return params.toString();
  };

  const returnQuery = makeQueryForPath(currentPage);
  const prevQuery = makeQueryForPath(currentPage - 1);
  const nextQuery = makeQueryForPath(currentPage + 1);
  const paginationData = {
    page: currentPage,
    totalPages,
    totalCount,
    hasPrev: currentPage > 1,
    hasNext: currentPage < totalPages,
    prevQuery,
    nextQuery,
    pages: Array.from({ length: totalPages }, (_, idx) => {
      const p = idx + 1;
      return { page: p, query: makeQueryForPath(p), active: p === currentPage };
    }),
  };

  const ctx = {
    lectures: pageItems,
    filters: { q: q || "", type: type || "all", tag: tag || "", category: category || "" },
    availableTags,
    pagination: paginationData,
    returnQuery,
    viewerBasePath: options.viewerBasePath || "/learning",
    learningApiPath: options.learningApiPath || "/api/learning/lectures",
    adminRegisterHref: options.adminRegisterHref || "/admin",
    adminBasePath: options.adminBasePath || "/admin",
    navActiveMenu: options.navActiveMenu || "learning",
    pageTitle: options.pageTitle || "학습센터",
    heroTitle: options.heroTitle || "최신 컨텐츠로 학습하고, 바로 업무에 적용하세요.",
    heroDesc:
      options.heroDesc ||
      "유튜브·PPT·웹 링크를 등록한 뒤, 목록에서 클릭하여 강의를 시청하거나 외부 자료를 열 수 있습니다.",
    sectionListHeading: options.sectionListHeading || "등록된 강의",
    filterPanelTitle: options.filterPanelTitle,
    adminPageTitle: options.adminPageTitle || "학습센터 관리",
  };

  if (forAdmin) {
    ctx.adminMode = isValidToken;
    ctx.adminRequested = !!token;
    ctx.tokenRaw = isValidToken ? token : "";
    ctx.retryQueued = retryQueued;
    ctx.eventsCleared = eventsCleared;
    ctx.recentEvents = [...(await readThumbnailEventDb())].reverse().slice(0, 12);
    const events = await readThumbnailEventDb();
    ctx.failureReasons = Object.values(
      events.reduce((acc, evt) => {
        if (evt.type !== "failed" || !evt.error) return acc;
        const key = evt.error;
        if (!acc[key]) acc[key] = { reason: key, count: 0, latestAt: evt.at };
        acc[key].count += 1;
        if (new Date(evt.at).getTime() > new Date(acc[key].latestAt).getTime()) acc[key].latestAt = evt.at;
        return acc;
      }, {})
    ).sort((a, b) => b.count - a.count);
    ctx.thumbnailStatusSummary = sectionLectures.reduce(
      (acc, l) => {
        if (l.type !== "ppt") return acc;
        const s = l.thumbnailStatus || "pending";
        if (s === "ready") acc.ready += 1;
        else if (s === "processing") acc.processing += 1;
        else if (s === "failed") acc.failed += 1;
        else acc.pending += 1;
        return acc;
      },
      { ready: 0, processing: 0, failed: 0, pending: 0 }
    );
    ctx.thumbnailQueueInfo = { pending: thumbnailQueue.length, working: thumbnailWorkerRunning, maxRetry: THUMBNAIL_MAX_RETRY };
  }

  ctx.opsUserEmail = !!(req.res && req.res.locals && req.res.locals.opsUserEmail);

  return ctx;
};

app.post("/api/admin/validate-token", (req, res) => {
  const token = (req.body?.token ?? "").toString().trim();
  const valid =
    ADMIN_TOKEN.length > 0 &&
    token.length > 0 &&
    token === ADMIN_TOKEN;
  res.json({ valid: !!valid });
});

app.get("/", (req, res) => res.redirect("/learning"));

app.get("/learning", async (req, res) => {
  const ctx = await buildLectureListContext(req, { basePath: "/learning", forAdmin: false });
  res.render("learning-viewer", ctx);
});

/** 구 URL 호환 */
app.get("/newsletter", (req, res) => {
  res.redirect(302, "/learning");
});
app.get("/newsletter/admin", (req, res) => {
  const q = req.url.includes("?") ? req.url.slice(req.url.indexOf("?")) : "";
  res.redirect(302, "/admin" + q);
});

app.get("/api/learning/lectures", async (req, res) => {
  try {
    const ctx = await buildLectureListContext(req, { basePath: "/learning", forAdmin: false });
    res.render("learning-lectures-partial", ctx, (err, html) => {
      if (err) return res.status(500).json({ error: err?.message || "렌더링 실패" });
      res.json({
        html: html || "",
        hasNext: ctx.pagination?.hasNext || false,
        nextPage: (ctx.pagination?.page || 1) + 1,
        totalCount: ctx.pagination?.totalCount ?? 0,
      });
    });
  } catch (err) {
    res.status(500).json({ error: err?.message || "요청 처리 실패" });
  }
});

app.get("/admin/logout", (req, res) => {
  res.clearCookie(ADMIN_COOKIE_NAME, { path: "/" });
  res.redirect("/learning");
});

/** 관리자: OPS 이메일 인증 로그인 사용자 목록(이메일·최근 접속일) */
app.get("/admin/users", async (req, res) => {
  const token = (req.query.token != null ? String(req.query.token) : "").trim();
  const cookieToken = (req.cookies?.[ADMIN_COOKIE_NAME] || "").trim();
  const isValidUrlToken =
    ADMIN_TOKEN.length > 0 && token.length > 0 && token === ADMIN_TOKEN;
  const isValidCookie =
    ADMIN_TOKEN.length > 0 && cookieToken.length > 0 && cookieToken === ADMIN_TOKEN;

  if (isValidUrlToken) {
    res.cookie(ADMIN_COOKIE_NAME, token, {
      httpOnly: true,
      maxAge: ADMIN_COOKIE_MAX_AGE,
      path: "/",
    });
  } else if (req.cookies?.[ADMIN_COOKIE_NAME] && !isValidCookie) {
    res.clearCookie(ADMIN_COOKIE_NAME, { path: "/" });
  }

  const allowed = isValidUrlToken || isValidCookie;
  if (!allowed) {
    return res.redirect("/learning");
  }

  let users = [];
  let dbError = null;
  const pgConnected = ENABLE_POSTGRES && !!pgPool;
  try {
    if (pgPool) {
      const r = await pgPool.query(
        `SELECT email, last_login_at, first_seen_at, login_count, sessions_revoked_at
         FROM ops_email_users
         ORDER BY last_login_at DESC NULLS LAST`
      );
      users = (r.rows || []).map((row) => ({
        email: row.email,
        lastLoginAt: row.last_login_at,
        firstSeenAt: row.first_seen_at,
        loginCount: row.login_count,
        sessionsRevokedAt: row.sessions_revoked_at,
      }));
    }
  } catch (err) {
    console.error("[admin/users]", err);
    dbError = err?.message || "조회 실패";
  }

  res.render("admin-users", {
    adminMode: true,
    activeMenu: "admin-users",
    users,
    dbError,
    pgConnected,
  });
});

/** 관리자: 특정 OPS 사용자의 모든 디바이스 세션 무효화(전체 로그아웃) */
app.post("/api/admin/users/revoke-sessions", async (req, res) => {
  if (!res.locals.adminMode) {
    return res.status(403).json({ error: "관리자만 세션을 만료시킬 수 있습니다." });
  }

  const email = String(req.body?.email || "")
    .trim()
    .toLowerCase()
    .slice(0, 320);
  if (!email || !email.endsWith("@ncue.net")) {
    return res.status(400).json({ error: "유효한 @ncue.net 이메일이 필요합니다." });
  }

  try {
    const { revokedAtMs } = await revokeAllOpsSessionsForEmail(pgPool, DATA_DIR, email);
    await recordOpsEmailAuthEvent({
      email,
      eventType: "sessions_revoked",
      req,
    });
    res.json({
      ok: true,
      email,
      revokedAt: new Date(revokedAtMs).toISOString(),
    });
  } catch (err) {
    console.error("[admin/revoke-sessions]", err);
    res.status(500).json({ error: err?.message || "세션 만료 처리 실패" });
  }
});

app.get("/admin", async (req, res) => {
  const ctx = await buildLectureListContext(req, {
    basePath: "/admin",
    forAdmin: true,
    adminBasePath: "/admin",
    viewerBasePath: "/learning",
    navActiveMenu: "learning",
  });
  const token = (req.query.token != null ? String(req.query.token) : "").trim();
  const cookieToken = (req.cookies?.[ADMIN_COOKIE_NAME] || "").trim();
  const isValidUrlToken =
    ADMIN_TOKEN.length > 0 && token.length > 0 && token === ADMIN_TOKEN;
  const isValidCookie =
    ADMIN_TOKEN.length > 0 && cookieToken.length > 0 && cookieToken === ADMIN_TOKEN;

  if (isValidUrlToken) {
    res.cookie(ADMIN_COOKIE_NAME, token, {
      httpOnly: true,
      maxAge: ADMIN_COOKIE_MAX_AGE,
      path: "/",
    });
  } else if (isValidCookie) {
    ctx.adminMode = true;
    ctx.tokenRaw = cookieToken;
  } else if (req.cookies?.[ADMIN_COOKIE_NAME]) {
    res.clearCookie(ADMIN_COOKIE_NAME, { path: "/" });
    res.locals.adminMode = false;
  }
  res.render("learning-admin", ctx);
});

app.post("/lectures/youtube", async (req, res) => {
  const token = (req.body.token || "").toString().trim();
  if (token !== ADMIN_TOKEN) {
    res.status(403).send("관리자 토큰이 필요합니다.");
    return;
  }
  const { title, description, youtubeUrl, tags, category } = req.body;
  if (!title?.trim() || !youtubeUrl?.trim()) {
    res.status(400).send("제목과 유튜브 링크는 필수입니다.");
    return;
  }
  if (!isYoutubeUrl(youtubeUrl)) {
    res.status(400).send("유효한 유튜브 링크를 입력해주세요.");
    return;
  }

  const lectures = await readLectureDb();
  lectures.push({
    id: uuidv4(),
    type: "youtube",
    listSection: "learning",
    title: title.trim(),
    description: (description || "").trim(),
    tags: mergeTagsWithCategory(tags, category),
    youtubeUrl: youtubeUrl.trim(),
    createdAt: new Date().toISOString(),
  });
  await writeLectureDb(lectures);
  const returnTo = (req.body.returnTo || "").toString().trim();
  res.redirect(returnTo.startsWith("/") ? returnTo : "/admin");
});

app.post("/lectures/ppt", upload.single("pptFile"), async (req, res) => {
  const token = (req.body.token || "").toString().trim();
  if (token !== ADMIN_TOKEN) {
    res.status(403).send("관리자 토큰이 필요합니다.");
    return;
  }
  const { title, description, tags, category } = req.body;
  if (!req.file) {
    res.status(400).send("PDF 또는 PPT 파일이 필요합니다.");
    return;
  }
  if (!title?.trim()) {
    res.status(400).send("제목은 필수입니다.");
    return;
  }

  const filePath = path.join(UPLOAD_DIR, req.file.filename);
  const ext = path.extname(req.file.filename).toLowerCase();
  let pptMeta = { previewTitle: "", slideCount: 0 };
  try {
    if (ext === ".pdf") {
      pptMeta = await getPdfMeta(filePath);
    } else {
      pptMeta = await getPptxMeta(filePath);
    }
  } catch {
    // Keep defaults; upload still succeeds even if metadata parsing fails.
  }

  const lectures = await readLectureDb();
  const lectureId = uuidv4();
  lectures.push({
    id: lectureId,
    type: "ppt",
    listSection: "learning",
    title: title.trim(),
    description: (description || "").trim(),
    tags: mergeTagsWithCategory(tags, category),
    fileName: req.file.filename,
    originalName: req.file.originalname,
    previewTitle: pptMeta.previewTitle,
    slideCount: pptMeta.slideCount,
    thumbnailUrl: null,
    thumbnailStatus: "pending",
    thumbnailRetryCount: 0,
    thumbnailError: null,
    thumbnailUpdatedAt: new Date().toISOString(),
    createdAt: new Date().toISOString(),
  });
  await writeLectureDb(lectures);
  enqueueThumbnailJob(lectureId, { force: false, reason: "upload" });

  if (ENABLE_PPT_THUMBNAIL) {
    generateSlideImages(filePath, lectureId).catch((err) => {
      console.error("[slide-images] 업로드 후 생성 실패:", err?.message || err);
    });
  }

  const returnTo = (req.body.returnTo || "").toString().trim();
  res.redirect(returnTo.startsWith("/") ? returnTo : "/admin");
});

app.post(
  "/lectures/video",
  (req, res, next) => {
    uploadVideo.single("videoFile")(req, res, (err) => {
      if (err) {
        res.status(400).send(err.message || "동영상 업로드에 실패했습니다.");
        return;
      }
      next();
    });
  },
  async (req, res) => {
    const token = (req.body.token || "").toString().trim();
    if (token !== ADMIN_TOKEN) {
      res.status(403).send("관리자 토큰이 필요합니다.");
      return;
    }
    const { title, description, tags, category } = req.body;
    if (!req.file) {
      res.status(400).send("동영상 파일이 필요합니다.");
      return;
    }
    if (!title?.trim()) {
      res.status(400).send("제목은 필수입니다.");
      return;
    }

    const lectures = await readLectureDb();
    const lectureId = uuidv4();
    const createdAt = new Date().toISOString();
    lectures.push({
      id: lectureId,
      type: "video",
      listSection: "learning",
      title: title.trim(),
      description: (description || "").trim(),
      tags: mergeTagsWithCategory(tags, category),
      fileName: req.file.filename,
      originalName: req.file.originalname,
      previewTitle: null,
      slideCount: 0,
      youtubeUrl: null,
      thumbnailUrl: null,
      thumbnailStatus: ENABLE_VIDEO_THUMBNAIL ? "pending" : "failed",
      thumbnailRetryCount: 0,
      thumbnailError: ENABLE_VIDEO_THUMBNAIL ? null : "동영상 썸네일 비활성화(ENABLE_VIDEO_THUMBNAIL=0)",
      thumbnailUpdatedAt: createdAt,
      createdAt,
    });
    await writeLectureDb(lectures);
    if (ENABLE_VIDEO_THUMBNAIL) {
      enqueueThumbnailJob(lectureId, { force: false, reason: "upload" });
    }

    const returnTo = (req.body.returnTo || "").toString().trim();
    res.redirect(returnTo.startsWith("/") ? returnTo : "/admin");
  }
);

/** 학습센터: 유튜브가 아닌 일반 http(s) 링크를 카드로 등록 (news_url 컬럼 사용) */
app.post("/lectures/link", async (req, res) => {
  const token = (req.body.token || "").toString().trim();
  if (token !== ADMIN_TOKEN) {
    res.status(403).send("관리자 토큰이 필요합니다.");
    return;
  }
  const { title, description, tags, category } = req.body;
  const linkUrl = (req.body.linkUrl || "").toString().trim();
  if (!title?.trim() || !linkUrl) {
    res.status(400).send("제목과 URL은 필수입니다.");
    return;
  }
  if (!isHttpUrl(linkUrl)) {
    res.status(400).send("유효한 http(s) URL을 입력해주세요.");
    return;
  }
  if (isYoutubeUrl(linkUrl)) {
    res.status(400).send("유튜브 링크는 «유튜브 강의 등록»을 이용해주세요.");
    return;
  }

  const lectures = await readLectureDb();
  const lectureId = uuidv4();
  const createdAt = new Date().toISOString();
  lectures.push({
    id: lectureId,
    type: "link",
    listSection: "learning",
    title: title.trim(),
    description: (description || "").trim(),
    tags: mergeTagsWithCategory(tags, category),
    newsUrl: linkUrl,
    thumbnailUrl: null,
    thumbnailStatus: "pending",
    thumbnailRetryCount: 0,
    thumbnailError: null,
    thumbnailUpdatedAt: createdAt,
    createdAt,
  });
  await writeLectureDb(lectures);
  scheduleLinkOgPreviewFetch(lectureId, linkUrl);
  const returnTo = (req.body.returnTo || "").toString().trim();
  res.redirect(returnTo.startsWith("/") ? returnTo : "/admin");
});

app.post("/lectures/:id/delete", async (req, res) => {
  const { token, returnTo } = req.body;
  if ((token || "").trim() !== ADMIN_TOKEN) {
    res.status(403).send("관리자 토큰이 유효하지 않습니다.");
    return;
  }

  const lectures = await readLectureDb();
  const index = lectures.findIndex((item) => item.id === req.params.id);
  if (index < 0) {
    res.status(404).send("삭제할 강의를 찾을 수 없습니다.");
    return;
  }

  const target = lectures[index];
  await removeQueuedThumbnailJobs(target.id);
  const storedFileTypes = ["ppt", "video"];
  if (storedFileTypes.includes(target.type) && target.fileName) {
    const filePath = path.join(UPLOAD_DIR, target.fileName);
    try {
      await fs.unlink(filePath);
    } catch {
      // Ignore file delete failures to keep metadata consistent.
    }
    if (target.type === "ppt" || target.type === "video") {
      if (target.thumbnailUrl) {
        const thumbPath = path.join(ROOT_DIR, target.thumbnailUrl.replace(/^\//, ""));
        try {
          await fs.unlink(thumbPath);
        } catch {
          // Ignore thumbnail delete failures.
        }
      }
      if (target.type === "ppt") {
        const slidesDir = path.join(SLIDES_DIR, target.id);
        try {
          await fs.rm(slidesDir, { recursive: true, force: true });
        } catch {
          // Ignore slide images delete failures.
        }
      }
    }
  }

  lectures.splice(index, 1);
  await writeLectureDb(lectures);

  const safeReturn = (returnTo || "").toString().trim();
  if (safeReturn.startsWith("/")) {
    res.redirect(safeReturn);
    return;
  }
  if (safeReturn.startsWith("?")) {
    res.redirect(`/admin${safeReturn}`);
    return;
  }
  res.redirect("/admin");
});

app.post("/lectures/:id/thumbnail/regenerate", async (req, res) => {
  const { token, returnTo } = req.body;
  if ((token || "").trim() !== ADMIN_TOKEN) {
    res.status(403).send("관리자 토큰이 유효하지 않습니다.");
    return;
  }

  const lectures = await readLectureDb();
  const lecture = lectures.find((item) => item.id === req.params.id);
  if (!lecture) {
    res.status(404).send("강의를 찾을 수 없습니다.");
    return;
  }
  if ((lecture.type !== "ppt" && lecture.type !== "video") || !lecture.fileName) {
    res.status(400).send("PDF/PPT·업로드 동영상만 썸네일 재생성이 가능합니다.");
    return;
  }

  if (lecture.type === "ppt") {
    ensurePptThumbnailFields(lecture);
  } else {
    ensureVideoThumbnailFields(lecture);
  }
  lecture.thumbnailStatus = "pending";
  lecture.thumbnailError = null;
  lecture.thumbnailRetryCount = 0;
  lecture.thumbnailUpdatedAt = new Date().toISOString();
  await writeLectureDb(lectures);
  enqueueThumbnailJob(lecture.id, { force: true, reason: "manual-regenerate" });

  const safeReturn = (returnTo || "").toString().trim();
  if (safeReturn.startsWith("/")) {
    res.redirect(safeReturn);
    return;
  }
  if (safeReturn.startsWith("?")) {
    res.redirect(`/admin${safeReturn}`);
    return;
  }
  res.redirect("/admin");
});

app.post("/lectures/:id/slides/regenerate", async (req, res) => {
  const { token, returnTo } = req.body;
  if ((token || "").trim() !== ADMIN_TOKEN) {
    res.status(403).send("관리자 토큰이 유효하지 않습니다.");
    return;
  }

  const lectures = await readLectureDb();
  const lecture = lectures.find((item) => item.id === req.params.id);
  if (!lecture) {
    res.status(404).send("강의를 찾을 수 없습니다.");
    return;
  }
  if (lecture.type !== "ppt" || !lecture.fileName) {
    res.status(400).send("PDF/PowerPoint 강의만 슬라이드 이미지 재생성이 가능합니다.");
    return;
  }

  const filePath = path.join(UPLOAD_DIR, lecture.fileName);
  if (!fsSync.existsSync(filePath)) {
    res.status(400).send("원본 파일을 찾을 수 없습니다.");
    return;
  }

  const outDir = path.join(SLIDES_DIR, lecture.id);
  try {
    await fs.rm(outDir, { recursive: true, force: true });
  } catch {
    // Ignore
  }

  const urls = await generateSlideImages(filePath, lecture.id);
  if (urls && urls.length > 0) {
    res.redirect(`/lectures/${lecture.id}`);
  } else {
    res.redirect(`/lectures/${lecture.id}?slidesError=1`);
  }
});

app.post("/thumbnails/retry-failed", async (req, res) => {
  const { token, returnTo } = req.body;
  if ((token || "").trim() !== ADMIN_TOKEN) {
    res.status(403).send("관리자 토큰이 유효하지 않습니다.");
    return;
  }

  const lectures = await readLectureDb();
  let queued = 0;
  for (const lecture of lectures) {
    if (lecture.type === "ppt") {
      ensurePptThumbnailFields(lecture);
    } else if (lecture.type === "video") {
      ensureVideoThumbnailFields(lecture);
    } else {
      continue;
    }
    if (lecture.thumbnailStatus === "failed") {
      lecture.thumbnailStatus = "pending";
      lecture.thumbnailError = null;
      lecture.thumbnailRetryCount = 0;
      lecture.thumbnailUpdatedAt = new Date().toISOString();
      if (enqueueThumbnailJob(lecture.id, { force: true, reason: "bulk-retry" })) {
        queued += 1;
      }
    }
  }
  await writeLectureDb(lectures);

  const safeReturn = (returnTo || "").toString().trim();
  if (safeReturn.startsWith("/")) {
    const sep = safeReturn.includes("?") ? "&" : "?";
    res.redirect(`${safeReturn}${sep}retryQueued=${queued}`);
    return;
  }
  if (safeReturn.startsWith("?")) {
    const joiner = safeReturn.length > 1 ? "&" : "";
    res.redirect(`/admin${safeReturn}${joiner}retryQueued=${queued}`);
    return;
  }
  res.redirect(`/admin?retryQueued=${queued}`);
});

app.post("/thumbnails/events/clear", async (req, res) => {
  const { token, returnTo } = req.body;
  if ((token || "").trim() !== ADMIN_TOKEN) {
    res.status(403).send("관리자 토큰이 유효하지 않습니다.");
    return;
  }

  await writeThumbnailEventDb([]);

  const safeReturn = (returnTo || "").toString().trim();
  if (safeReturn.startsWith("/")) {
    const sep = safeReturn.includes("?") ? "&" : "?";
    res.redirect(`${safeReturn}${sep}eventsCleared=1`);
    return;
  }
  if (safeReturn.startsWith("?")) {
    const joiner = safeReturn.length > 1 ? "&" : "";
    res.redirect(`/admin${safeReturn}${joiner}eventsCleared=1`);
    return;
  }
  res.redirect("/admin?eventsCleared=1");
});

app.get("/admin/thumbnail-events", async (req, res) => {
  const token = (req.query.token || "").toString();
  if (token !== ADMIN_TOKEN) {
    res.status(403).send("관리자 토큰이 유효하지 않습니다.");
    return;
  }

  const page = Math.max(Number(req.query.page) || 1, 1);
  const limit = Math.min(Math.max(Number(req.query.limit) || THUMBNAIL_EVENT_PAGE_SIZE, 10), 200);
  const filter = buildEventFilter(req.query);

  const events = await readThumbnailEventDb();
  const filtered = [...events].reverse().filter(filter.matches);
  const totalCount = filtered.length;
  const totalPages = Math.max(Math.ceil(totalCount / limit), 1);
  const currentPage = Math.min(page, totalPages);
  const start = (currentPage - 1) * limit;
  const pageItems = filtered.slice(start, start + limit);

  const makeQuery = (nextPage) => {
    const params = new URLSearchParams();
    params.set("token", token);
    if (filter.eventType !== "all") params.set("eventType", filter.eventType);
    if (filter.lectureId) params.set("lectureId", filter.lectureId);
    if (filter.reason) params.set("reason", filter.reason);
    if (filter.from) params.set("from", filter.from);
    if (filter.to) params.set("to", filter.to);
    if (limit !== THUMBNAIL_EVENT_PAGE_SIZE) params.set("limit", String(limit));
    if (nextPage > 1) params.set("page", String(nextPage));
    return params.toString();
  };

  const pagination = {
    page: currentPage,
    totalPages,
    totalCount,
    hasPrev: currentPage > 1,
    hasNext: currentPage < totalPages,
    prevQuery: makeQuery(currentPage - 1),
    nextQuery: makeQuery(currentPage + 1),
    pages: Array.from({ length: totalPages }, (_, idx) => {
      const p = idx + 1;
      return { page: p, query: makeQuery(p), active: p === currentPage };
    }),
  };

  const csvQuery = (() => {
    const params = new URLSearchParams(makeQuery(1));
    params.delete("page");
    return params.toString();
  })();

  const eventsCleared = req.query.eventsCleared === "1" || req.query.eventsCleared === "true";

  res.render("admin-thumbnail-events", {
    token,
    filters: {
      eventType: filter.eventType,
      lectureId: filter.lectureId,
      reason: filter.reason,
      from: filter.from,
      to: filter.to,
      limit,
    },
    events: pageItems,
    pagination,
    csvQuery,
    eventsCleared,
  });
});

app.get("/admin/thumbnail-events.csv", async (req, res) => {
  const token = (req.query.token || "").toString();
  if (token !== ADMIN_TOKEN) {
    res.status(403).send("관리자 토큰이 유효하지 않습니다.");
    return;
  }

  const filter = buildEventFilter(req.query);
  const events = await readThumbnailEventDb();
  const filtered = [...events].reverse().filter(filter.matches);

  const header = ["id", "at", "type", "lectureId", "lectureTitle", "reason", "force", "retryCount", "durationMs", "error"];
  const rows = filtered.map((evt) =>
    [
      evt.id,
      evt.at,
      evt.type,
      evt.lectureId || "",
      evt.lectureTitle || "",
      evt.reason || "",
      evt.force === true ? "true" : "false",
      evt.retryCount ?? "",
      evt.durationMs ?? "",
      evt.error || "",
    ]
      .map(escapeCsv)
      .join(",")
  );

  const csv = [header.join(","), ...rows].join("\n");
  res.setHeader("Content-Type", "text/csv; charset=utf-8");
  res.setHeader("Content-Disposition", `attachment; filename="thumbnail-events-${Date.now()}.csv"`);
  res.send(csv);
});

app.get("/api/queue/metrics", async (req, res) => {
  const token = (req.query.token || "").toString();
  if (token !== ADMIN_TOKEN) {
    res.status(403).json({ message: "관리자 토큰이 유효하지 않습니다." });
    return;
  }

  const lectures = await readLectureDb();
  const events = await readThumbnailEventDb();
  const pptLectures = lectures.filter((lecture) => lecture.type === "ppt");
  const statusCounts = pptLectures.reduce(
    (acc, lecture) => {
      const status = lecture.thumbnailStatus || "pending";
      if (status === "ready") acc.ready += 1;
      else if (status === "processing") acc.processing += 1;
      else if (status === "failed") acc.failed += 1;
      else acc.pending += 1;
      return acc;
    },
    { ready: 0, processing: 0, pending: 0, failed: 0 }
  );

  const recentFailures = [...events]
    .filter((evt) => evt.type === "failed")
    .reverse()
    .slice(0, 10)
    .map((evt) => ({
      at: evt.at,
      lectureId: evt.lectureId,
      lectureTitle: evt.lectureTitle || null,
      reason: evt.reason || null,
      error: evt.error || null,
      retryCount: evt.retryCount || 0,
    }));

  res.json({
    queue: {
      pending: thumbnailQueue.length,
      working: thumbnailWorkerRunning,
      queuedLectureIds: [...queuedLectureIds],
    },
    statusCounts,
    config: {
      maxRetry: THUMBNAIL_MAX_RETRY,
      retryDelayMs: THUMBNAIL_RETRY_DELAY_MS,
      eventKeep: THUMBNAIL_EVENT_KEEP,
      thumbnailEnabled: ENABLE_PPT_THUMBNAIL,
    },
    recentFailures,
    generatedAt: new Date().toISOString(),
  });
});

app.get("/api/queue/events-summary", async (req, res) => {
  const token = (req.query.token || "").toString();
  if (token !== ADMIN_TOKEN) {
    res.status(403).json({ message: "관리자 토큰이 유효하지 않습니다." });
    return;
  }

  const hours = Math.min(Math.max(Number(req.query.hours) || 24, 1), 168);
  const now = Date.now();
  const startMs = now - hours * 60 * 60 * 1000;

  const events = await readThumbnailEventDb();
  const scoped = events.filter((evt) => {
    const atMs = new Date(evt.at || "").getTime();
    return Number.isFinite(atMs) && atMs >= startMs;
  });

  const processed = scoped.filter((evt) => evt.type === "success" || evt.type === "failed");
  const successCount = processed.filter((evt) => evt.type === "success").length;
  const failedCount = processed.filter((evt) => evt.type === "failed").length;
  const processedCount = processed.length;
  const failureRate = processedCount > 0 ? failedCount / processedCount : 0;

  const durations = processed
    .map((evt) => Number(evt.durationMs))
    .filter((ms) => Number.isFinite(ms) && ms >= 0);
  const avgDurationMs = durations.length
    ? Math.round(durations.reduce((sum, ms) => sum + ms, 0) / durations.length)
    : 0;

  const bucketMap = new Map();
  for (let i = 0; i < hours; i += 1) {
    const bucketStart = startMs + i * 60 * 60 * 1000;
    const key = new Date(bucketStart).toISOString().slice(0, 13);
    bucketMap.set(key, {
      key,
      label: `${new Date(bucketStart).getHours()}시`,
      processed: 0,
      success: 0,
      failed: 0,
    });
  }
  for (const evt of processed) {
    const atMs = new Date(evt.at || "").getTime();
    if (!Number.isFinite(atMs) || atMs < startMs) continue;
    const key = new Date(atMs).toISOString().slice(0, 13);
    const bucket = bucketMap.get(key);
    if (!bucket) continue;
    bucket.processed += 1;
    if (evt.type === "success") bucket.success += 1;
    if (evt.type === "failed") bucket.failed += 1;
  }

  const buckets = [...bucketMap.values()];
  const queue = {
    pending: thumbnailQueue.length,
    working: thumbnailWorkerRunning,
  };

  res.json({
    windowHours: hours,
    generatedAt: new Date().toISOString(),
    kpi: {
      processedCount,
      successCount,
      failedCount,
      failureRate,
      avgDurationMs,
    },
    queue,
    buckets,
  });
});

app.get("/lectures/:id/edit", async (req, res) => {
  const queryToken = (req.query.token || "").toString().trim();
  const cookieToken = (req.cookies?.[ADMIN_COOKIE_NAME] || "").toString().trim();
  const isValidQuery = ADMIN_TOKEN.length > 0 && queryToken === ADMIN_TOKEN;
  const isValidCookie = ADMIN_TOKEN.length > 0 && cookieToken.length > 0 && cookieToken === ADMIN_TOKEN;
  if (!isValidQuery && !isValidCookie) {
    res.status(403).send("관리자 토큰이 필요합니다.");
    return;
  }
  const tokenRaw = isValidQuery ? queryToken : cookieToken;
  const lectures = await readLectureDb();
  const lecture = lectures.find((item) => item.id === req.params.id);
  if (!lecture) {
    res.status(404).send("강의를 찾을 수 없습니다.");
    return;
  }
  const returnQuery = new URLSearchParams(req.query).toString();
  res.render("learning-edit", {
    lecture,
    tokenRaw,
    returnQuery: returnQuery || (tokenRaw ? `token=${encodeURIComponent(tokenRaw)}` : ""),
    adminBasePath: "/admin",
    viewerBasePath: "/learning",
    navActiveMenu: "learning",
  });
});

app.post("/lectures/:id/update", async (req, res) => {
  const { token, returnTo, title, description, tags, category } = req.body;
  const youtubeUrl = req.body.youtubeUrl;
  if ((token || "").trim() !== ADMIN_TOKEN) {
    res.status(403).send("관리자 토큰이 유효하지 않습니다.");
    return;
  }
  const lectures = await readLectureDb();
  const index = lectures.findIndex((item) => item.id === req.params.id);
  if (index < 0) {
    res.status(404).send("수정할 강의를 찾을 수 없습니다.");
    return;
  }
  const lecture = lectures[index];
  if (!title?.trim()) {
    res.status(400).send("제목은 필수입니다.");
    return;
  }
  if (lecture.type === "youtube") {
    if (!youtubeUrl?.trim()) {
      res.status(400).send("유튜브 링크는 필수입니다.");
      return;
    }
    if (!isYoutubeUrl(youtubeUrl)) {
      res.status(400).send("유효한 유튜브 링크를 입력해주세요.");
      return;
    }
    lecture.youtubeUrl = youtubeUrl.trim();
  }
  let scheduleOgAfterWrite = false;
  if (lecture.type === "news" || lecture.type === "link") {
    const nu = (req.body.newsUrl || "").toString().trim();
    if (!nu) {
      res.status(400).send(lecture.type === "link" ? "URL은 필수입니다." : "뉴스 URL은 필수입니다.");
      return;
    }
    if (!isHttpUrl(nu)) {
      res.status(400).send("유효한 http(s) URL을 입력해주세요.");
      return;
    }
    if (lecture.type === "link" && isYoutubeUrl(nu)) {
      res.status(400).send("유튜브 링크는 «유튜브 강의 등록»을 이용해주세요.");
      return;
    }
    const prevUrl = (lecture.newsUrl || "").trim();
    lecture.newsUrl = nu;
    if (prevUrl !== nu) {
      scheduleOgAfterWrite = true;
      lecture.thumbnailUrl = null;
      lecture.thumbnailStatus = "pending";
      lecture.thumbnailError = null;
      lecture.thumbnailUpdatedAt = new Date().toISOString();
    }
  }
  lecture.title = title.trim();
  lecture.description = (description || "").trim();
  lecture.tags = mergeTagsWithCategory(tags, category);
  await writeLectureDb(lectures);
  if (scheduleOgAfterWrite && (lecture.type === "news" || lecture.type === "link")) {
    const nu = (lecture.newsUrl || "").trim();
    if (nu && isHttpUrl(nu) && !(lecture.type === "link" && isYoutubeUrl(nu))) {
      scheduleLinkOgPreviewFetch(lecture.id, nu);
    }
  }
  const safeReturn = (returnTo || "").toString().trim();
  res.redirect(safeReturn.startsWith("/") ? safeReturn : `/admin?${safeReturn || ""}`);
});

app.get("/lectures/:id", async (req, res) => {
  const lectures = await readLectureDb();
  const lecture = lectures.find((item) => item.id === req.params.id);

  if (!lecture) {
    res.status(404).send("강의를 찾을 수 없습니다.");
    return;
  }

  if (lecture.type === "youtube") {
    res.render("lecture-youtube", {
      lecture,
      embedUrl: toYoutubeEmbedUrl(lecture.youtubeUrl),
    });
    return;
  }

  if (lecture.type === "news" || lecture.type === "link") {
    const nu = (lecture.newsUrl || "").trim();
    if (nu && isHttpUrl(nu)) {
      res.redirect(302, nu);
      return;
    }
    res.render(lecture.type === "news" ? "lecture-news" : "lecture-link", { lecture });
    return;
  }

  if (lecture.type === "video" && lecture.fileName) {
    const filePath = path.join(UPLOAD_DIR, lecture.fileName);
    if (!fsSync.existsSync(filePath)) {
      res.status(404).send("동영상 파일을 찾을 수 없습니다.");
      return;
    }
    const videoSrc = `/uploads/${path.basename(lecture.fileName)}`;
    res.render("lecture-video", { lecture, videoSrc });
    return;
  }

  const filePath = path.join(UPLOAD_DIR, lecture.fileName);
  const ext = path.extname(lecture.fileName || "").toLowerCase();
  let slides =
    ext === ".pdf"
      ? await parsePdfSlides(filePath)
      : await parsePptxSlides(filePath);
  const slideImageUrls = getSlideImageUrls(lecture.id);

  if (slides.length === 0 && slideImageUrls.length > 0) {
    slides = slideImageUrls.map(() => ({ title: "", lines: [] }));
  }

  res.render("lecture-ppt", {
    lecture,
    slides,
    slideImageUrls: slideImageUrls || [],
    slidesError: req.query.slidesError === "1",
  });
});

app.use((req, res) => {
  res.status(404).send(`페이지를 찾을 수 없습니다: ${req.method} ${req.path}`);
});

app.use((error, _, res, __) => {
  res.status(400).send(error.message || "요청 처리 중 오류가 발생했습니다.");
});

ensureBootstrap()
  .then(() => {
    app.listen(PORT, HOST, () => {
      const hint = HOST === "0.0.0.0" || HOST === "::" ? "localhost" : HOST;
      console.log(`Server started: http://${hint}:${PORT} (listening on ${HOST}:${PORT})`);
    });
  })
  .catch((error) => {
    console.error("Failed to bootstrap:", error);
    process.exit(1);
  });
