import express from 'express';
import multer from 'multer';
import { WebSocketServer } from 'ws';
import http from 'http';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { randomUUID, scryptSync, randomBytes, timingSafeEqual } from 'crypto';
import { exec } from 'child_process';
import net from 'net';

import { load, getDb, save, saveNow } from './store.js';
import { buildDirective, directiveKey } from './scheduler.js';
import { createRemoteClient } from './remote-client.js';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, '..');
const PUBLIC_DIR = path.join(ROOT, 'public');
const MEDIA_DIR = path.join(ROOT, 'media');
const BASE_PORT = Number(process.env.PORT) || 4000;
const MAX_PORT_TRIES = 20;         // 포트가 사용 중이면 다음 포트로 자동 이동
const AUTO_OPEN = process.env.NO_OPEN !== '1'; // 시작 시 브라우저 자동 열기

if (!fs.existsSync(MEDIA_DIR)) fs.mkdirSync(MEDIA_DIR, { recursive: true });

// 재생 이력(proof-of-play) 로그
const DATA_DIR = path.join(ROOT, 'data');
const PLAYLOG_PATH = path.join(DATA_DIR, 'playlog.jsonl');
const PLAYLOG = [];        // 최근 이력(메모리)
let lastLogKey = '';
let lastLogAt = 0;

