// hier 절(section) 목차 표시용 순수 유틸 (PR-DocSrv-Hier-Section-UI-1).
// SvelteKit/Svelte 의존 0 → Node 내장 test runner(`node --test`)로 검증 가능.
//
// 책임:
//  - cleanHeading: section_title/heading_path 의 raw 마크다운/HTML 잔재 strip.
//  - pathSegments: heading_path("A > B > C")를 정제 세그먼트 배열로.
//  - collapseWindows: 연속 동일 heading 의 node_type='window'(과대 본문 인공 분할) dedupe.
//  - groupOrFlat: per-doc 동적 판정 — top-segment 1단 그룹 vs flat (실측 임계 기반).

export interface DocumentSection {
  chunk_id: number;
  section_title: string | null;
  heading_path: string | null;
  level: number | null;
  node_type: string | null; // 'window' | 'chapter_split' | 'clause_split' | 'section_split' | null
  is_leaf: boolean;
  /** 트리 부모 chunk_id. window child 의 parent_id = 그 split-parent (비인접 흡수에 사용). */
  parent_id?: number | null;
  /** md_content 내 heading offset(UTF-16). jump-target 만 값, window-child/preamble/Path A = null (Path B). */
  char_start?: number | null;
  /** 절 본문 = 청크 원문. split-parent 는 heading 줄뿐, window child 가 실 본문 보유. */
  text?: string | null;
  section_type: string | null;
  summary: string | null;
  confidence: number | null;
}

/** window dedupe 후 목차 한 항목 (대표 절 + 합쳐진 조각 수). */
export interface OutlineItem {
  section: DocumentSection;
  fragmentCount: number; // >1 이면 "(n조각)" 배지
  /** 대표 + 흡수된 window child 들의 본문을 순서대로 이어붙인 논리 절 전체 본문.
   *  split-parent 는 heading 줄(text)을 본문에서 제외(제목과 중복) — window 본문만 합친다. */
  bodyText: string;
  /** 집계된 절-레벨 분석. windowed 절은 분석이 window child(chunk_section_analysis)에 붙고
   *  대표=split-parent 엔 없으므로 멤버에서 집계한다. 단일 절은 대표 자신의 값.
   *  - sectionType: 멤버 section_type 다수결(동률=첫 등장)
   *  - confidence: 멤버 confidence 평균
   *  - summaries: 멤버 요약(빈 것 제외, chunk_index 순) — 단일=1개, windowed=N개(부분별 요약) */
  sectionType: string | null;
  confidence: number | null;
  summaries: string[];
}

export interface OutlineGroup {
  key: string; // top segment (OTHER → '기타')
  isOther: boolean;
  items: OutlineItem[];
}

export interface OutlineLayout {
  mode: 'group' | 'flat';
  items: OutlineItem[]; // flat 모드에서 채워짐
  groups: OutlineGroup[]; // group 모드에서 채워짐
}

const OTHER = '__OTHER__';

// 동적 그룹 판정 임계 (실측 pilot 3 검증: 5140 group→그룹 / 5186·5225→flat).
const GROUP_MIN = 2;
const GROUP_MAX = 30;
const OTHER_PCT_MAX = 50;

/** section_type → 한글 라벨 (느슨한 enum, 미지정/미상은 그대로 표시). */
export const SECTION_TYPE_LABEL: Record<string, string> = {
  definition: '정의',
  requirement: '요건',
  procedure: '절차',
  formula: '수식',
  data_table: '표·데이터',
  example: '예시',
  case_study: '사례',
  question: '문제',
  reference: '참조',
  overview: '개요',
  other: '기타',
};

export function sectionTypeLabel(t: string | null | undefined): string | null {
  if (!t) return null;
  return SECTION_TYPE_LABEL[t] ?? t;
}

