/** * Kelomn static-article law citation drawer — mirrors SPA LawChip + LawCitationModal. * Safe on production: embedded .lawquote text is always the fallback (RULE 1). * Live API enriches when resolver returns citation-grade varak; wrong hits are rejected. */ const THAI_DIGITS = "๐๑๒๓๔๕๖๗๘๙"; const OCS_SEARCH_URL = "https://www.ocs.go.th/searchlaw"; /** * --------------------------------------------------------------------------------------- * PDF-identical statutory-text formatter — JS port of * ~/.claude/skills/thai-legal-paragraph-format/assets/format_pdf_identical.py (+ the * kelomn_law_format.py primitives it depends on). Adds ONLY newlines + leading indent — * words 100% verbatim (RULE 1 of skill thai-legal-paragraph-format). See SKILL.md * "กฎ 3 เสา": (1) first-line indent every block, (2) blank line before every block incl. * อนุมาตรา, (3) natural flow within a block (collapse cosmetic column-wrap). * * Verified byte-for-byte identical to the Python reference on 6 real statutes (PDPA ม.19, * ม.37; ป.พ.พ. ม.34; ป.อ. ม.288; + 2 sections with real (ก)(ข)(ค)(ง) sub-items) — see * scratchpad/{py_reference_out.json,js_reference_out.json} diff (2026-07-11). * --------------------------------------------------------------------------------------- */ const TH2AR_MAP = (() => { const m = {}; for (let i = 0; i < THAI_DIGITS.length; i++) m[THAI_DIGITS[i]] = i; return m; })(); function lawThaiToInt(s) { let digits = ""; for (const ch of s) { if (Object.prototype.hasOwnProperty.call(TH2AR_MAP, ch)) digits += String(TH2AR_MAP[ch]); } return digits === "" ? -1 : parseInt(digits, 10); } // _PAREN_NUM = re.compile(r"\(([" + _TH + r"]+(?:/[" + _TH + r"]+)?)\)") const LAW_PAREN_NUM_SRC = "\\(([๐-๙]+(?:/[๐-๙]+)?)\\)"; // _LEADING_NUM = re.compile(r"^\s*\xa0*\s*\(([" + _TH + r"]+)\)") const LAW_LEADING_NUM = /^\s*\xa0*\s*\(([๐-๙]+)\)/; // _ENUM = re.compile(r"^\((?:[๐-๙]{1,3}|[ก-ฮ])\)") const LAW_ENUM_RE = /^\((?:[๐-๙]{1,3}|[ก-ฮ])\)/; // _SUBITEM_KO = re.compile(r"^\([ก-ฮ]\)") const LAW_SUBITEM_KO = /^\([ก-ฮ]\)/; // _VAKK_OPEN const LAW_VAKK_OPEN = new RegExp( "^(ถ้า|ในกรณี|ในการพิจารณา|ความผิดตาม|ความในวรรค|ความในมาตรา|บทบัญญัติ|" + "ต้องระวางโทษ|ผู้กระทำต้องระวางโทษ|ทั้งนี้|เมื่อ|ให้ถือ|ให้นำ|มิให้|" + "ห้ามมิให้|การกระทำตาม|เพื่อประโยชน์)", ); const LAW_INDENT = " "; // ~1 tab (level 1). (ก)(ข) sub-item = LAW_INDENT * 2 (level 2). function lawStripWs(s) { return s.replace(/\s/g, ""); } // _break_subitems_global(text, start_expected=1) function lawBreakSubitemsGlobal(text, startExpected = 1) { const re = new RegExp(LAW_PAREN_NUM_SRC, "g"); const out = []; let pos = 0; let expected = startExpected; let m; while ((m = re.exec(text)) !== null) { const numPart = m[1].split("/")[0]; if (lawThaiToInt(numPart) === expected) { let seg = text.slice(pos, m.index); if (seg.endsWith(" ") || seg.endsWith("\x00")) seg = seg.slice(0, -1); out.push(seg + "\n" + m[0]); pos = m.index + m[0].length; expected += 1; } } out.push(text.slice(pos)); return out.join(""); } // _break_line(line) function lawBreakLine(line) { const m = LAW_LEADING_NUM.exec(line); const start = m ? lawThaiToInt(m[1]) + 1 : 1; return lawBreakSubitemsGlobal(line, start); } // format_law_text_pre(text) function lawFormatTextPre(text) { const varak = text .split("\n") .map((v) => v.trim()) .filter((v) => v.length > 0); return varak.map(lawBreakLine).join("\n").trim(); } // _collapse_cosmetic(text) — collapse cosmetic column-wrap \n into a space; keep \n only // before a structural boundary (อนุมาตรา marker / indented line / วรรค-opener). function lawCollapseCosmetic(text) { const lines = text.split("\n"); let out = lines[0].replace(/\s+$/, ""); for (let i = 1; i < lines.length; i++) { const nxt = lines[i]; const s = nxt.trim(); const indented = nxt.length - nxt.replace(/^ +/, "").length >= 2; const keep = LAW_ENUM_RE.test(s) || indented || LAW_VAKK_OPEN.test(s); if (keep) { out += "\n" + s; } else { out += (out.endsWith(" ") ? "" : " ") + s; } } return out; } // Shared block-builder (indent every block + blank line before every block) + RULE-1 gate. function lawBuildBlocks(pre, sourceForGate) { const blocks = []; for (const ln of pre.split("\n")) { let s = ln.trim(); s = s.replace(/^ +/, "").replace(/^\xa0+/, ""); if (!s) continue; const indent = LAW_SUBITEM_KO.test(s) ? LAW_INDENT + LAW_INDENT : LAW_INDENT; blocks.push(indent + s); } const out = blocks.join("\n\n"); if (lawStripWs(out) !== lawStripWs(sourceForGate)) { return null; // RULE-1 fail — honest-hold, caller must fall back to raw/original text } return out; } /** * formatPdfIdentical(text) — single flat string input (text_varak / text / embedded
 * lawquote text). Returns the PDF-identical rendering, or null if RULE-1 fails (caller
 * must then display the untouched original — never a mangled render).
 */