// 앱 버전 (원격 상태 표시에 사용)
let APP_VERSION = '0.1.0';
try { APP_VERSION = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf-8')).version || APP_VERSION; } catch {}
const STARTED_AT = Date.now();

// 최근 로그 링버퍼 (원격에서 로그 조회용)
const LOG_BUFFER = [];
function pushLog(kind, args) {
  const line = `[${new Date().toISOString()}] ${kind} ${args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ')}`;
  LOG_BUFFER.push(line);
  if (LOG_BUFFER.length > 300) LOG_BUFFER.shift();
}
const _origLog = console.log.bind(console);
const _origErr = console.error.bind(console);
console.log = (...a) => { pushLog('LOG', a); _origLog(...a); };
console.error = (...a) => { pushLog('ERR', a); _origErr(...a); };

load();

// 부팅 시 최근 재생 이력 로드(파일 tail)
function loadPlaylog() {
  try {
    if (!fs.existsSync(PLAYLOG_PATH)) return;
    const lines = fs.readFileSync(PLAYLOG_PATH, 'utf-8').trim().split('\n').slice(-1000);
    for (const ln of lines) { try { PLAYLOG.push(JSON.parse(ln)); } catch {} }
  } catch {}
}
loadPlaylog();

// 재생 이력 기록 (플레이어가 nowplaying 보고 시 호출)
function logPlayback(media) {
  if (!media) return;
  const now = Date.now();
  const key = media.id || media.name || '';
  if (key === lastLogKey && now - lastLogAt < 2000) return; // 중복 억제
  lastLogKey = key; lastLogAt = now;
  const dir = buildDirective(getDb(), new Date());
  const entry = { t: new Date().toISOString(), name: media.name || '', mediaId: media.id || '', kind: media.kind || media.type || '', mode: dir.mode, schedule: dir.scheduleName || '' };
  PLAYLOG.push(entry);
  if (PLAYLOG.length > 1000) PLAYLOG.shift();
  fs.appendFile(PLAYLOG_PATH, JSON.stringify(entry) + '\n', () => {});
}

// ===== 위젯 데이터(날씨/RSS) 주기적 갱신 + directive 보강 =====
let weatherCache = null;          // { temp, code, at }
let rssCache = { text: '', at: 0 };

async function refreshWeather() {
  const w = (getDb().settings.overlay || {}).weather || {};
  if (!w.enabled) { weatherCache = null; return; }
  try {
    const url = `https://api.open-meteo.com/v1/forecast?latitude=${w.lat}&longitude=${w.lon}&current=temperature_2m,weather_code&timezone=auto`;
    const r = await fetch(url, { signal: AbortSignal.timeout(8000) });
    const j = await r.json();
    if (j && j.current) weatherCache = { temp: Math.round(j.current.temperature_2m), code: j.current.weather_code, at: Date.now() };
  } catch {}
}
async function refreshRss() {
  const t = (getDb().settings.overlay || {}).ticker || {};
  if (!(t.enabled && t.source === 'rss' && t.rssUrl)) return;
  try {
    const r = await fetch(t.rssUrl, { signal: AbortSignal.timeout(8000) });
    const xml = await r.text();
    const titles = [];
    // <item>(RSS) / <entry>(Atom) 안의 <title>만 추출 → 채널 제목 제외
    const items = xml.match(/<(item|entry)[\s\S]*?<\/(item|entry)>/gi) || [];
    for (const it of items) {
      if (titles.length >= 15) break;
      const m = it.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
      if (!m) continue;
      const s = m[1].replace(/<!\[CDATA\[|\]\]>/g, '').replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
      if (s) titles.push(s);
    }
    if (titles.length) rssCache = { text: titles.join('    ·    '), at: Date.now() };
  } catch {}
}
function refreshWidgets() { refreshWeather(); refreshRss(); }
setInterval(refreshWidgets, 5 * 60 * 1000);
refreshWidgets();

// directive에 실시간 위젯 데이터(날씨 값 / RSS 티커 문구) 주입
function enrichDirective(d) {
  if (d && d.overlay) {
    const ov = d.overlay;
    if (ov.weather && ov.weather.enabled && weatherCache) {
      d.overlay = { ...d.overlay, weather: { ...ov.weather, temp: weatherCache.temp, code: weatherCache.code } };
    }
    const tk = d.overlay.ticker;
    if (tk && tk.enabled && tk.source === 'rss' && rssCache.text) {
      d.overlay = { ...d.overlay, ticker: { ...tk, text: rssCache.text } };
    }
  }
  return d;
}
function currentDirective() { return enrichDirective(buildDirective(getDb(), new Date())); }

const app = express();
app.use(express.json({ limit: '2mb' }));
app.use(express.static(PUBLIC_DIR));
app.use('/media', express.static(MEDIA_DIR));

// ===== 관리자 로그인 (무조건 필요) =====
// CMS 데이터 API는 조회·변경 모두 로그인 토큰 필요. 플레이어용(상태/스트림/정적)은 개방.
const AUTH_TOKENS = new Set();
function hashPassword(pw) {
  const salt = randomBytes(16).toString('hex');
  const hash = scryptSync(String(pw), salt, 64).toString('hex');
  return { salt, hash };
}
function verifyPassword(pw, salt, hash) {
  if (!salt || !hash) return false;
  const h = scryptSync(String(pw), salt, 64).toString('hex');
  const a = Buffer.from(h, 'hex'), b = Buffer.from(hash, 'hex');
  return a.length === b.length && timingSafeEqual(a, b);
}
function isConfigured() { const a = getDb().auth; return !!(a && a.hash); }
function isAuthed(req) { const t = req.headers['x-auth-token']; return !!(t && AUTH_TOKENS.has(t)); }

// 로그인 없이 접근 가능한 공개 API (플레이어/전광판용) + 인증 엔드포인트
const PUBLIC_API = new Set([
  '/api/state', '/api/auth/status', '/api/auth/setup', '/api/auth/login', '/api/auth/logout',
]);
function isPublicApi(p) {
  if (PUBLIC_API.has(p)) return true;
  if (p.startsWith('/api/stream/')) return true; // 전광판 콘텐츠 스트리밍
  return false;
}
// 정적 파일(로그인 화면 로딩용)은 열고, 그 외 /api/* 는 무조건 토큰 필요
app.use((req, res, next) => {
  if (!req.path.startsWith('/api/')) return next();
  if (isPublicApi(req.path)) return next();
  if (!isAuthed(req)) return res.status(401).json({ error: '로그인이 필요합니다', authRequired: true });
  next();
});

app.get('/api/auth/status', (req, res) => res.json({ configured: isConfigured(), authed: isAuthed(req) }));
// 최초 1회 비밀번호 설정 (아직 미설정일 때만)
app.post('/api/auth/setup', (req, res) => {
  const db = getDb();
  if (isConfigured()) return res.status(403).json({ error: '이미 설정되어 있습니다. 로그인하세요.' });
  const pw = (req.body && req.body.password) || '';
  if (String(pw).length < 4) return res.status(400).json({ error: '비밀번호는 4자 이상이어야 합니다' });
  db.auth = hashPassword(pw); saveNow();
  const t = randomUUID(); AUTH_TOKENS.add(t);
  res.json({ ok: true, token: t });
});
app.post('/api/auth/login', (req, res) => {
  const db = getDb();
  if (!isConfigured()) return res.status(409).json({ error: '초기 설정이 필요합니다', needsSetup: true });
  if (verifyPassword((req.body && req.body.password) || '', db.auth.salt, db.auth.hash)) {
    const t = randomUUID(); AUTH_TOKENS.add(t);
    return res.json({ ok: true, token: t });
  }
  return res.status(401).json({ ok: false, error: '비밀번호가 올바르지 않습니다' });
});
app.post('/api/auth/logout', (req, res) => {
  const t = req.headers['x-auth-token'] || (req.body && req.body.token);
  if (t) AUTH_TOKENS.delete(t);
  res.json({ ok: true });
});
// 비밀번호 변경 (로그인 상태에서만 — 미들웨어가 토큰 요구)
app.post('/api/auth/change-password', (req, res) => {
  const db = getDb(); const b = req.body || {};
  if (!verifyPassword(b.current || '', db.auth.salt, db.auth.hash)) return res.status(401).json({ error: '현재 비밀번호가 올바르지 않습니다' });
  if (String(b.next || '').length < 4) return res.status(400).json({ error: '새 비밀번호는 4자 이상이어야 합니다' });
  db.auth = hashPassword(b.next); saveNow();
  AUTH_TOKENS.clear(); // 다른 세션 로그아웃
  const t = randomUUID(); AUTH_TOKENS.add(t); // 현재 세션 재발급
  res.json({ ok: true, token: t });
});

// ---------- 업로드 (multer) ----------
const storage = multer.diskStorage({
  destination: (req, file, cb) => cb(null, MEDIA_DIR),
  filename: (req, file, cb) => {
    const ext = path.extname(file.originalname);
    cb(null, `${Date.now()}-${randomUUID().slice(0, 8)}${ext}`);
  },
});
const upload = multer({ storage, limits: { fileSize: 5 * 1024 * 1024 * 1024 } }); // 5GB

const VIDEO_EXT = ['.mp4', '.webm', '.ogg', '.mov', '.m4v', '.mkv', '.avi'];
const IMAGE_EXT = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.avif'];
const MEDIA_EXT = [...VIDEO_EXT, ...IMAGE_EXT];

// 파일 확장자로 미디어 종류(kind) 판별: 'video' | 'image' | null
function kindForFile(name) {
  const e = path.extname(name).toLowerCase();
  if (VIDEO_EXT.includes(e)) return 'video';
  if (IMAGE_EXT.includes(e)) return 'image';
  return null;
}

// ---------- 유틸 ----------
function parseYouTubeId(url) {
  if (!url) return null;
  const patterns = [
    /(?:youtube\.com\/watch\?[^#]*v=)([\w-]{11})/,
    /(?:youtu\.be\/)([\w-]{11})/,
    /(?:youtube\.com\/embed\/)([\w-]{11})/,
    /(?:youtube\.com\/shorts\/)([\w-]{11})/,
  ];
  for (const re of patterns) {
    const m = url.match(re);
    if (m) return m[1];
  }
  // 순수 11자 ID 직접 입력
  if (/^[\w-]{11}$/.test(url.trim())) return url.trim();
  return null;
}

// ================= 미디어 라이브러리 API =================
app.get('/api/media', (req, res) => {
  res.json(getDb().media);
});

// 파일 업로드 (USB 등)
app.post('/api/media/upload', upload.array('files'), (req, res) => {
  const db = getDb();
  const added = [];
  for (const f of req.files || []) {
    const item = {
      id: randomUUID(),
      name: f.originalname,
      type: 'upload',
      kind: kindForFile(f.originalname) || 'video', // video | image
      src: f.filename, // media/ 하위 파일명
      duration: null,
      addedAt: Date.now(),
    };
    db.media.push(item);
    added.push(item);
  }
  save();
  broadcastChanged();
  res.json(added);
});

// 로컬 폴더 참조 (폴더 안의 영상 파일들을 스캔해서 등록)
app.post('/api/media/folder', (req, res) => {
  const { folderPath, single } = req.body || {};
  if (!folderPath) return res.status(400).json({ error: 'folderPath 필요' });
  const db = getDb();
  const added = [];
  try {
    // only: 'video' | 'image' | undefined(둘 다)
    const only = (req.body || {}).only;
    const accept = (name) => {
      const k = kindForFile(name);
      if (!k) return false;
      return only ? k === only : true;
    };
    const stat = fs.statSync(folderPath);
    if (stat.isFile()) {
      // 단일 파일 경로
      if (accept(folderPath)) added.push(addFolderItem(db, folderPath));
    } else if (stat.isDirectory()) {
      // 파일명 자연 정렬(숫자 포함) → 폴더 스캔 시 순서 예측 가능하게 자동 배치
      const files = fs.readdirSync(folderPath).sort((a, b) => a.localeCompare(b, 'ko', { numeric: true }));
      for (const name of files) {
        const full = path.join(folderPath, name);
        try {
          if (fs.statSync(full).isFile() && accept(name)) added.push(addFolderItem(db, full));
        } catch {}
      }
    }
  } catch (e) {
    return res.status(400).json({ error: '경로를 읽을 수 없습니다: ' + e.message });
  }
  save();
  broadcastChanged();
  res.json(added);
});

function addFolderItem(db, fullPath) {
  const item = {
    id: randomUUID(),
    name: path.basename(fullPath),
    type: 'folder',
    kind: kindForFile(fullPath) || 'video',
    src: fullPath, // 절대 경로
    duration: null,
    addedAt: Date.now(),
  };
  db.media.push(item);
  return item;
}

// 유튜브 URL 등록
app.post('/api/media/youtube', (req, res) => {
  const { url, name } = req.body || {};
  const vid = parseYouTubeId(url);
  if (!vid) return res.status(400).json({ error: '유효한 유튜브 주소가 아닙니다' });
  const db = getDb();
  const item = {
    id: randomUUID(),
    name: name || `YouTube ${vid}`,
    type: 'youtube',
    kind: 'video',
    src: vid,
    thumbnail: `https://img.youtube.com/vi/${vid}/hqdefault.jpg`,
    duration: null,
    addedAt: Date.now(),
  };
  db.media.push(item);
  save();
  broadcastChanged();
  res.json(item);
});

// 미디어 메타 수정 (이름, duration 등)
app.patch('/api/media/:id', (req, res) => {
  const db = getDb();
  const m = db.media.find((x) => x.id === req.params.id);
  if (!m) return res.status(404).json({ error: 'not found' });
  const { name, duration } = req.body || {};
  if (name !== undefined) m.name = name;
  if (duration !== undefined) m.duration = duration;
  save();
  res.json(m);
});

// 미디어 삭제
app.delete('/api/media/:id', (req, res) => {
  const db = getDb();
  const idx = db.media.findIndex((x) => x.id === req.params.id);
  if (idx < 0) return res.status(404).json({ error: 'not found' });
  const [removed] = db.media.splice(idx, 1);
  // 업로드 파일이면 실제 파일 삭제
  if (removed.type === 'upload') {
    const fp = path.join(MEDIA_DIR, removed.src);
    fs.promises.unlink(fp).catch(() => {});
  }
  // 스케줄/포스터/긴급송출에서 참조 제거
  for (const s of db.schedules) {
    s.mediaIds = (s.mediaIds || []).filter((id) => id !== removed.id);
  }
  db.posters = db.posters.filter((p) => p.mediaId !== removed.id);
  for (const pl of db.playlists) pl.mediaIds = (pl.mediaIds || []).filter((id) => id !== removed.id);
  if (db.emergency.mediaId === removed.id) {
    db.emergency = { active: false, mediaId: null, startedAt: null };
  }
  save();
  broadcastChanged();
  broadcastDirective(true);
  res.json({ ok: true });
});

// 스트리밍 (upload / folder 공통, Range 지원)
app.get('/api/stream/:id', (req, res) => {
  const db = getDb();
  const m = db.media.find((x) => x.id === req.params.id);
  if (!m || (m.type !== 'upload' && m.type !== 'folder')) return res.sendStatus(404);
  const filePath = m.type === 'upload' ? path.join(MEDIA_DIR, m.src) : m.src;
  fs.stat(filePath, (err, stat) => {
    if (err) return res.sendStatus(404);
    const range = req.headers.range;
    const type = mimeFor(filePath);
    if (range) {
      const parts = range.replace(/bytes=/, '').split('-');
      const start = parseInt(parts[0], 10);
      const end = parts[1] ? parseInt(parts[1], 10) : stat.size - 1;
      const chunk = end - start + 1;
      res.writeHead(206, {
        'Content-Range': `bytes ${start}-${end}/${stat.size}`,
        'Accept-Ranges': 'bytes',
        'Content-Length': chunk,
        'Content-Type': type,
      });
      fs.createReadStream(filePath, { start, end }).pipe(res);
    } else {
      res.writeHead(200, { 'Content-Length': stat.size, 'Content-Type': type, 'Accept-Ranges': 'bytes' });
      fs.createReadStream(filePath).pipe(res);
    }
  });
});

function mimeFor(fp) {
  const ext = path.extname(fp).toLowerCase();
  return (
    {
      '.mp4': 'video/mp4',
      '.m4v': 'video/mp4',
      '.webm': 'video/webm',
      '.ogg': 'video/ogg',
      '.mov': 'video/quicktime',
      '.mkv': 'video/x-matroska',
      '.avi': 'video/x-msvideo',
      '.jpg': 'image/jpeg',
      '.jpeg': 'image/jpeg',
      '.png': 'image/png',
      '.gif': 'image/gif',
      '.webp': 'image/webp',
      '.bmp': 'image/bmp',
      '.avif': 'image/avif',
    }[ext] || 'application/octet-stream'
  );
}

// ================= 스케줄 API =================
app.get('/api/schedules', (req, res) => res.json(getDb().schedules));

app.post('/api/schedules', (req, res) => {
  const db = getDb();
  const s = normalizeSchedule(req.body, true);
  db.schedules.push(s);
  save();
  broadcastChanged();
  broadcastDirective(true);
  res.json(s);
});

app.put('/api/schedules/:id', (req, res) => {
  const db = getDb();
  const idx = db.schedules.findIndex((x) => x.id === req.params.id);
  if (idx < 0) return res.status(404).json({ error: 'not found' });
  db.schedules[idx] = { ...db.schedules[idx], ...normalizeSchedule(req.body, false), id: req.params.id };
  save();
  broadcastChanged();
  broadcastDirective(true);
  res.json(db.schedules[idx]);
});

app.delete('/api/schedules/:id', (req, res) => {
  const db = getDb();
  const idx = db.schedules.findIndex((x) => x.id === req.params.id);
  if (idx < 0) return res.status(404).json({ error: 'not found' });
  db.schedules.splice(idx, 1);
  save();
  broadcastChanged();
  broadcastDirective(true);
  res.json({ ok: true });
});

function normalizeSchedule(body = {}, isNew) {
  const s = {
    name: body.name || '새 스케줄',
    days: Array.isArray(body.days) ? body.days.map(Number) : [0, 1, 2, 3, 4, 5, 6],
    start: body.start || '00:00',
    end: body.end || '23:59',
    mediaIds: Array.isArray(body.mediaIds) ? body.mediaIds : [],
    playlistId: body.playlistId || null, // 재사용 재생목록(캠페인) 참조
    enabled: body.enabled !== false,
  };
  if (isNew) s.id = randomUUID();
  return s;
}

// ================= 재사용 재생목록(캠페인) API =================
app.get('/api/playlists', (req, res) => res.json(getDb().playlists));
app.post('/api/playlists', (req, res) => {
  const db = getDb();
  const p = { id: randomUUID(), name: (req.body && req.body.name) || '새 재생목록', mediaIds: Array.isArray(req.body && req.body.mediaIds) ? req.body.mediaIds : [] };
  db.playlists.push(p);
  save(); broadcastChanged(); broadcastDirective(true);
  res.json(p);
});
app.put('/api/playlists/:id', (req, res) => {
  const db = getDb();
  const p = db.playlists.find((x) => x.id === req.params.id);
  if (!p) return res.status(404).json({ error: 'not found' });
  if (req.body.name !== undefined) p.name = req.body.name;
  if (Array.isArray(req.body.mediaIds)) p.mediaIds = req.body.mediaIds;
  save(); broadcastChanged(); broadcastDirective(true);
  res.json(p);
});
app.delete('/api/playlists/:id', (req, res) => {
  const db = getDb();
  const i = db.playlists.findIndex((x) => x.id === req.params.id);
  if (i < 0) return res.status(404).json({ error: 'not found' });
  db.playlists.splice(i, 1);
  // 이 재생목록을 참조하던 스케줄은 참조 해제
  for (const s of db.schedules) if (s.playlistId === req.params.id) s.playlistId = null;
  save(); broadcastChanged(); broadcastDirective(true);
  res.json({ ok: true });
});

// ================= 포스터 / 현수막 API =================
function normalizePoster(body = {}, isNew) {
  const p = {
    name: body.name || '새 포스터',
    mediaId: body.mediaId || null,
    startDate: body.startDate || '',   // 'YYYY-MM-DD' (비우면 제한 없음)
    endDate: body.endDate || '',
    days: Array.isArray(body.days) ? body.days.map(Number) : [0, 1, 2, 3, 4, 5, 6],
    slots: Array.isArray(body.slots) ? body.slots.filter((s) => s && s.start && s.end) : [{ start: '09:00', end: '18:00' }],
    displaySeconds: Number(body.displaySeconds) > 0 ? Number(body.displaySeconds) : 15,
    enabled: body.enabled !== false,
  };
  if (isNew) p.id = randomUUID();
  return p;
}
app.get('/api/posters', (req, res) => res.json(getDb().posters));
app.post('/api/posters', (req, res) => {
  const db = getDb();
  const p = normalizePoster(req.body, true);
  db.posters.push(p);
  save(); broadcastChanged(); broadcastDirective(true);
  res.json(p);
});
app.put('/api/posters/:id', (req, res) => {
  const db = getDb();
  const i = db.posters.findIndex((x) => x.id === req.params.id);
  if (i < 0) return res.status(404).json({ error: 'not found' });
  db.posters[i] = { ...db.posters[i], ...normalizePoster(req.body, false), id: req.params.id };
  save(); broadcastChanged(); broadcastDirective(true);
  res.json(db.posters[i]);
});
app.delete('/api/posters/:id', (req, res) => {
  const db = getDb();
  const i = db.posters.findIndex((x) => x.id === req.params.id);
  if (i < 0) return res.status(404).json({ error: 'not found' });
  db.posters.splice(i, 1);
  save(); broadcastChanged(); broadcastDirective(true);
  res.json({ ok: true });
});

// ================= 설정 API (해상도 / 밝기) =================
app.get('/api/settings', (req, res) => res.json(getDb().settings));

app.put('/api/settings', (req, res) => {
  const db = getDb();
  const b = req.body || {};
  if (b.resolution) db.settings.resolution = { ...db.settings.resolution, ...b.resolution };
  if (b.brightness) db.settings.brightness = { ...db.settings.brightness, ...b.brightness };
  if (b.scaleMode !== undefined) db.settings.scaleMode = b.scaleMode;
  if (b.idleMode !== undefined) db.settings.idleMode = b.idleMode;
  if (b.overlay) {
    db.settings.overlay = {
      ticker: { ...db.settings.overlay.ticker, ...(b.overlay.ticker || {}) },
      clock: { ...db.settings.overlay.clock, ...(b.overlay.clock || {}) },
      weather: { ...db.settings.overlay.weather, ...(b.overlay.weather || {}) },
    };
    refreshWidgets(); // 날씨/RSS 즉시 갱신
  }
  if (b.layout) {
    db.settings.layout = {
      preset: b.layout.preset || db.settings.layout.preset,
      subMediaIds: Array.isArray(b.layout.subMediaIds) ? b.layout.subMediaIds : db.settings.layout.subMediaIds,
    };
  }
  if (b.power) db.settings.power = { ...db.settings.power, ...b.power };
  save();
  broadcastChanged();
  broadcastDirective(true);
  res.json(db.settings);
});

// ================= 긴급 송출 API =================
app.post('/api/emergency', (req, res) => {
  const db = getDb();
  const { mediaId } = req.body || {};
  if (!db.media.find((m) => m.id === mediaId)) return res.status(400).json({ error: '미디어를 찾을 수 없습니다' });
  db.emergency = { active: true, mediaId, startedAt: Date.now() };
  save();
  broadcastChanged();
  broadcastDirective(true);
  res.json(db.emergency);
});

app.delete('/api/emergency', (req, res) => {
  const db = getDb();
  db.emergency = { active: false, mediaId: null, startedAt: null };
  save();
  broadcastChanged();
  broadcastDirective(true);
  res.json(db.emergency);
});

// ================= 저장 / 불러오기 (송출 구성 파일) =================
// 미디어 목록 + 스케줄 + 설정을 하나의 파일로 내보내고 다시 불러온다.
app.get('/api/export', (req, res) => {
  const db = getDb();
  res.json({
    app: 'signage-cms',
    version: 1,
    exportedAt: new Date().toISOString(),
    media: db.media,
    schedules: db.schedules,
    playlists: db.playlists,
    posters: db.posters,
    settings: db.settings,
  });
});

app.post('/api/import', (req, res) => {
  const db = getDb();
  const data = req.body?.data || req.body || {};
  const mode = req.body?.mode || 'replace'; // 'replace' | 'merge'
  try {
    if (Array.isArray(data.media)) {
      if (mode === 'replace') db.media = data.media;
      else {
        const ids = new Set(db.media.map((m) => m.id));
        for (const m of data.media) if (!ids.has(m.id)) db.media.push(m);
      }
    }
    if (Array.isArray(data.schedules)) {
      db.schedules = mode === 'replace' ? data.schedules : [...db.schedules, ...data.schedules];
    }
    if (Array.isArray(data.posters)) {
      db.posters = mode === 'replace' ? data.posters : [...db.posters, ...data.posters];
    }
    if (Array.isArray(data.playlists)) {
      db.playlists = mode === 'replace' ? data.playlists : [...db.playlists, ...data.playlists];
    }
    if (data.settings && mode === 'replace') {
      db.settings = { ...db.settings, ...data.settings };
    }
    saveNow();
    broadcastChanged();
    broadcastDirective(true);
    res.json({ ok: true, media: db.media.length, schedules: db.schedules.length });
  } catch (e) {
    res.status(400).json({ error: '불러오기 실패: ' + e.message });
  }
});

// ================= 되돌리기(undo) =================
app.get('/api/undo', (req, res) => res.json({ count: Math.max(0, undoStack.length - 1) }));
app.post('/api/undo', (req, res) => {
  if (undoStack.length < 2) return res.status(400).json({ error: '되돌릴 이전 상태가 없습니다' });
  applyingUndo = true;
  undoStack.pop();                                   // 현재 상태 제거
  const prev = JSON.parse(undoStack[undoStack.length - 1]);
  applySnapshot(prev);
  persistUndo();
  broadcastChanged(); broadcastDirective(true);      // applyingUndo=true이므로 캡처 스킵
  applyingUndo = false;
  res.json({ ok: true, count: Math.max(0, undoStack.length - 1) });
});

// ================= 로컬 저장/불러오기 (이름 붙인 스냅샷) =================
app.get('/api/snapshots', (req, res) => {
  res.json(loadSnaps().map((s) => ({ id: s.id, name: s.name, at: s.at, size: s.size })).sort((a, b) => (a.at < b.at ? 1 : -1)));
});
app.post('/api/snapshots', (req, res) => {
  const name = String((req.body && req.body.name) || '').trim() || ('저장 ' + new Date().toLocaleString('ko-KR'));
  const data = configSnapshot(); const str = JSON.stringify(data);
  const list = loadSnaps();
  list.push({ id: randomUUID(), name, at: new Date().toISOString(), size: str.length, data });
  saveSnaps(list.sort((a, b) => (a.at < b.at ? 1 : -1)).slice(0, 50));
  res.json({ ok: true });
});
app.post('/api/snapshots/:id/restore', (req, res) => {
  const s = loadSnaps().find((x) => x.id === req.params.id);
  if (!s) return res.status(404).json({ error: '저장을 찾을 수 없습니다' });
  applySnapshot(s.data || {});
  broadcastChanged(); broadcastDirective(true);
  res.json({ ok: true });
});
app.delete('/api/snapshots/:id', (req, res) => {
  saveSnaps(loadSnaps().filter((x) => x.id !== req.params.id));
  res.json({ ok: true });
});

// ================= 클라우드(cms.hcjoyn.com) 계정 연결·백업·복구 =================
function maskedCloud() {
  const c = getDb().cloud;
  return { linked: c.linked, email: c.email, deviceName: c.deviceName, orgName: c.orgName, portalUrl: c.portalUrl,
    autoBackup: c.autoBackup, lastBackupAt: c.lastBackupAt, lastBackupStatus: c.lastBackupStatus, lastRestoreAt: c.lastRestoreAt, hasToken: !!c.deviceToken };
}
app.get('/api/cloud', (req, res) => res.json(maskedCloud()));
// 프로 요금제 여부(디자인 템플릿 등 프로 기능 게이트) — 포털 cloud/status 프록시
app.get('/api/cloud/pro-status', async (req, res) => {
  const c = getDb().cloud;
  if (!c.linked || !c.deviceToken) return res.json({ linked: false, pro: false });
  try {
    const r = await fetch(`${c.portalUrl}/api/cloud/status`, { headers: { 'x-device-token': c.deviceToken }, signal: AbortSignal.timeout(8000) });
    const j = await r.json();
    res.json({ linked: true, pro: !!j.pro, plan: j.plan || null, planUntil: j.planUntil || '', expired: !!j.expired });
  } catch (e) { res.json({ linked: true, pro: false, error: '클라우드 상태 확인 실패' }); }
});
// 클라우드(R2)에 저장된 내 AI 이미지 목록 — 포털 프록시
app.get('/api/cloud/ai-images', async (req, res) => {
  const c = getDb().cloud;
  if (!c.linked || !c.deviceToken) return res.status(400).json({ error: '클라우드 계정이 연결되지 않았습니다' });
  try {
    const r = await fetch(`${c.portalUrl}/api/ai/device/mine`, { headers: { 'x-device-token': c.deviceToken }, signal: AbortSignal.timeout(15000) });
    const j = await r.json().catch(() => ({}));
    if (!r.ok) return res.status(r.status).json(j);
    res.json(j);
  } catch (e) { res.status(502).json({ error: '클라우드 이미지 목록을 가져오지 못했습니다' }); }
});
// 클라우드 AI 이미지를 로컬 미디어로 가져오기(다운로드) — 스케줄/포스터에 사용
app.post('/api/cloud/ai-import', async (req, res) => {
  const { url, name } = req.body || {};
  if (!url) return res.status(400).json({ error: '이미지 주소가 없습니다' });
  try {
    const r = await fetch(url, { signal: AbortSignal.timeout(30000) });
    if (!r.ok) return res.status(502).json({ error: '이미지 다운로드 실패' });
    const buf = Buffer.from(await r.arrayBuffer());
    const fname = `${Date.now()}-${Math.random().toString(16).slice(2, 10)}.png`;
    fs.writeFileSync(path.join(MEDIA_DIR, fname), buf);
    const db = getDb();
    const item = { id: randomUUID(), name: String(name || 'AI 이미지').slice(0, 80) + '.png', type: 'upload', kind: 'image', src: fname, duration: null, addedAt: Date.now() };
    db.media.push(item); save(); broadcastChanged();
    res.json({ ok: true, media: item });
  } catch (e) { res.status(502).json({ error: '가져오기 실패: ' + e.message }); }
});
app.post('/api/cloud/link', async (req, res) => {
  const db = getDb(); const b = req.body || {};
  const portalUrl = (b.portalUrl && String(b.portalUrl).trim().replace(/\/$/, '')) || db.cloud.portalUrl || 'https://cms.hcjoyn.com';
  const email = String(b.email || '').trim(); const password = String(b.password || ''); const deviceName = String(b.deviceName || '').trim();
  if (!email || !password) return res.status(400).json({ error: '이메일과 비밀번호를 입력하세요' });
  try {
    const r = await fetch(`${portalUrl}/api/device/link`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password, deviceName }) });
    const j = await r.json().catch(() => ({}));
    if (!r.ok) return res.status(r.status).json({ error: j.error || '연결 실패' });
    db.cloud.portalUrl = portalUrl; db.cloud.linked = true; db.cloud.email = email; db.cloud.deviceToken = j.deviceToken;
    db.cloud.deviceName = deviceName || (j.user && j.user.name) || ''; db.cloud.orgName = (j.user && j.user.orgName) || '';
    saveNow();
    // 연결 직후 1회 백업
    cloudPush('manual', '연결 직후 백업');
    res.json({ ok: true, cloud: maskedCloud() });
  } catch (e) { res.status(502).json({ error: '포털 연결 실패: ' + e.message }); }
});
app.post('/api/cloud/unlink', (req, res) => {
  const c = getDb().cloud; c.linked = false; c.deviceToken = ''; c.email = ''; c.orgName = ''; saveNow();
  res.json({ ok: true, cloud: maskedCloud() });
});
app.put('/api/cloud', (req, res) => {
  const c = getDb().cloud; const b = req.body || {};
  if (b.autoBackup !== undefined) c.autoBackup = !!b.autoBackup;
  if (b.deviceName !== undefined) c.deviceName = String(b.deviceName).slice(0, 60);
  saveNow(); res.json({ ok: true, cloud: maskedCloud() });
});
app.post('/api/cloud/backup-now', async (req, res) => {
  const out = await cloudPush('manual', (req.body && req.body.label) || '수동 백업');
  if (!out.ok) return res.status(502).json(out);
  res.json(out);
});
app.get('/api/cloud/backups', async (req, res) => {
  const c = getDb().cloud;
  if (!c.linked || !c.deviceToken) return res.status(400).json({ error: '클라우드 계정이 연결되지 않았습니다' });
  try {
    const r = await fetch(`${c.portalUrl}/api/backup/mine`, { headers: { 'x-device-token': c.deviceToken } });
    const j = await r.json().catch(() => ({}));
    if (!r.ok) return res.status(r.status).json({ error: j.error || '조회 실패' });
    res.json(j);
  } catch (e) { res.status(502).json({ error: '포털 조회 실패: ' + e.message }); }
});