export function cleanHeading(raw: string | null | undefined): string {
  if (!raw) return '';
  return raw
    // D9(read-time): ASME 개정바 ðNÞ(`<sup>ð</sup>**25**<sup>Þ</sup>`) 통째 제거 — 개별 sup strip 전에.
    //   (일반 sup strip 이 먼저면 가운데 '25'(개정 연도)만 남아 'ð25Þ PG-5.4' → '25 PG-5.4' 오염)
    .replace(/<sup>\s*ð\s*<\/sup>.*?<sup>\s*Þ\s*<\/sup>/gi, '')
    .replace(/<sup>.*?<\/sup>/gi, '') // 각주 위첨자
    .replace(/<sub>.*?<\/sub>/gi, '')
    .replace(/<[^>]+>/g, '') // 잔여 HTML 태그
    .replace(/\*\*/g, '') // **bold**
    .replace(/[*_`]/g, '') // 잔여 마크다운 마커
    .replace(/\s+/g, ' ')
    .trim();
}

export function pathSegments(hp: string | null | undefined): string[] {
  if (!hp) return [];
  // ⚠ 먼저 strip 후 split: heading_path 에 <sup>2</sup> 등 raw HTML 의 '>' 가 섞여 있어
  //    bare '>' 로 먼저 split 하면 태그가 잘림(단위테스트로 발견). cleanHeading 이 HTML 태그를
  //    제거하므로 separator ' > '(bare '>')만 남은 뒤 split 한다.
  return cleanHeading(hp)
    .split('>')
    .map((s) => s.trim())
    .filter(Boolean);
}

/** 그룹 키: window/%_split(인공 조각·windowed split-parent) 또는 path 없음/깨짐 → OTHER. */
function topSegment(s: DocumentSection): string {
  if (s.node_type === 'window' || !!s.node_type?.endsWith('_split')) return OTHER;
  const segs = pathSegments(s.heading_path);
  return segs.length === 0 ? OTHER : segs[0];
}

/**
 * 서버 chunk_index 순서를 유지한 채(정렬 변경 금지), 연속된 동일 cleaned heading_path 의
 * node_type='window' 절을 1 항목으로 dedupe. fragmentCount = window 조각 수.
 *
 * [C2] g4-t2 가 split-parent(%_split, char_start 보유)를 그 window child 들보다 먼저(낮은 chunk_index)
 *   노출하므로, 후속 window child 를 직전 split-parent(또는 legacy window 대표)에 흡수해 rail 1행으로 만든다.
 *   merged row 의 대표 section = split-parent 여야 jump(anchorMap[split-parent char_start])가 성립한다 —
 *   window-child(char_start NULL, anchorMap 부재)가 대표면 windowed section 이 점프 안 됨.
 *   fragmentCount: split-parent 대표는 0 에서 시작(자신은 조각 아님) + 흡수 child 수 = 실제 조각 수;
 *   legacy window 대표는 1 에서 시작(자신이 첫 조각).
 */
/** 멤버 section_type 다수결(동률은 첫 등장 우선). 비어있으면 null. */
function majorityType(types: (string | null)[]): string | null {
  const vals = types.filter((t): t is string => !!t);
  if (!vals.length) return null;
  const count = new Map<string, number>();
  for (const t of vals) count.set(t, (count.get(t) ?? 0) + 1);
  let best: string | null = null;
  let bestN = -1;
  for (const t of vals) {
    const n = count.get(t)!;
    if (n > bestN) { bestN = n; best = t; } // 첫 등장 우선 tie-break
  }
  return best;
}

export function collapseWindows(sections: DocumentSection[]): OutlineItem[] {
  const out: OutlineItem[] = [];
  const members: DocumentSection[][] = []; // out[i] 의 멤버(대표 + 흡수된 window child)
  const repByChunkId = new Map<number, number>(); // split-parent chunk_id → out index (window 가 parent_id 로 흡수)

  // window child 본문/멤버를 out[idx] 대표에 흡수.
  const absorb = (idx: number, s: DocumentSection) => {
    out[idx].fragmentCount += 1;
    const t = (s.text ?? '').trim();
    if (t) out[idx].bodyText = out[idx].bodyText ? `${out[idx].bodyText}\n\n${t}` : t;
    members[idx].push(s);
  };

  for (const s of sections) {
    if (s.node_type === 'window') {
      // 1) parent_id 로 split-parent 대표에 흡수 — split-parent 와 window 가 chunk_index 상 비인접일 수
      //    있으므로(예: 헤딩 1143, window 1233) 인접 가정 대신 트리 부모 링크로 정확히 연결한다.
      let idx = s.parent_id != null ? repByChunkId.get(s.parent_id) ?? -1 : -1;
      // 2) fallback: 인접 대표(legacy window run / 같은 heading split)면 흡수
      if (idx < 0) {
        const prev = out[out.length - 1];
        const h = cleanHeading(s.heading_path);
        if (
          prev &&
          (prev.section.node_type === 'window' || !!prev.section.node_type?.endsWith('_split')) &&
          h !== '' &&
          cleanHeading(prev.section.heading_path) === h
        ) {
          idx = out.length - 1;
        }
      }
      if (idx >= 0) {
        absorb(idx, s);
        continue;
      }
      // 3) legacy: 부모 없는 window → 자기 대표(자기 본문으로 시작)
      out.push({ section: s, fragmentCount: 1, bodyText: s.text ?? '', sectionType: null, confidence: null, summaries: [] });
      members.push([s]);
    } else {
      const isSplit = !!s.node_type?.endsWith('_split');
      // split-parent 의 text 는 heading 줄뿐 → 본문에서 제외(window 가 본문 보유). 그 외엔 자기 본문으로 시작.
      out.push({
        section: s, fragmentCount: isSplit ? 0 : 1, bodyText: isSplit ? '' : (s.text ?? ''),
        sectionType: null, confidence: null, summaries: [],
      });
      members.push([s]);
      if (isSplit) repByChunkId.set(s.chunk_id, out.length - 1); // window 가 parent_id 로 찾아 흡수
    }
  }
  // 멤버에서 절-레벨 분석 집계 (windowed 절: 대표 split-parent 엔 분석 없고 window 들이 보유).
  for (let i = 0; i < out.length; i++) {
    const mem = members[i];
    out[i].sectionType = majorityType(mem.map((m) => m.section_type));
    const confs = mem.map((m) => m.confidence).filter((c): c is number => c != null);
    out[i].confidence = confs.length ? confs.reduce((a, b) => a + b, 0) / confs.length : null;
    out[i].summaries = mem.map((m) => (m.summary ?? '').trim()).filter((x) => x !== '');
  }
  return out;
}

/**
 * per-doc 동적 판정: top-segment 1단 그룹 vs flat.
 * 판정은 raw 절 기준(실측 임계와 동일 차원), 표시는 collapseWindows 적용.
 *  - 그룹 채택: GROUP_MIN ≤ distinct top-segment ≤ GROUP_MAX AND 기타% < OTHER_PCT_MAX.
 *  - 아니면 flat 강등.
 */
export function groupOrFlat(sections: DocumentSection[]): OutlineLayout {
  const total = sections.length;
  const order: string[] = [];
  const map = new Map<string, DocumentSection[]>();
  let otherCount = 0;
  for (const s of sections) {
    const key = topSegment(s);
    if (key === OTHER) otherCount += 1;
    if (!map.has(key)) {
      map.set(key, []);
      order.push(key);
    }
    map.get(key)!.push(s);
  }
  const groupCount = map.size;
  const otherPct = total === 0 ? 0 : (otherCount / total) * 100;
  const useGroup = groupCount >= GROUP_MIN && groupCount <= GROUP_MAX && otherPct < OTHER_PCT_MAX;

  if (!useGroup) {
    return { mode: 'flat', items: collapseWindows(sections), groups: [] };
  }
  const groups: OutlineGroup[] = order.map((key) => ({
    key: key === OTHER ? '기타' : key,
    isOther: key === OTHER,
    items: collapseWindows(map.get(key)!),
  }));
  return { mode: 'group', items: [], groups };
}

// ── D7/D8 (asme-item-decomp read-time): front-matter 억제 + Part 계층 그룹 ──
// 긴 구조화 코드(ASME)의 절뷰가 flat 1030 으로 길어지는 문제(front-matter 240 + 다중 PART)를
// 표현 계층에서 해결. 빌더/재분해 무접촉 — sections 엔드포인트가 주는 heading_path 만으로 산출.

/**
 * 최상위 섹션 경계 top-segment 패턴: 대문자 'PART'/'SUBSECTION'/'(MANDATORY|NONMANDATORY) APPENDIX'
 * + 대문자 코드(PG/UW/IV/A) + 선택 제목(대문자/숫자/괄호 시작).
 *   예: 'PART PG GENERAL REQUIREMENTS…', 'SUBSECTION A GENERAL', 'NONMANDATORY APPENDIX A EXPLANATION…'.
 * 부록(APPENDIX)도 ASME 최상위 섹션(파트와 동격)이라 별 그룹으로 — 안 그러면 마지막 PART 가 부록 전체를
 * carry-forward 로 흡수(5180 실측: PART PHRSG 11항목 → 부록 289 흡수 = 300).
 *
 * ★ case-sensitive + 제목-대문자 가드 = 본문 cross-ref/문장 false match 차단(5210 실측):
 *   'Part D, Subpart 3의 …'(혼합대소문자) · 'PART UW 규정은 용접에 …'(코드 뒤 한글 문장) · 'PART 층이 진 …'
 *   (코드 비대문자) 전부 거부. D1 빌더 _ENG 가드의 read-time 대응([[feedback_docstring_invariant_swap_audit]]).
 * ⚠ 알려진 트레이드오프(D3 재검토): 제목-대문자 가드는 비영문(한글) 제목으로 시작하는 PART 도 거부한다
 *   (예: 'PART PG 일반 요건'). false-negative(→flat 폴백)는 false-positive(→가짜 그룹)보다 안전한 방향이라
 *   파일럿(5180 영문)엔 옳고 5210(D3 재분해 전 한글 stale)은 flat 폴백된다. **5210 D3 재분해 후 실 PART
 *   제목 형태(영문/한글/코드만)를 보고 가드를 정련** — read-time 라 마이그 0. [[project_hierarchical_decomposition]] D3.
 */
const PART_MARKER_RE = /^((MANDATORY |NONMANDATORY )?APPENDIX|PART|SUBSECTION)\s+[A-Z][A-Z0-9.\-]*(\s+[A-Z0-9(].*)?$/;

/** top-segment 문자열이 PART/SUBSECTION/APPENDIX 헤딩인가 (마커 판정 단일 소스 — 경계·carry 공용). */
function isPartMarkerSeg(seg0: string): boolean {
  return PART_MARKER_RE.test(seg0);
}

/** 절의 heading_path 첫 세그먼트가 PART/SUBSECTION/APPENDIX 헤딩 = 새 최상위 섹션 경계. */
function isPartMarker(s: DocumentSection): boolean {
  const segs = pathSegments(s.heading_path);
  return segs.length > 0 && isPartMarkerSeg(segs[0]);
}

export interface PartOutline {
  /** PART PG / PART PW … 전(前) front-matter(TOC·위원회·인명) — 단일 접이 그룹용. */
  frontMatter: OutlineItem[];
  /** 본문 Part 그룹들(heading_path 첫 세그먼트 = PART 기준). 기본 접힘은 렌더(D8)에서. */
  groups: OutlineGroup[];
  /** content part 경계를 못 찾으면 false → 기존 groupOrFlat 폴백 권장. */
  hasParts: boolean;
}

/**
 * 이미 collapseWindows 된 OutlineItem[] 를 front-matter(첫 PART 마커 전) 분리 + 본문을 PART 로
 * **순서 기반 carry-forward** 그룹. 정렬(chunk_index) 유지.
 *
 * ★ carry-forward 가 핵심: 실 ASME md 는 marker 추출 트리가 불규칙해 'PG-28'·'GENERAL' 등 다수
 *   항목의 heading_path 첫 세그먼트가 PART 가 아니다(자기 자신/중간 헤딩). 단순 segs[0] 그룹핑은
 *   250+ 가짜 그룹을 낳는다(5180 실측). → PART/SUBSECTION 마커를 만나면 새 그룹을 열고, 비-마커
 *   항목은 직전 PART 로 흡수 = 실제 ~13 PART 로 수렴.
 * ★ 같은 OutlineItem 인스턴스를 재배치만 한다(재-collapse 없음) → 호출자의 flat outline 과
 *   chunk_id·인스턴스가 1:1 일치(상세페이지 treeNav 가 selectedSectionId/focusView 와 정합).
 * PART 마커가 0 이면 hasParts=false → 호출자가 groupOrFlat/flat 으로 폴백.
 */
export function partitionOutlineItems(items: OutlineItem[]): PartOutline {
  let boundary = -1;
  for (let i = 0; i < items.length; i++) {
    if (isPartMarker(items[i].section)) { boundary = i; break; }
  }
  if (boundary < 0) {
    return { frontMatter: [], groups: [], hasParts: false };
  }
  const frontMatter = items.slice(0, boundary);

  const order: string[] = [];
  const map = new Map<string, OutlineItem[]>();
  let current = ''; // 현재 PART 키 — boundary 가 PART 마커라 첫 본문 항목에서 즉시 설정됨.
  for (let i = boundary; i < items.length; i++) {
    const it = items[i];
    const segs = pathSegments(it.section.heading_path);
    if (segs.length && isPartMarkerSeg(segs[0])) current = segs[0]; // 새 PART 경계(경계 루프와 동일 판정 = '' 누출 불가)
    if (!map.has(current)) { map.set(current, []); order.push(current); }
    map.get(current)!.push(it);
  }
  const groups: OutlineGroup[] = order.map((key) => ({ key, isOther: false, items: map.get(key)! }));
  return { frontMatter, groups, hasParts: true };
}

/**
 * front-matter 경계(첫 content part) 분리 + 본문을 PART(heading_path 첫 세그먼트)로 그룹.
 * = collapseWindows 후 partitionOutlineItems (절뷰 rail/treeNav 공용 진입점, sections 기반).
 */
export function buildPartOutline(sections: DocumentSection[]): PartOutline {
  return partitionOutlineItems(collapseWindows(sections));
}

// ── D8: Part 접이 렌더용 — front-matter 를 첫 그룹으로 평탄화 + auto-expand 역인덱스 ──

/** front-matter 접이 그룹의 안정 key/라벨(실 PART 키와 충돌 불가능한 sentinel). */
export const FRONT_MATTER_KEY = '__front_matter__';
export const FRONT_MATTER_LABEL = '문서 정보·서문';

/** 접이 그룹 1개(front-matter 또는 PART) 의 렌더 뷰. */
export interface PartGroupView {
  /** Svelte each key + 접이 상태 key. front-matter = FRONT_MATTER_KEY. */
  key: string;
  /** 헤더 표시 라벨. */
  label: string;
  isFrontMatter: boolean;
  items: OutlineItem[];
}

/**
 * PartOutline → 렌더 그룹 배열. front-matter(있으면)를 항상 첫 그룹으로,
 * 이어서 PART 그룹들. 기본 접힘/auto-expand 는 컴포넌트가 key 로 관리.
 */
export function partGroupViews(outline: PartOutline): PartGroupView[] {
  const views: PartGroupView[] = [];
  if (outline.frontMatter.length) {
    views.push({ key: FRONT_MATTER_KEY, label: FRONT_MATTER_LABEL, isFrontMatter: true, items: outline.frontMatter });
  }
  for (const g of outline.groups) {
    views.push({ key: g.key, label: g.key, isFrontMatter: false, items: g.items });
  }
  return views;
}

/**
 * 대표 OutlineItem 의 chunk_id → 소속 group key 역인덱스(딥링크/스크롤스파이 진입 시
 * 조상 그룹 auto-expand 용). activeKey/selectedSectionId 는 대표 chunk_id 라 대표만 매핑.
 */
export function groupKeyByChunkId(views: PartGroupView[]): Map<number, string> {
  const m = new Map<number, string>();
  for (const v of views) for (const it of v.items) m.set(it.section.chunk_id, v.key);
  return m;
}