function formatPdfIdentical(text) {
  if (!text || !text.trim()) return "";
  const pre = lawFormatTextPre(lawCollapseCosmetic(text));
  return lawBuildBlocks(pre, text);
}

/**
 * formatPdfIdenticalParagraphs(paragraphs) — ADAPTATION for API responses that already
 * segment a มาตรา into a `paragraphs: string[]` array (one real วรรค per element — a KNOWN
 * boundary, not a guess). Unlike formatPdfIdentical() we do NOT run cosmetic-collapse
 * across the whole joined text, because that could wrongly MERGE two real, distinct
 * paragraphs whenever a paragraph's first line doesn't happen to start with a recognized
 * วรรค-opener word (the cosmetic-collapse heuristic is designed for a single flat OCR
 * string where paragraph boundaries are NOT already known — here they are, so we must not
 * risk collapsing them). We only collapse cosmetic wraps WITHIN each already-known
 * paragraph, then run the same shared indent/blank-line pass.
 */
function formatPdfIdenticalParagraphs(paragraphs) {
  if (!paragraphs || !paragraphs.length) return "";
  const collapsed = paragraphs.map((p) => lawCollapseCosmetic(p));
  const pre = lawFormatTextPre(collapsed.join("\n"));
  return lawBuildBlocks(pre, paragraphs.join("\n"));
}

const API_BASE = (() => {
  const host = typeof location !== "undefined" ? location.hostname : "";
  if (host === "localhost" || host === "127.0.0.1") return "";
  return "https://api.kelomn.com";
})();

function thaiToArabic(value) {
  return String(value || "").replace(/[๐-๙]/g, (ch) => String(THAI_DIGITS.indexOf(ch)));
}