// ================= 원격 지원 (Remote Client) =================
// 토큰은 응답에서 마스킹하여 노출하지 않는다.
function maskedRemote() {
  const r = getDb().remote;
  return {
    enabled: r.enabled,
    managerUrl: r.managerUrl,
    clientName: r.clientName,
    allowUpdate: r.allowUpdate,
    hasToken: !!r.token,
    connection: remoteClient ? remoteClient.getState() : 'disabled',
  };
}
app.get('/api/remote', (req, res) => res.json(maskedRemote()));

app.put('/api/remote', (req, res) => {
  const db = getDb();
  const b = req.body || {};
  if (b.enabled !== undefined) db.remote.enabled = !!b.enabled;
  if (b.managerUrl !== undefined) db.remote.managerUrl = String(b.managerUrl);
  if (b.clientName !== undefined) db.remote.clientName = String(b.clientName);
  if (b.allowUpdate !== undefined) db.remote.allowUpdate = !!b.allowUpdate;
  if (b.token) db.remote.token = String(b.token); // 빈 값이면 기존 토큰 유지
  saveNow();
  // 클라이언트 재기동
  if (db.remote.enabled) remoteClient.restart();
  else remoteClient.stop();
  broadcastChanged();
  res.json(maskedRemote());
});

// 원격 제어 디스패처: 매니저가 보낸 제어 명령을 실제로 적용
async function applyRemoteControl(payload) {
  const db = getDb();
  const kind = payload.kind;
  if (kind === 'settings') {
    if (payload.resolution) db.settings.resolution = { ...db.settings.resolution, ...payload.resolution };
    if (payload.brightness) db.settings.brightness = { ...db.settings.brightness, ...payload.brightness };
    if (payload.scaleMode !== undefined) db.settings.scaleMode = payload.scaleMode;
    if (payload.idleMode !== undefined) db.settings.idleMode = payload.idleMode;
  } else if (kind === 'emergency-start') {
    if (db.media.find((m) => m.id === payload.mediaId)) db.emergency = { active: true, mediaId: payload.mediaId, startedAt: Date.now() };
  } else if (kind === 'emergency-stop') {
    db.emergency = { active: false, mediaId: null, startedAt: null };
  } else if (kind === 'push-config') {
    // 원격에서 편성한 스케줄(+새 미디어)을 전송받아 적용
    if (Array.isArray(payload.media)) {
      const ids = new Set(db.media.map((m) => m.id));
      for (const m of payload.media) if (m && m.id && !ids.has(m.id)) { db.media.push(m); ids.add(m.id); }
    }
    if (Array.isArray(payload.schedules)) {
      db.schedules = payload.schedules.map((s) => {
        const n = normalizeSchedule(s, false);
        n.id = s.id || randomUUID();
        return n;
      });
    }
    if (Array.isArray(payload.posters)) {
      db.posters = payload.posters.map((p) => {
        const n = normalizePoster(p, false);
        n.id = p.id || randomUUID();
        return n;
      });
    }
    if (Array.isArray(payload.playlists)) db.playlists = payload.playlists;
    saveNow();
    broadcastChanged();
    broadcastDirective(true);
    return { ok: true, kind, media: db.media.length, schedules: db.schedules.length, posters: db.posters.length };
  } else {
    return { ok: false, error: '알 수 없는 제어: ' + kind };
  }
  saveNow();
  broadcastChanged();
  broadcastDirective(true);
  return { ok: true, kind };
}

