import crypto from "crypto";
import fs from "fs";
import fsp from "fs/promises";
import path from "path";
import express from "express";
import cookieParser from "cookie-parser";
import multer from "multer";

const app = express();
const port = Number(process.env.PORT || 8080);
const dataDir = "/data";
const uploadsDir = "/uploads";
const dbPath = path.join(dataDir, "db.json");
const publicDir = path.resolve("src", "public");
const adminPassword = process.env.ADMIN_PASSWORD;
const sessionSecret = process.env.SESSION_SECRET;
const publicBaseUrl = (process.env.PUBLIC_BASE_URL || "").replace(/\/$/, "");

if (!adminPassword || !sessionSecret) {
  throw new Error("ADMIN_PASSWORD and SESSION_SECRET are required.");
}

app.set("trust proxy", true);
app.use(express.urlencoded({ extended: false, limit: `${Number(process.env.MAX_FIELDS_SIZE_MB || 2)}mb` }));
app.use(cookieParser(sessionSecret));
app.use("/assets", express.static(publicDir, { fallthrough: true }));

const imageExts = ["jpg", "jpeg", "png", "gif", "webp", "heic", "heif", "tif", "tiff", "bmp", "avif"];
const videoExts = ["mp4", "mov", "m4v", "avi", "mkv", "webm"];
const defaultExts = [...imageExts, ...videoExts];

function escapeHtml(value = "") {
  return String(value)
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#039;");
}