function arabicToThai(value) {
  return String(value || "").replace(/\d/g, (d) => THAI_DIGITS[Number(d)]);
}

function normalizeWs(text) {
  return String(text || "").replace(/\s+/g, " ").trim();
}

function apiLooksWrong(actHint, sectionArabic, apiText, fallbackText) {
  const api = normalizeWs(apiText);
  const fb = normalizeWs(fallbackText);
  if (!api || !fb || api === fb) return false;
  const hint = normalizeWs(actHint).toLowerCase();
  if (hint.includes("ลิขสิทธิ์") && sectionArabic === "15") {
    if (fb.includes("สิทธิแต่ผู้เดียว") && api.includes("มิให้นำบทบัญญัติ")) return true;
    if (fb.includes("ทำซ้ำหรือดัดแปลง") && !api.includes("ทำซ้ำ")) return true;
  }
  return false;
}

function parseActAndSections(raw) {
  const text = normalizeWs(raw.replace(/<[^>]+>/g, " "));
  if (!text) return null;

  let act = text;
  let sectionPart = "";
  const m = text.match(/^(.*?)\s*มาตรา\s*([\d๐-๙,\s]+)$/u);
  if (m) {
    act = normalizeWs(m[1]);
    sectionPart = m[2];
  }

  const sections = sectionPart
    ? sectionPart
        .split(/[,,]/)
        .map((s) => thaiToArabic(s.trim()))
        .filter(Boolean)
    : [];

  if (!act) return null;
  return { act, sections };
}

function parseLawquoteBlock(quoteEl) {
  const head = quoteEl.querySelector(".head");
  const pre = quoteEl.querySelector("pre.law-text, pre");
  const fallbackText = pre ? pre.textContent || "" : "";

  let parsed = head ? parseActAndSections(head.textContent || "") : null;

  const cite =
    quoteEl.nextElementSibling?.classList?.contains("cite-source")
      ? quoteEl.nextElementSibling
      : quoteEl.parentElement?.querySelector(".cite-source");

  if (cite) {
    const strong = cite.querySelector("strong");
    const fromCite = parseActAndSections(strong ? strong.textContent || "" : cite.textContent || "");
    if (fromCite) {
      parsed = {
        act: fromCite.act || parsed?.act || "",
        sections: fromCite.sections?.length ? fromCite.sections : parsed?.sections || [],
      };
    }
  }

  if (!parsed?.act) return null;

  if (!parsed.sections.length) {
    const m = (head?.textContent || fallbackText).match(/มาตรา\s*([๐-๙\d]+)/u);
    if (m) parsed.sections = [thaiToArabic(m[1])];
  }

  return {
    act: parsed.act,
    sections: parsed.sections,
    fallbackText,
    title: head ? normalizeWs(head.textContent || "") : parsed.act,
  };
}

async function fetchLawCitation(act, section) {
  const params = new URLSearchParams({ law: act, section });
  const url = `${API_BASE}/api/law-by-citation?${params}`;
  const res = await fetch(url, { credentials: "include" });
  if (!res.ok) {
    try {
      const body = await res.json();
      const detail = body?.detail;
      if (detail && typeof detail === "object") return detail;
    } catch {
      /* ignore */
    }
    return { resolved: false };
  }
  return res.json();
}

// Applies formatPdfIdentical* and honest-holds to the untouched raw text if the RULE-1
// gate fails (words must stay 100% verbatim — never ship a mangled render).
function lawFormatOrRaw(raw) {
  const out = formatPdfIdentical(raw);
  return out !== null ? out : raw;
}