// 원격 업데이트: 파일 배열 [{path, b64}] 을 프로젝트 폴더 내에 안전하게 기록
function applyRemoteUpdate(files) {
  const written = [];
  for (const f of files) {
    if (!f || !f.path || f.b64 === undefined) continue;
    // 경로 가드: 프로젝트 루트 밖으로는 절대 쓰지 않음
    const target = path.resolve(ROOT, f.path);
    if (target !== ROOT && !target.startsWith(ROOT + path.sep)) {
      return { ok: false, error: '허용되지 않은 경로: ' + f.path };
    }
    fs.mkdirSync(path.dirname(target), { recursive: true });
    fs.writeFileSync(target, Buffer.from(f.b64, 'base64'));
    written.push(f.path);
  }
  return { ok: true, written };
}

function remoteStatus() {
  const db = getDb();
  const directive = buildDirective(db, new Date());
  return {
    version: APP_VERSION,
    name: db.remote.clientName || 'signage',
    platform: process.platform,
    uptimeSec: Math.round((Date.now() - STARTED_AT) / 1000),
    port: currentPort,
    mediaCount: db.media.length,
    scheduleCount: db.schedules.length,
    mode: directive.mode,
    scheduleName: directive.scheduleName,
    brightness: directive.brightness,
    resolution: directive.resolution,
    emergency: db.emergency.active,
    allowUpdate: db.remote.allowUpdate,
  };
}