function sanitizeName(value, fallback = "request") {
  const safe = String(value || "")
    .normalize("NFKC")
    .replace(/[\\/:*?"<>|]/g, "_")
    .replace(/\s+/g, " ")
    .trim()
    .slice(0, 80);
  return safe || fallback;
}

function sanitizeFileName(value) {
  const parsed = path.parse(sanitizeName(value, "upload"));
  const base = sanitizeName(parsed.name, "upload").replace(/^\.+$/, "upload");
  const ext = parsed.ext.replace(/[^\w.]/g, "").slice(0, 16).toLowerCase();
  return `${base}${ext}`;
}

function normalizeExts(value) {
  return String(value || "")
    .split(/[\s,;]+/)
    .map((item) => item.trim().replace(/^\./, "").toLowerCase())
    .filter(Boolean)
    .filter((item, index, list) => /^[a-z0-9]+$/.test(item) && list.indexOf(item) === index);
}

async function ensureDb() {
  await fsp.mkdir(dataDir, { recursive: true });
  await fsp.mkdir(uploadsDir, { recursive: true });
  if (!fs.existsSync(dbPath)) {
    await writeDb({ requests: [] });
  }
}

async function readDb() {
  await ensureDb();
  return JSON.parse(await fsp.readFile(dbPath, "utf8"));
}

async function writeDb(db) {
  const tmp = `${dbPath}.${process.pid}.tmp`;
  await fsp.writeFile(tmp, JSON.stringify(db, null, 2));
  await fsp.rename(tmp, dbPath);
}

function sign(value) {
  return crypto.createHmac("sha256", sessionSecret).update(value).digest("hex");
}

function makeSession() {
  const payload = JSON.stringify({ admin: true, exp: Date.now() + 1000 * 60 * 60 * 12 });
  const encoded = Buffer.from(payload).toString("base64url");
  return `${encoded}.${sign(encoded)}`;
}

function isAuthed(req) {
  const token = req.cookies.session || "";
  const [encoded, mac] = token.split(".");
  if (!encoded || !mac || sign(encoded) !== mac) return false;
  try {
    const payload = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
    return payload.admin === true && payload.exp > Date.now();
  } catch {
    return false;
  }
}

function requireAdmin(req, res, next) {
  if (isAuthed(req)) return next();
  res.redirect("/admin/login");
}

function page(title, body) {
  return `<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>${escapeHtml(title)}</title>
  <style>
    :root { color-scheme: light; --ink:#17202a; --muted:#667085; --line:#d6dde7; --bg:#f6f8fb; --panel:#fff; --accent:#0f766e; --danger:#b42318; }
    * { box-sizing: border-box; }
    body { margin:0; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background:var(--bg); color:var(--ink); }
    main { width:min(960px, calc(100% - 32px)); margin:32px auto; }
    .panel { background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:20px; }
    h1 { font-size:24px; margin:0 0 18px; }
    h2 { font-size:18px; margin:26px 0 12px; }
    label { display:block; font-size:14px; color:var(--muted); margin:14px 0 6px; }
    input, textarea, select { width:100%; border:1px solid var(--line); border-radius:6px; padding:10px 12px; font:inherit; background:#fff; }
    input[type=file] { padding:9px; }
    button, .button { display:inline-flex; align-items:center; justify-content:center; gap:6px; border:0; border-radius:6px; padding:10px 14px; background:var(--accent); color:#fff; font-weight:650; cursor:pointer; text-decoration:none; }
    button.danger { background:var(--danger); }
    .row { display:grid; grid-template-columns: 1fr 1fr; gap:14px; }
    .actions { display:flex; gap:10px; flex-wrap:wrap; margin-top:16px; }
    .muted { color:var(--muted); }
    .table { width:100%; border-collapse:collapse; margin-top:12px; }
    .table th, .table td { text-align:left; border-top:1px solid var(--line); padding:10px; vertical-align:top; }
    .table th { color:var(--muted); font-size:13px; }
    .notice { border:1px solid #bae6fd; background:#ecfeff; padding:12px; border-radius:6px; margin:12px 0; }
    .error { border-color:#fecaca; background:#fff1f2; }
    .upload-box { border:2px dashed var(--line); border-radius:8px; background:#fff; padding:18px; }
    @media (max-width:720px) { main { width:calc(100% - 20px); margin:16px auto; } .row { grid-template-columns:1fr; } .table { font-size:14px; } }
  </style>
</head>
<body><main>${body}</main></body>
</html>`;
}

function renderTemplate(template, values) {
  return template.replaceAll(/\{\{(\w+)\}\}/g, (match, key) => values[key] ?? match);
}

function wantsJson(req) {
  return String(req.get("accept") || "").includes("application/json");
}

function uploadResultPage(request, { heading, message, isError = false }) {
  const title = escapeHtml(request.title);
  const slug = escapeHtml(request.slug);
  return `<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>${escapeHtml(heading)}</title>
  <link rel="stylesheet" href="/assets/upload.css">
</head>
<body>
  <main class="upload-page">
    <section class="wedding-card" aria-labelledby="archive-title">
      <div class="poster">
        <div class="poster__portrait" aria-hidden="true">
          <img class="poster__couple" src="/assets/wedding-couple.png" alt="">
        </div>
        <img class="poster__gate" src="/assets/wedding-gate-bg.png?v=gate-sage-2" alt="">
        <div class="poster__plate">
          <h1 id="archive-title">${title}</h1>
          <p class="poster__date">2026.05.24 SUN</p>
          <p class="poster__subtitle">OUR WEDDING ARCHIVE</p>
        </div>
      </div>
      <section class="thank-you${isError ? " thank-you--error" : ""}">
        <p class="thank-you__eyebrow">${isError ? "PLEASE TRY AGAIN" : "THANK YOU"}</p>
        <h2>${escapeHtml(heading)}</h2>
        <p>${escapeHtml(message)}</p>
        <a class="upload-again" href="/r/${slug}">${isError ? "다시 올리기" : "사진 더 올리기"}</a>
      </section>
    </section>
  </main>
</body>
</html>`;
}

async function uploadPage(request) {
  const accept = request.allowedExts.map((ext) => `.${ext}`).join(",");
  const templatePath = path.join(publicDir, "upload.html");
  const template = await fsp.readFile(templatePath, "utf8").catch(() => "");
  const values = {
    title: escapeHtml(request.title),
    slug: escapeHtml(request.slug),
    accept: escapeHtml(accept),
    allowedExts: escapeHtml(request.allowedExts.join(", ")),
    maxMb: escapeHtml(Math.round(request.maxBytes / 1024 / 1024)),
    cssPath: "/assets/upload.css"
  };
  if (template) return renderTemplate(template, values);
  return page(request.title, `
    <section class="panel">
      <h1>${values.title}</h1>
      <form method="post" action="/r/${values.slug}" enctype="multipart/form-data">
        <div class="upload-box">
          <label>사진/영상 선택</label>
          <input type="file" name="files" accept="${values.accept}" multiple required>
        </div>
        <div class="actions"><button type="submit">업로드</button></div>
      </form>
    </section>
  `);
}

async function directorySize(dir) {
  let total = 0;
  const entries = await fsp.readdir(dir, { withFileTypes: true }).catch((error) => {
    if (error.code === "ENOENT") return [];
    throw error;
  });
  for (const entry of entries) {
    const fullPath = path.join(dir, entry.name);
    if (entry.isDirectory()) {
      total += await directorySize(fullPath);
    } else if (entry.isFile()) {
      total += (await fsp.stat(fullPath)).size;
    }
  }
  return total;
}

async function removeFiles(files = []) {
  await Promise.all(files.map((file) => fsp.unlink(file.path).catch(() => {})));
}

async function moveUploadedFiles(files, targetDir) {
  await fsp.mkdir(targetDir, { recursive: true });
  for (const file of files) {
    const targetPath = path.join(targetDir, file.filename);
    await fsp.copyFile(file.path, targetPath);
    await fsp.unlink(file.path);
  }
}

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

app.get("/admin/login", (req, res) => {
  res.send(page("관리자 로그인", `
    <section class="panel">
      <h1>사진 요청 관리자</h1>
      <form method="post" action="/admin/login">
        <label>비밀번호</label>
        <input type="password" name="password" autocomplete="current-password" autofocus>
        <div class="actions"><button type="submit">로그인</button></div>
      </form>
    </section>
  `));
});

app.post("/admin/login", (req, res) => {
  const supplied = Buffer.from(req.body.password || "");
  const expected = Buffer.from(adminPassword);
  const ok = supplied.length === expected.length && crypto.timingSafeEqual(supplied, expected);
  if (!ok) {
    res.status(401).send(page("로그인 실패", `<section class="panel"><h1>로그인 실패</h1><p class="muted">비밀번호가 맞지 않아.</p><a class="button" href="/admin/login">다시 시도</a></section>`));
    return;
  }
  res.cookie("session", makeSession(), { httpOnly: true, sameSite: "lax", secure: req.secure, maxAge: 1000 * 60 * 60 * 12 });
  res.redirect("/admin");
});

app.post("/admin/logout", (req, res) => {
  res.clearCookie("session");
  res.redirect("/admin/login");
});

app.get("/admin", requireAdmin, async (req, res) => {
  const db = await readDb();
  const rows = db.requests
    .filter((item) => !item.deletedAt)
    .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
    .map((item) => {
      const url = `${publicBaseUrl}/r/${item.slug}`;
      return `<tr>
        <td><strong>${escapeHtml(item.title)}</strong><br><span class="muted">${escapeHtml(item.folderName)}</span></td>
        <td><a href="${escapeHtml(url)}">${escapeHtml(url)}</a></td>
        <td>${Math.round(item.maxBytes / 1024 / 1024)} MB</td>
        <td>${escapeHtml(item.allowedExts.join(", "))}</td>
        <td>
          <form method="post" action="/admin/requests/${escapeHtml(item.slug)}/delete" onsubmit="return confirm('링크를 삭제할까? 업로드된 파일은 유지돼.');">
            <button class="danger" type="submit">삭제</button>
          </form>
        </td>
      </tr>`;
    }).join("");

  res.send(page("사진 요청 관리자", `
    <section class="panel">
      <form method="post" action="/admin/logout" style="float:right"><button type="submit">로그아웃</button></form>
      <h1>사진 요청 관리자</h1>
      <form method="post" action="/admin/requests">
        <div class="row">
          <div>
            <label>요청 이름</label>
            <input name="title" placeholder="예: 2026 가족 모임" required>
          </div>
          <div>
            <label>링크 총 업로드 용량(MB)</label>
            <input name="maxMb" type="number" min="1" max="20480" value="500" required>
          </div>
        </div>
        <label>허용 확장자</label>
        <input name="allowedExts" value="${escapeHtml(defaultExts.join(", "))}" required>
        <div class="actions"><button type="submit">링크 만들기</button></div>
      </form>
      <h2>활성 링크</h2>
      <table class="table">
        <thead><tr><th>요청</th><th>링크</th><th>총 용량 제한</th><th>확장자</th><th></th></tr></thead>
        <tbody>${rows || `<tr><td colspan="5" class="muted">아직 만든 링크가 없어.</td></tr>`}</tbody>
      </table>
    </section>
  `));
});

app.post("/admin/requests", requireAdmin, async (req, res) => {
  const db = await readDb();
  const title = sanitizeName(req.body.title, "사진 요청");
  const allowedExts = normalizeExts(req.body.allowedExts);
  const maxMb = Math.max(1, Math.min(20480, Number(req.body.maxMb || 500)));
  if (allowedExts.length === 0) {
    res.status(400).send(page("입력 오류", `<section class="panel"><h1>입력 오류</h1><p class="muted">허용 확장자를 하나 이상 넣어야 해.</p><a class="button" href="/admin">돌아가기</a></section>`));
    return;
  }
  const slug = crypto.randomBytes(16).toString("base64url");
  const folderName = `${new Date().toISOString().slice(0, 10)}_${title}_${slug.slice(0, 6)}`;
  await fsp.mkdir(path.join(uploadsDir, folderName), { recursive: true });
  db.requests.push({
    slug,
    title,
    folderName,
    maxBytes: maxMb * 1024 * 1024,
    allowedExts,
    createdAt: new Date().toISOString()
  });
  await writeDb(db);
  res.redirect("/admin");
});

app.post("/admin/requests/:slug/delete", requireAdmin, async (req, res) => {
  const db = await readDb();
  const item = db.requests.find((request) => request.slug === req.params.slug && !request.deletedAt);
  if (item) item.deletedAt = new Date().toISOString();
  await writeDb(db);
  res.redirect("/admin");
});

async function findRequest(slug) {
  const db = await readDb();
  return db.requests.find((request) => request.slug === slug && !request.deletedAt);
}

function uploadMiddleware(request) {
  const tempDir = path.join(dataDir, "tmp", request.slug);
  const storage = multer.diskStorage({
    destination: async (req, file, cb) => {
      try {
        await fsp.mkdir(tempDir, { recursive: true });
        cb(null, tempDir);
      } catch (error) {
        cb(error);
      }
    },
    filename: (req, file, cb) => {
      const safe = sanitizeFileName(Buffer.from(file.originalname, "latin1").toString("utf8"));
      cb(null, `${Date.now()}_${crypto.randomBytes(4).toString("hex")}_${safe}`);
    }
  });

  return multer({
    storage,
    limits: { files: 100 },
    fileFilter: (req, file, cb) => {
      const ext = path.extname(file.originalname).replace(".", "").toLowerCase();
      cb(null, request.allowedExts.includes(ext));
    }
  }).array("files", 100);
}

app.get("/r/:slug", async (req, res) => {
  const request = await findRequest(req.params.slug);
  if (!request) {
    res.status(404).send(page("링크 없음", `<section class="panel"><h1>링크가 없거나 삭제됐어</h1></section>`));
    return;
  }
  res.send(await uploadPage(request));
});

app.post("/r/:slug", async (req, res) => {
  const request = await findRequest(req.params.slug);
  if (!request) {
    res.status(404).send(page("링크 없음", `<section class="panel"><h1>링크가 없거나 삭제됐어</h1></section>`));
    return;
  }
  const upload = uploadMiddleware(request);
  upload(req, res, async (error) => {
    if (error) {
      await removeFiles(req.files);
      const message = "업로드 중 오류가 났어. 파일 형식과 용량을 확인해줘.";
      if (wantsJson(req)) {
        res.status(400).json({ ok: false, message });
        return;
      }
      res.status(400).send(uploadResultPage(request, { heading: "업로드 실패", message, isError: true }));
      return;
    }
    try {
      const files = req.files || [];
      const targetDir = path.join(uploadsDir, request.folderName);
      const existingBytes = await directorySize(targetDir);
      const incomingBytes = files.reduce((total, file) => total + file.size, 0);
      if (existingBytes + incomingBytes > request.maxBytes) {
        await removeFiles(files);
        const remainMb = Math.max(0, Math.floor((request.maxBytes - existingBytes) / 1024 / 1024));
        if (wantsJson(req)) {
          res.status(400).json({ ok: false, message: `업로드 가능한 용량을 넘었어요. 남은 용량은 약 ${remainMb} MB예요.` });
          return;
        }
        res.status(400).send(uploadResultPage(request, { heading: "업로드 실패", message: `업로드 가능한 용량을 넘었어요. 남은 용량은 약 ${remainMb} MB예요.`, isError: true }));
        return;
      }
      await moveUploadedFiles(files, targetDir);
      if (wantsJson(req)) {
        res.json({ ok: true, count: files.length });
        return;
      }
      res.send(uploadResultPage(request, { heading: "감사합니다", message: `${files.length}개의 파일 잘 받았습니다. 소중한 오늘을 함께 남겨주셔서 감사드립니다.` }));
    } catch {
      await removeFiles(req.files);
      if (wantsJson(req)) {
        res.status(500).json({ ok: false, message: "파일을 저장하는 중 오류가 났어요. 다시 시도해 주세요." });
        return;
      }
      res.status(500).send(uploadResultPage(request, { heading: "업로드 실패", message: "파일을 저장하는 중 오류가 났어요. 다시 시도해 주세요.", isError: true }));
    }
  });
});

app.listen(port, () => {
  console.log(`photo request service listening on ${port}`);
});