function resolveDisplayBody(data, fallbackText, act, section) {
  // Source precedence UNCHANGED from prior behavior (paragraphs → text_varak/text →
  // embedded lawquote) — only the FORMATTING applied at each stage changes (skill
  // thai-legal-paragraph-format: indent every block + blank line before every block incl.
  // อนุมาตรา + collapse cosmetic column-wrap). RULE-1 (words verbatim) is gated at every
  // stage; on failure we honest-hold to the untouched raw text rather than risk a
  // mis-formatted render.
  const paragraphs = data?.paragraphs;
  if (Array.isArray(paragraphs) && paragraphs.length) {
    const cleaned = paragraphs
      .map((p) => (typeof p === "string" ? p : p?.text || "").trim())
      .filter(Boolean);
    if (cleaned.length) {
      const formatted = formatPdfIdenticalParagraphs(cleaned);
      const body = formatted !== null ? formatted : cleaned.join("\n\n");
      return { body, source: "api-paragraphs" };
    }
  }
  const text = (data?.text_varak || data?.text || "").trim();
  if (text && !apiLooksWrong(act, section, text, fallbackText)) {
    return { body: lawFormatOrRaw(text), source: data?.from_text_varak ? "api-varak" : "api-text" };
  }
  if (fallbackText.trim()) {
    return { body: lawFormatOrRaw(fallbackText.trim()), source: "embedded-lawquote" };
  }
  if (text) return { body: lawFormatOrRaw(text), source: "api-fallback" };
  return { body: "", source: "none" };
}

function el(tag, className, text) {
  const node = document.createElement(tag);
  if (className) node.className = className;
  if (text != null) node.textContent = text;
  return node;
}

function renderBodyParagraphs(container, body) {
  container.replaceChildren();
  const quote = el("div", "kelomn-law-drawer-quote");
  for (const para of body.split(/\n{2,}/).filter(Boolean)) {
    quote.appendChild(el("p", "kelomn-law-drawer-para", para));
  }
  container.appendChild(quote);
}

function closeDrawer() {
  const root = document.getElementById("kelomn-law-drawer-root");
  if (!root) return;
  root.replaceChildren();
  root.setAttribute("aria-hidden", "true");
  document.body.classList.remove("kelomn-law-drawer-open");
}