function doRestart() {
  console.log('  ↻ 원격 명령으로 재시작합니다…');
  saveNow();
  process.exit(42); // start.command 루프가 재기동 (코드 42 = 재시작)
}

const remoteClient = createRemoteClient(() => getDb().remote, {
  version: APP_VERSION,
  getStatus: remoteStatus,
  getLogs: () => LOG_BUFFER.slice(-200),
  getConfig: () => { const db = getDb(); return { media: db.media, schedules: db.schedules, playlists: db.playlists, posters: db.posters, settings: db.settings }; },
  captureScreen: () => requestScreenshot(),
  getHistory: (n) => PLAYLOG.slice(-(n || 100)).reverse(),
  control: applyRemoteControl,
  applyUpdate: applyRemoteUpdate,
  restart: doRestart,
  onState: (s) => { /* 상태 변경 시 CMS에 알림 */ wss && wss.clients.forEach((ws) => { if (ws.role === 'cms') safeSend(ws, { type: 'remote-state', state: s }); }); },
});

// ================= 재생 이력 (proof-of-play) =================
app.get('/api/history', (req, res) => {
  const n = Math.min(1000, Number(req.query.limit) || 200);
  res.json(PLAYLOG.slice(-n).reverse());
});
app.get('/api/history.csv', (req, res) => {
  res.setHeader('Content-Type', 'text/csv; charset=utf-8');
  res.setHeader('Content-Disposition', 'attachment; filename="playlog.csv"');
  const rows = [['시간', '콘텐츠', '종류', '모드', '편성'], ...PLAYLOG.slice().reverse().map((e) => [e.t, e.name, e.kind, e.mode, e.schedule])];
  res.send('﻿' + rows.map((r) => r.map((c) => `"${String(c).replace(/"/g, '""')}"`).join(',')).join('\n'));
});
app.delete('/api/history', (req, res) => {
  PLAYLOG.length = 0;
  fs.writeFile(PLAYLOG_PATH, '', () => {});
  res.json({ ok: true });
});

