/**
 * md2hwpx — PresetManager (v0.3.0)
 *
 * 양식 hwpx (.hwpx) 를 분석해서 프리셋(스타일 풀) 을 추출하는 코어 모듈.
 *
 * 알고리즘 일부는 msjang/pypandoc-hwpx (MIT, 2024) 의
 * PandocToHwpx.py 의 outline level 매핑 + style 수집 로직을 JavaScript 로 재구현.
 * 참조: https://github.com/msjang/pypandoc-hwpx
 *
 * 사용 데이터 모델:
 *   StyleInfo {
 *     id, type, name, engName, paraPrIDRef, charPrIDRef,
 *     usage,             // section0.xml 에서 styleIDRef 참조 횟수
 *     char: CharPrMeta,  // 폰트·크기·굵기·이탤릭·색상
 *     para: ParaPrMeta,  // 정렬·줄간격
 *     external: boolean, // 외부 출처 추정 (엑셀·MS Word 등)
 *     externalSource: string|null
 *   }
 *
 * 한컴 HWPX 의 단위:
 *   - height 는 1/100pt (예: 900 → 9pt)
 *   - lineSpacing PERCENT 100 → 100%
 *
 * 참조 표준: KS X 6101:2018 (OWPML)
 */
'use strict';

window.PresetManager = (() => {

  // ────────────────────────────────────────────────────────────────────
  // 1. hwpx ZIP 추출 — Contents/header.xml + Contents/section0.xml 만 필요
  // ────────────────────────────────────────────────────────────────────

  async function extractHwpx(arrayBuffer) {
    if (typeof JSZip === 'undefined') {
      throw new Error('JSZip 이 로드되지 않았습니다');
    }
    const zip = await JSZip.loadAsync(arrayBuffer);

    // mimetype 확인 (악성 ZIP 차단)
    const mimeFile = zip.file('mimetype');
    if (mimeFile) {
      const mime = (await mimeFile.async('string')).trim();
      if (mime !== 'application/hwp+zip') {
        throw new Error(`HWPX 파일이 아닙니다 (mimetype: ${mime})`);
      }
    }

    const headerFile  = zip.file('Contents/header.xml');
    const sectionFile = zip.file('Contents/section0.xml');
    if (!headerFile)  throw new Error('Contents/header.xml 누락 — HWPX 파일 아님');
    if (!sectionFile) throw new Error('Contents/section0.xml 누락 — HWPX 파일 아님');

    const header  = await headerFile.async('string');
    const section = await sectionFile.async('string');
    // ★ v1.6.0: 다중 구역(section1.xml 이후) 수집 — 사용량 집계·재스타일이 전 구역을 보도록.
    //   (종전엔 section0 만 → 구역1 이후에만 쓰인 스타일이 "미사용"으로 분류돼 매핑 누락)
    const sectionNames = Object.keys(zip.files)
      .filter(n => /^Contents\/section\d+\.xml$/.test(n))
      .sort((a, b) => (+a.match(/(\d+)/)[1]) - (+b.match(/(\d+)/)[1]));
    const sections = [];
    for (const n of sectionNames) sections.push({ name: n, xml: await zip.file(n).async('string') });
    return { header, section, sections, zip };
  }

  // ────────────────────────────────────────────────────────────────────
  // 2-pre. fontfaces 파싱
  //   <hh:fontfaces><hh:fontface lang="HANGUL" fontCnt="N"><hh:font id="N" face="이름" .../></hh:fontface>...
  // ────────────────────────────────────────────────────────────────────

  /**
   * @returns {Object<string, Array<{id, face, type}>>}
   *   { HANGUL: [{id:'0', face:'한컴바탕', type:'TTF'}, ...], LATIN: [...], ... }
   *   인덱스로도 접근 가능 — result.HANGUL[5] 가 ID 5 폰트
   */
  function parseFontFaces(headerXmlText) {
    const result = {};
    // <hh:fontface lang="HANGUL" fontCnt="N"> ... </hh:fontface>
    const ffRe = /<hh:fontface\s+([^>]*?)>([\s\S]*?)<\/hh:fontface>/g;
    let m;
    while ((m = ffRe.exec(headerXmlText)) !== null) {
      const lang = pickAttr(m[1], /\blang="([^"]+)"/);
      if (!lang) continue;
      const body = m[2];
      const fonts = [];
      const fontRe = /<hh:font\s+([^>]*?)(?:\/>|>)/g;
      let fm;
      while ((fm = fontRe.exec(body)) !== null) {
        const attrs = fm[1];
        const id   = pickAttr(attrs, /\bid="([^"]+)"/);
        const face = pickAttr(attrs, /\bface="([^"]+)"/);
        const type = pickAttr(attrs, /\btype="([^"]+)"/);
        if (id !== null && face !== null) {
          fonts[parseInt(id, 10)] = { id, face: decodeXmlEntities(face), type };
        }
      }
      result[lang] = fonts;
    }
    return result;
  }

  // ────────────────────────────────────────────────────────────────────
  // 2. 스타일 풀 추출 — <hh:style ... />
  // ────────────────────────────────────────────────────────────────────

  /**
   * header.xml 텍스트에서 모든 <hh:style ... /> 추출
   * attribute 순서 무관 + 추가 attribute 허용 (lock 등이 끼어들 수 있음)
   * @returns {Array<StyleInfo>} (char·para·usage·external 은 비어있음 — 후속 함수가 채움)
   */
  function parseStyles(headerXmlText) {
    const styles = [];
    // 자기닫기 (<hh:style .../>) 또는 일반 (<hh:style ...></hh:style>) 양쪽 매칭
    const re = /<hh:style\s+([^>]*?)\/?>/g;
    let m;
    while ((m = re.exec(headerXmlText)) !== null) {
      const attrs = m[1];
      const id          = pickAttr(attrs, /\bid="([^"]+)"/);
      const type        = pickAttr(attrs, /\btype="([^"]*)"/);
      const name        = pickAttr(attrs, /\bname="([^"]*)"/);
      const engName     = pickAttr(attrs, /\bengName="([^"]*)"/);
      const paraPrIDRef = pickAttr(attrs, /\bparaPrIDRef="([^"]+)"/);
      const charPrIDRef = pickAttr(attrs, /\bcharPrIDRef="([^"]+)"/);
      // 필수 4개 모두 있어야 유효 스타일 (id, name, paraPrIDRef, charPrIDRef)
      if (id !== null && name !== null && paraPrIDRef !== null && charPrIDRef !== null) {
        styles.push({
          id,
          type:        type || 'PARA',
          name:        decodeXmlEntities(name),
          engName:     decodeXmlEntities(engName || ''),
          paraPrIDRef,
          charPrIDRef,
        });
      }
    }
    return styles;
  }

  // ────────────────────────────────────────────────────────────────────
  // 3. charPr / paraPr 정의 추출 — 양식 정보 표시용
  // ────────────────────────────────────────────────────────────────────

  /**
   * @returns {CharPrMeta|null}
   *   height        : 글자 크기 (1/100pt) — 예 900 = 9pt
   *   bold/italic/underline/strikeout : 불리언
   *   textColor     : #RRGGBB
   *   hangulFontId  : 한글 폰트 ID (fontfaces[HANGUL] 에서 face 조회)
   *   latinFontId   : 영문 폰트 ID (fontfaces[LATIN])
   *   ratio         : 장평 % (50~200, 기본 100)
   *   spacing       : 자간 % (-50~+50, 기본 0)
   *   relSz         : 상대 크기 % (10~250, 기본 100)
   *   offset        : 글자 위치 % (-100~+100, 기본 0)
   */
  function parseCharPrMeta(headerXmlText, charPrId) {
    // ★ v1.6.0: 자기닫힘(<hh:charPr id="N" .../>) 처리 — 종전 정규식은 자기닫힘 태그의
    //   `/>` 를 열림으로 보고 다음 charPr 의 닫는 태그까지 삼켜 "이웃 블록의 bold/밑줄"을
    //   이 charPr 의 메타로 오독했음(전부 기본값인 charPr 는 자식 없이 자기닫힘 가능).
    const selfM = headerXmlText.match(new RegExp(
      '<hh:charPr id="' + escapeRe(charPrId) + '"[^>]*/>'
    ));
    let block, openTag;
    if (selfM) {
      openTag = selfM[0];
      block = '';                                   // 자식 없음 — 장식 전부 기본(꺼짐)
    } else {
      const re = new RegExp(
        '<hh:charPr id="' + escapeRe(charPrId) + '"[^>]*>([\\s\\S]*?)</hh:charPr>'
      );
      const m = headerXmlText.match(re);
      if (!m) return null;
      block = m[1];
      openTag = headerXmlText.match(new RegExp(
        '<hh:charPr id="' + escapeRe(charPrId) + '"[^>]*>'
      ))?.[0] || '';
    }
    // ★ v1.3.0: 밑줄·취소선 ON 판정 = "type 속성이 있고 NONE 이 아닐 때"만.
    //   한컴은 꺼진 상태도 <hh:strikeout shape="SOLID"/> 처럼 shape 만 남겨 쓰는 일이 많음
    //   (type 기본값 = NONE = 꺼짐). 종전의 shape 기준 판정은 멀쩡한 문서를 '취소선'으로
    //   오인 → 재스타일이 절 제목 전체에 취소선을 그리는 실버그의 원인이었음 (속성 순서 무관).
    const decoOn = (tag) => {
      const em = block.match(new RegExp('<hh:' + tag + '\\b[^>]*/?>'));
      if (!em) return false;
      const tm = /\btype="([^"]*)"/.exec(em[0]);
      return !!(tm && tm[1] && tm[1] !== 'NONE');
    };
    return {
      height:        toInt(pickAttr(openTag, /\bheight="([^"]+)"/)),
      textColor:     pickAttr(openTag, /\btextColor="([^"]+)"/),
      bold:          /<hh:bold\s*\/>/.test(block),
      italic:        /<hh:italic\s*\/>/.test(block),
      underline:     decoOn('underline'),
      strikeout:     decoOn('strikeout'),
      hangulFontId:  pickAttr(block, /<hh:fontRef[^/]*\bhangul="([^"]+)"/),
      latinFontId:   pickAttr(block, /<hh:fontRef[^/]*\blatin="([^"]+)"/),
      ratio:         toInt(pickAttr(block, /<hh:ratio[^/]*\bhangul="([^"]+)"/)),
      spacing:       toInt(pickAttr(block, /<hh:spacing[^/]*\bhangul="([^"]+)"/)),
      relSz:         toInt(pickAttr(block, /<hh:relSz[^/]*\bhangul="([^"]+)"/)),
      offset:        toInt(pickAttr(block, /<hh:offset[^/]*\bhangul="([^"]+)"/)),
    };
  }

  /**
   * @returns {ParaPrMeta|null}
   *   align: LEFT|RIGHT|CENTER|JUSTIFY|DISTRIBUTE|DIVISION
   *   lineSpacingType: PERCENT|FIXED|MARGIN
   *   lineSpacingValue: 숫자 (PERCENT 면 %, FIXED 면 1/100pt)
   *   headingType: NONE|BULLET|NUMBER|OUTLINE  (v1.3.0 — 글머리 종류)
   *   headingIdRef: 글머리/번호 정의 id (bullets/numberings 테이블)
   *   headingLevel: 개요 수준
   */
  function parseParaPrMeta(headerXmlText, paraPrId) {
    const re = new RegExp(
      '<hh:paraPr id="' + escapeRe(paraPrId) + '"[^>]*>([\\s\\S]*?)</hh:paraPr>'
    );
    const m = headerXmlText.match(re);
    if (!m) return null;
    const block = m[1];
    // v1.3.0: 글머리(heading) — 스타일 매칭의 1순위 정체성 (네모는 네모로, 동그라미는 동그라미로)
    const hm = block.match(/<hh:heading\b[^>]*\/?>/);
    return {
      align:            pickAttr(block, /<hh:align[^/]*horizontal="([^"]+)"/),
      lineSpacingType:  pickAttr(block, /<hh:lineSpacing[^/]*type="([^"]+)"/),
      lineSpacingValue: toInt(pickAttr(block, /<hh:lineSpacing[^/]*value="([^"]+)"/)),
      headingType:      hm ? (pickAttr(hm[0], /\btype="([^"]+)"/) || 'NONE') : 'NONE',
      headingIdRef:     hm ? pickAttr(hm[0], /\bidRef="([^"]+)"/) : null,
      headingLevel:     hm ? toInt(pickAttr(hm[0], /\blevel="([^"]+)"/)) : null,
    };
  }

  /**
   * v1.3.0: bullets 테이블 파싱 — { bulletId: char }
   *   <hh:bullet id="N" char="■" .../> (속성 순서 무관)
   */
  function parseBullets(headerXmlText) {
    const map = {};
    const re = /<hh:bullet\b[^>]*?>/g;
    let m;
    while ((m = re.exec(headerXmlText)) !== null) {
      const tag = m[0];
      const id = pickAttr(tag, /\bid="([^"]+)"/);
      const ch = pickAttr(tag, /\bchar="([^"]*)"/);
      if (id != null && ch != null) map[id] = decodeXmlEntities(ch);
    }
    return map;
  }

  // ────────────────────────────────────────────────────────────────────
  // 4. section0.xml 사용량 집계
  // ────────────────────────────────────────────────────────────────────

  /**
   * section0.xml 안의 styleIDRef 모두 카운트
   * @returns {Object<string, number>} {styleId: count}
   */
  function countStyleUsage(sectionXmlText) {
    const counts = {};
    const re = /styleIDRef="([^"]+)"/g;
    let m;
    while ((m = re.exec(sectionXmlText)) !== null) {
      const id = m[1];
      counts[id] = (counts[id] || 0) + 1;
    }
    return counts;
  }

  // ────────────────────────────────────────────────────────────────────
  // 5. 외부 출처 노이즈 추정
  //    엑셀·웹·MS Word 등에서 복사·붙여넣기로 따라 들어온 스타일 추정
  // ────────────────────────────────────────────────────────────────────

  /**
   * @returns {{isExternal: boolean, source: string|null}}
   */
  function detectExternalNoise(name) {
    if (!name) return { isExternal: false, source: null };
    // 엑셀 자동 생성 스타일 패턴: xl65, xl66, xl67 ...
    if (/^xl\d+$/i.test(name)) return { isExternal: true, source: '엑셀' };
    // MS Word 기본 스타일명
    if (/^Normal$/.test(name)) return { isExternal: true, source: 'MS Word' };
    if (/^Body\s*Text$/i.test(name)) return { isExternal: true, source: 'MS Word' };
    if (/^heading\s*\d+$/i.test(name)) return { isExternal: true, source: 'MS Word' };
    if (/^Default\s*Paragraph\s*Font$/i.test(name)) return { isExternal: true, source: 'MS Word' };
    // HTML/CSS 자동 생성 패턴: msoNormal, p1, p2 ...
    if (/^mso[A-Z]/.test(name)) return { isExternal: true, source: 'HTML/Office' };
    return { isExternal: false, source: null };
  }

  // ────────────────────────────────────────────────────────────────────
  // 6. 종합 분석 (편의 래퍼)
  //    1회 호출로 전체 추출·메타·사용량·외부추정 모두 채운 StyleInfo[] 반환
  // ────────────────────────────────────────────────────────────────────

  async function analyzeHwpx(arrayBuffer) {
    const { header, section, sections, zip } = await extractHwpx(arrayBuffer);
    // ★ v1.6.0: 사용량·표 템플릿·변형 스캔은 전 구역 합본으로 (구역 순서 보존 — 첫 표 = 문서 첫 표)
    const allSections = (sections && sections.length) ? sections.map(s => s.xml).join('\n') : section;

    const fontfaces = parseFontFaces(header);
    const styles = parseStyles(header);
    const usage  = countStyleUsage(allSections);
    const borderFills = parseBorderFills(header); // v0.5.8: 표 변환용
    const tableTemplate = extractTableTemplate(allSections); // v0.5.10: 양식의 첫 표 셀별 borderFillID
    const bullets = parseBullets(header); // v1.3.0: 글머리 기호 테이블 (스타일 매칭용)

    const enriched = styles.map(s => {
      const char = parseCharPrMeta(header, s.charPrIDRef);
      const para = parseParaPrMeta(header, s.paraPrIDRef);
      // v1.3.0: 글머리 실제 문자 해석 (BULLET 이면 bullets 테이블에서)
      if (para) {
        para.bulletChar = (para.headingType === 'BULLET' && para.headingIdRef != null)
          ? (bullets[para.headingIdRef] || null) : null;
      }
      const noise = detectExternalNoise(s.name);
      // 한글 폰트 이름 조회 (fontfaces[HANGUL][N])
      const hangulFont = char && char.hangulFontId != null && fontfaces.HANGUL
        ? fontfaces.HANGUL[parseInt(char.hangulFontId, 10)]
        : null;
      return {
        ...s,
        usage: usage[s.id] || 0,
        char,
        para,
        hangulFontName: hangulFont ? hangulFont.face : null,
        external: noise.isExternal,
        externalSource: noise.source,
      };
    });

    // 기본 정렬: 사용횟수 내림차순. 0회는 외부추정·이름 순으로 뒤로
    enriched.sort((a, b) => {
      if (a.usage !== b.usage) return b.usage - a.usage;
      if (a.external !== b.external) return a.external ? 1 : -1;
      return Number(a.id) - Number(b.id);
    });

    return {
      styles: enriched,
      fontfaces,
      borderFills, // v0.5.8: 표 변환 시 매핑·자동 추천에 사용
      tableTemplate, // v0.5.10: 양식 첫 표의 셀 위치별 borderFillID 패턴
      bullets, // v1.3.0: { bulletId: char } — 글머리 변형(서브스타일) 식별용
      headerXml: header,
      sectionXml: allSections,  // ★ v1.6.0: 변형(글머리) 스캔 등이 전 구역을 보도록 합본
      zip,
      summary: {
        total: enriched.length,
        used:     enriched.filter(s => s.usage > 0).length,
        unused:   enriched.filter(s => s.usage === 0).length,
        external: enriched.filter(s => s.external).length,
      },
    };
  }

  // ────────────────────────────────────────────────────────────────────
  // borderFill 풀 분석 (v0.5.8 — 표 변환용)
  //   각 borderFill 의 음영 여부 / 이중선 여부 / 색상 등 분석
  // ────────────────────────────────────────────────────────────────────

  /**
   * @returns Array<{
   *   id, hasFill, fillColor,
   *   topType, topWidth, bottomType, bottomWidth,
   *   leftType, rightType,
   *   isHeaderLike,    // 음영 + 아래 이중선 (헤더 후보)
   *   isBodyLike,      // 위 이중선 (본문 첫행 후보 / 헤더 바로 아래)
   *   allSolid,        // 4면 모두 SOLID (외곽 후보)
   * }>
   */
  function parseBorderFills(headerXmlText) {
    const result = [];
    // 자기닫기 또는 일반 둘 다 매칭
    const re = /<hh:borderFill\s+id="([^"]+)"[^>]*>([\s\S]*?)<\/hh:borderFill>/g;
    let m;
    while ((m = re.exec(headerXmlText)) !== null) {
      const id    = m[1];
      const body  = m[2];

      const topType    = pickAttr(body, /<hh:topBorder[^/]*type="([^"]+)"/);
      const topWidth   = pickAttr(body, /<hh:topBorder[^/]*width="([^"]+)"/);
      const bottomType = pickAttr(body, /<hh:bottomBorder[^/]*type="([^"]+)"/);
      const bottomWidth= pickAttr(body, /<hh:bottomBorder[^/]*width="([^"]+)"/);
      const leftType   = pickAttr(body, /<hh:leftBorder[^/]*type="([^"]+)"/);
      const leftWidth  = pickAttr(body, /<hh:leftBorder[^/]*width="([^"]+)"/);  // v0.7.14
      const rightType  = pickAttr(body, /<hh:rightBorder[^/]*type="([^"]+)"/);
      const rightWidth = pickAttr(body, /<hh:rightBorder[^/]*width="([^"]+)"/); // v0.7.14

      const fillColor  = pickAttr(body, /<hc:winBrush[^/]*faceColor="([^"]+)"/);
      // v0.5.12: fillColor 가 "none" 또는 흰색/검정/빈 문자열이면 음영 X
      const hasFill    = !!fillColor && fillColor !== '#FFFFFF' && fillColor !== '#000000' && String(fillColor).toLowerCase() !== 'none';
      // alpha=0 이면 음영 안 보임 (한컴 표준). 명시적 fillBrush 존재만 의미.
      const hasFillBrush = /<hc:fillBrush\b/.test(body);

      const isDouble = (t) => t && /DOUBLE/i.test(t);
      const isSolid  = (t) => t === 'SOLID';
      const isNone   = (t) => !t || t === 'NONE';

      const hasTopDouble    = isDouble(topType);
      const hasBottomDouble = isDouble(bottomType);

      // 헤더 후보: 음영(faceColor 명시) + 아래 이중선
      const isHeaderLike = hasFill && hasBottomDouble;
      // 본문 후보: 음영 없음 + 위 이중선 (헤더 바로 아래 행)
      const isBodyLike   = !hasFill && hasTopDouble;
      // 외곽 후보: 4면 SOLID + 음영 없음
      const allSolid     = isSolid(topType) && isSolid(bottomType) && isSolid(leftType) && isSolid(rightType) && !hasFill;

      result.push({
        id, hasFill, fillColor, hasFillBrush,
        topType, topWidth, bottomType, bottomWidth,
        leftType, leftWidth, rightType, rightWidth, // v0.7.14: leftWidth, rightWidth 추가
        hasTopDouble, hasBottomDouble,
        isHeaderLike, isBodyLike, allSolid,
      });
    }
    return result;
  }

  // ────────────────────────────────────────────────────────────────────
  // v0.7.14: 양식 borderFill 풀 → 그래픽 스타일러 형식으로 매핑
  //   Phase C — 양식 hwpx 업로드 시 그 양식의 표 디자인을 SVG 초기값으로 반영
  // ────────────────────────────────────────────────────────────────────

  /**
   * borderFill 의 width 문자열 (예: "0.4 mm") + type 을 그래픽 스타일러 enum 으로 변환
   * @param {string} type  - 'NONE' | 'SOLID' | 'DOUBLE_SLIM' 등
   * @param {string} width - '0.1 mm' / '0.12 mm' / '0.4 mm' / '0.5 mm'
   * @returns {string} 'NONE' | 'THIN' | 'THICK' | 'DOUBLE'
   */
  function sideToStylerEnum(type, width) {
    if (!type || type === 'NONE') return 'NONE';
    if (/DOUBLE/i.test(type)) return 'DOUBLE';
    if (type === 'SOLID' || type === 'DOT' || type === 'DASH') {
      const m = String(width || '').match(/([\d.]+)/);
      const w = m ? parseFloat(m[1]) : 0.12;
      if (w >= 0.3) return 'THICK';
      return 'THIN';
    }
    return 'THIN'; // 기타 enum 안전 폴백
  }

  /**
   * 양식 hwpx 의 borderFill 풀에서 그래픽 스타일러용 currentTableStyle 추정.
   *   - 헤더형 borderFill (음영+이중선) → headerFill + headerBottom
   *   - 외곽형 borderFill (사방 SOLID) → outerTop/Bottom/Left/Right
   *   - 본문형 borderFill (위 이중선) → innerHorizontal/Vertical
   *   - alternating 행 (bodyOdd/EvenFill) 은 양식에서 자동 추정 어려움 → null 유지
   * @returns {Object|null} { outerTop, outerBottom, outerLeft, outerRight,
   *                          headerBottom, innerHorizontal, innerVertical,
   *                          headerFill, bodyOddFill, bodyEvenFill } | null
   */
  function borderFillsToTableStyle(borderFills) {
    if (!borderFills || borderFills.length === 0) return null;

    // 헤더형: 음영 + 아래 이중선 → 없으면 음영만 있는 것 → 없으면 첫 번째 hasFill
    const header = borderFills.find(bf => bf.isHeaderLike) ||
                   borderFills.find(bf => bf.hasFill && bf.hasFillBrush) ||
                   null;

    // 본문형: 위 이중선 + 음영 X → 없으면 본문 첫 행으로 적합한 SOLID 패턴
    const body = borderFills.find(bf => bf.isBodyLike) ||
                 borderFills.find(bf => !bf.hasFill && bf.topType === 'SOLID' && bf.bottomType === 'SOLID') ||
                 null;

    // 외곽형: 사방 SOLID + 음영 X
    const outer = borderFills.find(bf => bf.allSolid) ||
                  null;

    return {
      outerTop:    outer ? sideToStylerEnum(outer.topType, outer.topWidth)       : 'NONE',
      outerBottom: outer ? sideToStylerEnum(outer.bottomType, outer.bottomWidth) : 'NONE',
      outerLeft:   outer ? sideToStylerEnum(outer.leftType, outer.leftWidth)     : 'NONE',
      outerRight:  outer ? sideToStylerEnum(outer.rightType, outer.rightWidth)   : 'NONE',
      headerBottom: header ? sideToStylerEnum(header.bottomType, header.bottomWidth)
                           : (body ? sideToStylerEnum(body.topType, body.topWidth) : 'THIN'),
      innerHorizontal: body ? sideToStylerEnum(body.bottomType, body.bottomWidth) : 'THIN',
      innerVertical:   body ? sideToStylerEnum(body.leftType, body.leftWidth)     : 'THIN',
      headerFill: (header && header.hasFill) ? header.fillColor : null,
      // bodyOdd/EvenFill 는 양식 한 셀의 borderFill 로는 추정 어려움 → null 유지 (사용자가 명시 설정)
      bodyOddFill: null,
      bodyEvenFill: null,
    };
  }

  // ────────────────────────────────────────────────────────────────────
  // 표 템플릿 추출 — v0.5.10
  //   양식 hwpx 의 첫 <hp:tbl> 을 분석해 셀 위치별 borderFillID 패턴 추출
  //   변환 표 생성 시 그 위치의 borderFill 사용 → 정교한 외곽선
  // ────────────────────────────────────────────────────────────────────

  /**
   * @returns null | {
   *   rowCnt, colCnt,
   *   outerBF,            // 표 외곽 borderFillID
   *   cellCharPrId,       // 표 셀의 charPrIDRef
   *   cellParaPrId,       // 표 셀의 paraPrIDRef
   *   cellStyleId,        // 표 셀의 styleIDRef
   *   cellMap: { 'rowCat.colCat': borderFillID, ... }  // 위치별 매핑
   * }
   *
   * rowCat: 'header' | 'bodyFirst' | 'bodyMiddle' | 'bodyLast'
   * colCat: 'left' | 'middle' | 'right' | 'single'
   */
  function extractTableTemplate(sectionXmlText) {
    if (!sectionXmlText) return null;
    // 첫 번째 <hp:tbl ...> ... </hp:tbl> 추출
    const tblMatch = sectionXmlText.match(/<hp:tbl\b[^>]*rowCnt="(\d+)"[^>]*colCnt="(\d+)"[^>]*borderFillIDRef="([^"]+)"[^>]*>([\s\S]*?)<\/hp:tbl>/);
    if (!tblMatch) return null;

    const rowCnt = parseInt(tblMatch[1], 10);
    const colCnt = parseInt(tblMatch[2], 10);
    const outerBF = tblMatch[3];
    const inner = tblMatch[4];

    // 모든 <hp:tc ...> 의 attribute + cellAddr 추출
    const cells = [];
    const tcRe = /<hp:tc\s+([^>]*)>([\s\S]*?)<\/hp:tc>/g;
    let m;
    while ((m = tcRe.exec(inner)) !== null) {
      const attrs = m[1];
      const tcBody = m[2];
      const header = pickAttr(attrs, /\bheader="([^"]+)"/);
      const borderFillIDRef = pickAttr(attrs, /\bborderFillIDRef="([^"]+)"/);
      const addrMatch = tcBody.match(/<hp:cellAddr\s+colAddr="(\d+)"\s+rowAddr="(\d+)"/);
      const colAddr = addrMatch ? parseInt(addrMatch[1], 10) : 0;
      const rowAddr = addrMatch ? parseInt(addrMatch[2], 10) : 0;
      cells.push({ rowAddr, colAddr, header: header === '1', borderFillID: borderFillIDRef });
    }
    if (cells.length === 0) return null;

    // 첫 셀의 paraPrIDRef / styleIDRef / charPrIDRef (양식의 표내용 스타일)
    const firstPara = inner.match(/<hp:p\s+(?:id="[^"]*"\s+)?paraPrIDRef="([^"]+)"\s+styleIDRef="([^"]+)"/);
    const firstRunCharPr = inner.match(/<hp:run\s+charPrIDRef="([^"]+)"/);

    // 헤더 행 수 (연속된 header="1" 첫 줄들)
    const headerRowSet = new Set(cells.filter(c => c.header).map(c => c.rowAddr));
    const headerRowCnt = headerRowSet.size;
    const bodyFirstRow = headerRowCnt;
    const bodyLastRow  = rowCnt - 1;

    // 위치 카테고리 결정 함수
    function rowCat(rowIdx) {
      if (rowIdx < headerRowCnt) return 'header';
      if (rowIdx === bodyFirstRow) return 'bodyFirst';
      if (rowIdx === bodyLastRow)  return 'bodyLast';
      return 'bodyMiddle';
    }
    function colCat(colIdx) {
      if (colCnt === 1) return 'single';
      if (colIdx === 0) return 'left';
      if (colIdx === colCnt - 1) return 'right';
      return 'middle';
    }

    // cellMap 채움 — 같은 카테고리 중복 시 첫 값 유지
    const cellMap = {};
    for (const c of cells) {
      const key = `${rowCat(c.rowAddr)}.${colCat(c.colAddr)}`;
      if (!cellMap[key]) cellMap[key] = c.borderFillID;
    }

    return {
      rowCnt, colCnt,
      headerRowCnt,
      outerBF,
      cellParaPrId: firstPara ? firstPara[1] : null,
      cellStyleId:  firstPara ? firstPara[2] : null,
      cellCharPrId: firstRunCharPr ? firstRunCharPr[1] : null,
      cellMap,
    };
  }

  // ────────────────────────────────────────────────────────────────────
  // 7-2. 한국어 라벨 헬퍼 (UI 표시용)
  // ────────────────────────────────────────────────────────────────────

  const ALIGN_KOR = {
    LEFT:       '왼쪽 정렬',
    RIGHT:      '오른쪽 정렬',
    CENTER:     '가운데 정렬',
    JUSTIFY:    '양쪽 정렬',
    DISTRIBUTE: '배분 정렬',
    DIVISION:   '나눔 정렬',
  };

  const LINE_SPACING_TYPE_KOR = {
    PERCENT: '글자에 따라 %',
    FIXED:   '고정값',
    MARGIN:  '여백만 지정',
    BETWEENLINES: '줄 간 간격',
    ATLEAST: '최소',
  };

  function alignKor(en) { return ALIGN_KOR[en] || en || '?'; }
  function lineSpacingTypeKor(en) { return LINE_SPACING_TYPE_KOR[en] || en || '?'; }

  /** 글자 크기를 사람이 보기 좋은 pt 문자열로 ("9pt", "11.5pt") */
  function heightToPt(height) {
    if (height == null) return '?';
    const pt = height / 100;
    return Number.isInteger(pt) ? `${pt}pt` : `${pt.toFixed(1)}pt`;
  }

  // ────────────────────────────────────────────────────────────────────
  // 7. 양식 정보 → 사람이 읽는 한 줄 요약
  //    UI 표 / 펼침 상세 등에서 재사용
  // ────────────────────────────────────────────────────────────────────

  /**
   * 표 셀 1줄 요약 — 모든 양식 정보를 한 줄에. 세부 펼침 X.
   * 예: "KoPub돋움체 Medium · 9pt · 굵게 · 가운데 정렬 · 줄간 135% · 자간 -3% · 장평 98%"
   *
   * 표시 정책:
   *   - 폰트 이름·크기·굵게/기울임/밑줄/취소선·정렬·줄간격 : **항상 표시**
   *   - 자간 0, 장평 100, 색상 #000000 : 기본값이라 생략
   */
  function formatStyleInline(style) {
    const parts = [];
    const c = style.char || {};
    const p = style.para || {};

    // 0. 글머리 — 스타일 정체성의 1순위라 맨 앞 (v1.3.0)
    if (p.headingType === 'BULLET' && p.bulletChar) parts.push(`글머리 "${p.bulletChar}"`);
    else if (p.headingType === 'NUMBER') parts.push('번호 글머리');
    else if (p.headingType === 'OUTLINE') parts.push('개요 번호');

    // 1. 폰트 이름 (있으면)
    if (style.hangulFontName) parts.push(style.hangulFontName);

    // 2. 크기
    if (c.height) parts.push(heightToPt(c.height));

    // 3. 굵게·기울임·밑줄·취소선 (있을 때만)
    if (c.bold)       parts.push('굵게');
    if (c.italic)     parts.push('기울임');
    if (c.underline)  parts.push('밑줄');
    if (c.strikeout)  parts.push('취소선');

    // 4. 정렬 — 항상 표시 (사용자 피드백)
    if (p.align) parts.push(alignKor(p.align));

    // 5. 줄간격 — 항상 표시
    if (p.lineSpacingType === 'PERCENT' && p.lineSpacingValue != null) {
      parts.push(`줄간 ${p.lineSpacingValue}%`);
    } else if (p.lineSpacingType === 'FIXED' && p.lineSpacingValue) {
      parts.push(`줄간 ${heightToPt(p.lineSpacingValue)} 고정`);
    }

    // 6. 자간 (0 외만)
    if (c.spacing != null && c.spacing !== 0) parts.push(`자간 ${c.spacing > 0 ? '+' : ''}${c.spacing}%`);

    // 7. 장평 (100 외만)
    if (c.ratio != null && c.ratio !== 100) parts.push(`장평 ${c.ratio}%`);

    // 8. 글자색 (#000000 외만)
    if (c.textColor && c.textColor !== '#000000') parts.push(`색 ${c.textColor}`);

    return parts.join(' · ');
  }

  // ────────────────────────────────────────────────────────────────────
  // 유틸리티
  // ────────────────────────────────────────────────────────────────────

  function escapeRe(s) {
    return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  }
  function pickAttr(text, re) {
    const m = text.match(re);
    return m ? m[1] : null;
  }
  function toInt(v) {
    if (v == null) return null;
    const n = parseInt(v, 10);
    return Number.isFinite(n) ? n : null;
  }
  function decodeXmlEntities(s) {
    return String(s)
      .replace(/&lt;/g, '<')
      .replace(/&gt;/g, '>')
      .replace(/&quot;/g, '"')
      .replace(/&apos;/g, "'")
      .replace(/&amp;/g, '&');
  }

  // ────────────────────────────────────────────────────────────────────
  // 공개 API
  // ────────────────────────────────────────────────────────────────────

  return {
    extractHwpx,
    parseFontFaces,
    parseStyles,
    parseCharPrMeta,
    parseParaPrMeta,
    parseBullets, // v1.3.0
    countStyleUsage,
    detectExternalNoise,
    parseBorderFills,
    borderFillsToTableStyle, // v0.7.14: 양식 borderFill → 그래픽 스타일러 형식
    extractTableTemplate,
    analyzeHwpx,
    formatStyleInline,
    alignKor,
    lineSpacingTypeKor,
    heightToPt,
  };
})();