function openDrawer({ act, section, sectionDisplay, title, fallbackText }) {
  let root = document.getElementById("kelomn-law-drawer-root");
  if (!root) {
    root = el("div");
    root.id = "kelomn-law-drawer-root";
    document.body.appendChild(root);
  }
  root.setAttribute("aria-hidden", "false");
  document.body.classList.add("kelomn-law-drawer-open");
  root.replaceChildren();

  const overlay = el("button", "kelomn-law-drawer-overlay");
  overlay.type = "button";
  overlay.setAttribute("aria-label", "ปิด");
  overlay.addEventListener("click", closeDrawer);

  const panel = el("aside", "kelomn-law-drawer-panel");
  panel.setAttribute("role", "dialog");
  panel.setAttribute("aria-modal", "true");
  panel.setAttribute("aria-labelledby", "kelomn-law-drawer-title");

  const header = el("header", "kelomn-law-drawer-header");
  header.appendChild(el("div", "kelomn-law-drawer-icon", "§"));

  const titles = el("div", "kelomn-law-drawer-titles");
  titles.appendChild(el("h2", null, `${title || act} ม.${sectionDisplay || section}`));
  titles.lastChild.id = "kelomn-law-drawer-title";
  const actTitle = el("p", "kelomn-law-drawer-sub");
  titles.appendChild(actTitle);
  header.appendChild(titles);

  const closeBtn = el("button", "kelomn-law-drawer-close", "×");
  closeBtn.type = "button";
  closeBtn.setAttribute("aria-label", "ปิดหน้าต่าง");
  closeBtn.addEventListener("click", closeDrawer);
  header.appendChild(closeBtn);

  const bodyWrap = el("div", "kelomn-law-drawer-body");
  bodyWrap.appendChild(el("p", "kelomn-law-drawer-loading", "กำลังดึงตัวบท…"));

  const footer = el("footer", "kelomn-law-drawer-footer");
  const ocs = document.createElement("a");
  ocs.href = OCS_SEARCH_URL;
  ocs.target = "_blank";
  ocs.rel = "noopener noreferrer";
  ocs.textContent = "ค้นตัวบทฉบับทางการที่ สำนักงานคณะกรรมการกฤษฎีกา ↗";
  footer.appendChild(ocs);
  footer.appendChild(
    el("p", "kelomn-law-drawer-note", "เพื่อการศึกษา · โปรดตรวจสำเนาทางการก่อนใช้อ้างอิง"),
  );

  panel.append(header, bodyWrap, footer);
  root.append(overlay, panel);

  fetchLawCitation(act, section)
    .then((data) => {
      const { body, source } = resolveDisplayBody(data, fallbackText, act, section);
      if (data?.act_title) actTitle.textContent = data.act_title;
      bodyWrap.replaceChildren();
      if (!body) {
        const empty = el("div", "kelomn-law-drawer-empty");
        empty.appendChild(el("p", "kelomn-law-drawer-empty-title", "ยังไม่มีตัวบทของมาตรานี้ในระบบ"));
        empty.appendChild(
          el("p", null, "โปรดดูตัวบทจากแหล่งทางการก่อนใช้อ้างอิงนะคะ"),
        );
        bodyWrap.appendChild(empty);
        return;
      }
      renderBodyParagraphs(bodyWrap, body);
      const badgeText =
        source === "embedded-lawquote"
          ? "แสดงจากตัวบทในบทความ (ตรวจสอบแล้ว)"
          : source.startsWith("api")
            ? "ดึงสดจาก Kelomn Law API"
            : "";
      if (badgeText) bodyWrap.appendChild(el("p", "kelomn-law-drawer-badge", badgeText));
    })
    .catch(() => {
      bodyWrap.replaceChildren();
      const fb = (fallbackText || "").trim();
      if (fb) {
        renderBodyParagraphs(bodyWrap, fb);
        bodyWrap.appendChild(
          el("p", "kelomn-law-drawer-badge", "แสดงจากตัวบทในบทความ (API ไม่พร้อม)"),
        );
      } else {
        bodyWrap.appendChild(
          el("p", "kelomn-law-drawer-error", "ดึงตัวบทไม่สำเร็จ · ลองใหม่อีกครั้ง"),
        );
      }
    });

  function onKey(e) {
    if (e.key === "Escape") {
      closeDrawer();
      window.removeEventListener("keydown", onKey);
    }
  }
  window.addEventListener("keydown", onKey);
}

function injectChipsForQuote(quoteEl) {
  if (quoteEl.dataset.kelomnLawDrawer) return;
  const meta = parseLawquoteBlock(quoteEl);
  if (!meta || !meta.sections.length) return;
  quoteEl.dataset.kelomnLawDrawer = "1";

  const cite =
    quoteEl.nextElementSibling?.classList?.contains("cite-source")
      ? quoteEl.nextElementSibling
      : null;

  const wrap = el("div", "law-cite-chips");
  wrap.setAttribute("role", "group");
  wrap.setAttribute("aria-label", "เปิดตัวบทมาตรา");

  for (const section of meta.sections) {
    const btn = el("button", "law-cite-chip", `ม.${arabicToThai(section)}`);
    btn.type = "button";
    btn.title = "คลิกเพื่ออ่านตัวบทมาตรานี้";
    btn.addEventListener("click", () => {
      openDrawer({
        act: meta.act,
        section,
        sectionDisplay: arabicToThai(section),
        title: meta.title,
        fallbackText: meta.fallbackText,
      });
    });
    wrap.appendChild(btn);
  }

  if (cite) cite.insertAdjacentElement("afterend", wrap);
  else quoteEl.insertAdjacentElement("afterend", wrap);
}

function boot() {
  if (document.body.dataset.kelomnLawDrawerBoot) return;
  document.body.dataset.kelomnLawDrawerBoot = "1";
  document.querySelectorAll(".lawquote").forEach(injectChipsForQuote);
}

if (document.readyState === "loading") {
  document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
  boot();
}