// ================= 원격 실제화면 스크린샷 =================
let pendingShot = null;
function requestScreenshot(timeoutMs = 4000) {
  return new Promise((resolve) => {
    const players = [...wss.clients].filter((w) => w.role === 'player' && w.readyState === 1);
    if (!players.length) return resolve({ error: '연결된 플레이어(전광판 화면)가 없습니다' });
    if (pendingShot) { try { clearTimeout(pendingShot.timer); } catch {} pendingShot.resolve({ error: '취소됨' }); }
    const timer = setTimeout(() => { pendingShot = null; resolve({ error: '응답 시간 초과' }); }, timeoutMs);
    pendingShot = { resolve, timer };
    players.forEach((w) => safeSend(w, { type: 'capture' }));
  });
}
function resolveScreenshot(payload) {
  if (!pendingShot) return;
  clearTimeout(pendingShot.timer);
  const r = pendingShot.resolve; pendingShot = null;
  r(payload || {});
}
app.get('/api/screenshot', async (req, res) => res.json(await requestScreenshot()));

// ================= 통합 상태 =================
app.get('/api/state', (req, res) => {
  const db = getDb();
  res.json({
    settings: db.settings,
    emergency: db.emergency,
    directive: currentDirective(),
  });
});

// 라우트 별칭
app.get('/', (req, res) => res.redirect('/cms/'));

// ================= HTTP + WebSocket =================
const server = http.createServer(app);
const wss = new WebSocketServer({ server, path: '/ws' });

wss.on('connection', (ws, req) => {
  const url = new URL(req.url, 'http://localhost');
  ws.role = url.searchParams.get('role') || 'unknown';
  // 접속 즉시 현재 지시 전송
  sendDirectiveTo(ws);
  // CMS 접속 시, 마지막으로 감지된 전광판 모니터 해상도 전달
  if ((ws.role === 'cms' || ws.role === 'preview') && lastScreenInfo) {
    safeSend(ws, { type: 'screeninfo', ...lastScreenInfo });
  }
  ws.on('message', (raw) => {
    let msg;
    try { msg = JSON.parse(raw.toString()); } catch { return; }
    if (msg.type === 'nowplaying') {
      // 플레이어가 보고한 현재 재생 정보를 CMS/미리보기에 중계 + 이력 기록
      relay({ type: 'nowplaying', role: ws.role, ...msg.payload }, ['cms', 'preview']);
      if (ws.role === 'player') logPlayback(msg.payload && msg.payload.media);
    } else if (msg.type === 'screenshot') {
      // 플레이어가 캡처한 화면을 대기 중인 요청자에게 전달
      resolveScreenshot(msg.payload);
    } else if (msg.type === 'screeninfo') {
      // 플레이어(전광판)가 자기 모니터 해상도를 보고 → CMS로 중계
      lastScreenInfo = msg.payload;
      relay({ type: 'screeninfo', ...msg.payload }, ['cms', 'preview']);
    }
  });
});

function sendDirectiveTo(ws) {
  const directive = currentDirective();
  safeSend(ws, { type: 'directive', directive });
}

function safeSend(ws, obj) {
  if (ws.readyState === 1) ws.send(JSON.stringify(obj));
}

function relay(obj, roles) {
  wss.clients.forEach((ws) => {
    if (roles.includes(ws.role)) safeSend(ws, obj);
  });
}

let lastScreenInfo = null;
let lastDirectiveKey = '';
let currentPort = BASE_PORT;
function broadcastDirective(force = false) {
  const directive = currentDirective();
  const key = directiveKey(directive);
  if (!force && key === lastDirectiveKey) return;
  lastDirectiveKey = key;
  wss.clients.forEach((ws) => safeSend(ws, { type: 'directive', directive }));
}

function broadcastChanged() {
  // 라이브러리/스케줄/설정이 바뀌었으니 CMS가 다시 불러오도록 알림
  captureUndo();
  wss.clients.forEach((ws) => safeSend(ws, { type: 'changed' }));
}

// 매초 스케줄/밝기 변화를 감지해 지시 갱신 (시간 경과에 따른 자동 전환)
setInterval(() => broadcastDirective(false), 1000);

// ===== 스케줄 스냅샷: 되돌리기 · 로컬 저장 · 클라우드 백업/복구 공용 =====
const SNAP_PATH = path.join(DATA_DIR, 'snapshots.json'); // 로컬 이름 저장
const UNDO_PATH = path.join(DATA_DIR, 'undo.json');      // 되돌리기 스택
const UNDO_MAX = 25;

function configSnapshot() {
  const db = getDb();
  return { media: db.media, schedules: db.schedules, playlists: db.playlists, posters: db.posters, settings: db.settings };
}
function applySnapshot(data) {
  const db = getDb();
  if (Array.isArray(data.media)) db.media = data.media;
  if (Array.isArray(data.schedules)) db.schedules = data.schedules;
  if (Array.isArray(data.playlists)) db.playlists = data.playlists;
  if (Array.isArray(data.posters)) db.posters = data.posters;
  if (data.settings && typeof data.settings === 'object') db.settings = { ...db.settings, ...data.settings };
  saveNow();
}
function loadSnaps() { try { const r = JSON.parse(fs.readFileSync(SNAP_PATH, 'utf-8')); return Array.isArray(r) ? r : []; } catch { return []; } }
function saveSnaps(list) { try { fs.writeFileSync(SNAP_PATH, JSON.stringify(list, null, 2), 'utf-8'); } catch (e) { console.error('[snap] 저장 실패', e.message); } }

// 되돌리기(undo): 변경 후 상태 이력 스택. undo = 직전 상태로 복원
let undoStack = [];
let applyingUndo = false;
try { const raw = JSON.parse(fs.readFileSync(UNDO_PATH, 'utf-8')); if (Array.isArray(raw)) undoStack = raw; } catch {}
function persistUndo() { try { fs.writeFileSync(UNDO_PATH, JSON.stringify(undoStack), 'utf-8'); } catch {} }
function captureUndo() {
  if (applyingUndo) return;
  const cur = JSON.stringify(configSnapshot());
  if (undoStack.length && undoStack[undoStack.length - 1] === cur) return; // 변화 없으면 스킵
  undoStack.push(cur);
  if (undoStack.length > UNDO_MAX) undoStack.shift();
  persistUndo();
}
captureUndo(); // 기준점

// ===== 클라우드(cms.hcjoyn.com) 백업/복구 =====
async function cloudPush(kind = 'auto', label = '') {
  const c = getDb().cloud;
  if (!c.linked || !c.deviceToken) return { ok: false, error: '클라우드 계정이 연결되지 않았습니다' };
  try {
    const r = await fetch(`${c.portalUrl}/api/backup`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'x-device-token': c.deviceToken },
      body: JSON.stringify({ kind, label, deviceName: c.deviceName, data: configSnapshot() }),
    });
    const j = await r.json().catch(() => ({}));
    if (!r.ok) throw new Error(j.error || ('HTTP ' + r.status));
    c.lastBackupAt = new Date().toISOString(); c.lastBackupStatus = 'ok'; saveNow();
    return { ok: true, id: j.id, at: c.lastBackupAt };
  } catch (e) {
    c.lastBackupStatus = String(e.message || e); saveNow();
    return { ok: false, error: c.lastBackupStatus };
  }
}
async function cloudPollRestore() {
  const c = getDb().cloud;
  if (!c.linked || !c.deviceToken) return;
  try {
    const r = await fetch(`${c.portalUrl}/api/restore/pending`, { headers: { 'x-device-token': c.deviceToken } });
    if (!r.ok) return;
    const j = await r.json();
    if (!j.pending) return;
    applySnapshot(j.pending.data || {});   // 복구 적용
    c.lastRestoreAt = new Date().toISOString(); saveNow();
    broadcastChanged(); broadcastDirective(true); // captureUndo로 복구 후 상태 기록 → 되돌리기 가능
    await fetch(`${c.portalUrl}/api/restore/ack`, { method: 'POST', headers: { 'x-device-token': c.deviceToken } });
    console.log('[cloud] 관리자 지정 복구 적용 완료');
  } catch { /* 오프라인 등은 조용히 무시 */ }
}
setTimeout(cloudPollRestore, 8 * 1000);                  // 시작 직후 1회(전광판 켜지면 곧 복구 반영)
setInterval(cloudPollRestore, 5 * 60 * 1000);            // 5분마다 복구 확인
setInterval(() => {                                      // 30분마다 12시간 경과 시 자동 백업(하루 2회)
  const c = getDb().cloud;
  if (!c.linked || !c.autoBackup) return;
  const last = c.lastBackupAt ? Date.parse(c.lastBackupAt) : 0;
  if (Date.now() - last >= 12 * 60 * 60 * 1000) cloudPush('auto', '자동 백업');
}, 30 * 60 * 1000);

function openBrowser(url) {
  const cmd =
    process.platform === 'darwin' ? `open "${url}"` :
    process.platform === 'win32' ? `start "" "${url}"` :
    `xdg-open "${url}"`;
  exec(cmd, () => {});
}

// 사용 가능한 포트를 미리 찾는다 (사용 중이면 다음 포트로)
function findFreePort(port, triesLeft) {
  return new Promise((resolve, reject) => {
    const tester = net.createServer();
    tester.once('error', (err) => {
      if (err.code === 'EADDRINUSE' && triesLeft > 0) {
        console.log(`  ⚠ 포트 ${port} 사용 중 → ${port + 1} 확인`);
        resolve(findFreePort(port + 1, triesLeft - 1));
      } else {
        reject(err);
      }
    });
    tester.once('listening', () => tester.close(() => resolve(port)));
    tester.listen(port);
  });
}

// wss/http 예기치 못한 에러로 프로세스가 죽지 않도록 방어
wss.on('error', (e) => console.error('  ⚠ WebSocket 오류:', e.message));
server.on('error', (e) => console.error('  ⚠ 서버 오류:', e.message));

findFreePort(BASE_PORT, MAX_PORT_TRIES)
  .then((port) => {
    server.listen(port, () => {
      currentPort = port;
      const base = `http://localhost:${port}`;
      console.log(`\n  ▶ 사이니지 CMS 실행 중  (포트 ${port})`);
      console.log(`  • 관리자(CMS):      ${base}/cms/`);
      console.log(`  • 전광판(플레이어):  ${base}/player/\n`);
      if (AUTO_OPEN) openBrowser(`${base}/cms/`);
      // 원격 지원이 켜져 있으면 매니저에 연결
      if (getDb().remote.enabled) { console.log('  ⇄ 원격 지원 활성화 — 매니저에 연결합니다'); remoteClient.start(); }
    });
  })
  .catch((err) => {
    console.error('  ✖ 서버 시작 실패:', err.message);
    process.exit(1);
  });

process.on('SIGINT', () => { saveNow(); process.exit(0); });
process.on('SIGTERM', () => { saveNow(); process.exit(0); });
