/**
 * 클라이언트 폴더 기반 지리정보 로딩 + CSV 파싱 (v2.0 데이터 형상 전용)
 *
 * `<input type="file" webkitdirectory>` 로 선택된 File[] 에서 v2.0 폴더 구조를
 * 식별해 영상 / 드론 CSV / 측점·POI·구조물 CSV 를 파싱한다.
 *
 * v2.0 폴더 구조 (영상 base = 영상파일명에서 확장자 제거):
 *   <base>.MP4            영상
 *   <base>.csv            드론 프레임 (frame_cnt,latitude,...)
 *   <base>_POI.csv        POI (source,query_label,title,category_clean,...,lat,lon,...)
 *   building/01)측점.csv   측점 (측점,X좌표,...,비고,lat,lon,...)
 *   building/02)지장물.csv POI (명칭,...,lat,lon)
 *   building/03)교량.csv   구조물 bridge (구분,...,연장(m),...,lat,lon,...)
 *   building/04)터널.csv   구조물 tunnel (구분,...,연장(m),...,lat,lon,...)
 *   building/05)출입문번호.csv 출입문 (기본 표시 제외 — 아래 참고)
 *   building/06)구교.csv   구조물 bridge (역구간,시설물명,...,연장(m),lat,lon,...)
 *
 * v2.0엔 center.csv 가 없다 → 측점(01)측점)을 측점값 순으로 이어 중심선을 생성한다.
 *
 * 인코딩(혼재 — 자동 감지): ArrayBuffer 앞 3바이트가 UTF-8 BOM(EF BB BF)이면 UTF-8,
 * 아니면 EUC-KR. 디코딩 후 잔여 BOM(U+FEFF)을 제거한다. 파일별 하드코딩 없음.
 */

import type {
  DroneFrame,
  GeoPoint,
  CenterlinePoint,
  GeoOrigin,
  FolderGeoData,
  RouteMeta,
  RouteStructure,
  DirectionChange,
  PoiOverrideMap,
} from '../types/geo';
import { stationOrder, getWorldOrigin } from './geoSearch';
import { unzipSync, strFromU8 } from 'fflate';

// ── CSV 파싱 헬퍼 ─────────────────────────────────────────────────────

export function parseCsvLine(line: string): string[] {
  const result: string[] = [];
  let current = '';
  let inQuotes = false;
  for (const ch of line) {
    if (ch === '"') {
      inQuotes = !inQuotes;
    } else if (ch === ',' && !inQuotes) {
      result.push(current.trim());
      current = '';
    } else {
      current += ch;
    }
  }
  result.push(current.trim());
  return result;
}

/**
 * ArrayBuffer → 문자열. 인코딩 자동 감지:
 *   앞 3바이트가 UTF-8 BOM(EF BB BF)이면 UTF-8, 아니면 EUC-KR.
 * 디코딩 후 남을 수 있는 BOM(U+FEFF)을 제거한다.
 */
export function decodeBytes(buf: ArrayBuffer): string {
  const head = new Uint8Array(buf, 0, Math.min(3, buf.byteLength));
  const isUtf8Bom = head.length >= 3 && head[0] === 0xef && head[1] === 0xbb && head[2] === 0xbf;
  const encoding = isUtf8Bom ? 'utf-8' : 'euc-kr';
  const text = new TextDecoder(encoding).decode(buf);
  // 디코딩 후 잔여 BOM(U+FEFF) 제거
  return text.replace(/^﻿/, '');
}

/** File → 파싱된 행 배열. 인코딩은 BOM 기반 자동 감지. */
export async function readCsv(file: File): Promise<string[][]> {
  const buf = await file.arrayBuffer();
  const text = decodeBytes(buf);
  return text
    .split(/\r?\n/)
    .filter(Boolean)
    .map(parseCsvLine);
}

/** 헤더 행 → (헤더명|위치인덱스) → 값 추출기. 헤더가 깨지면 위치 인덱스로 폴백. */
function makeFieldIndexer(header: string[]): (name: string, fallback?: number) => number {
  const cleaned = header.map((h) => h.trim().replace(/^﻿/, ''));
  return (name: string, fallback?: number): number => {
    const i = cleaned.indexOf(name);
    if (i >= 0) return i;
    return fallback ?? -1;
  };
}

/** 안전한 셀 접근 (인덱스 음수/범위 밖이면 ''). */
function cell(row: string[], i: number): string {
  return i >= 0 && i < row.length ? row[i] : '';
}

// ── 파일 식별 헬퍼 ────────────────────────────────────────────────────

/** File 의 폴더 내 상대경로 (webkitRelativePath 우선, 없으면 name). */
function relPath(f: File): string {
  return f.webkitRelativePath || f.name;
}

/** 경로의 마지막 세그먼트(파일명). */
function baseNameOf(p: string): string {
  const parts = p.split('/');
  return parts[parts.length - 1];
}

/** building/ 하위 파일 여부 (마지막 디렉토리 세그먼트가 building). */
function isInBuilding(f: File): boolean {
  const parts = relPath(f).split('/');
  return parts.length >= 2 && parts[parts.length - 2].toLowerCase() === 'building';
}

/** building/ 하위에서 파일명이 키워드를 포함하는 첫 파일. (예 '01)측점', '03)교량') */
function findBuildingFile(files: File[], keyword: string): File | null {
  return files.find((f) => isInBuilding(f) && baseNameOf(relPath(f)).includes(keyword)) ?? null;
}

const VIDEO_EXT = /\.(mp4|webm)$/i;

/** 영상 파일 찾기 (mp4/webm, building 제외). */
export function findVideoFile(files: File[]): File | null {
  return (
    files.find((f) => !isInBuilding(f) && VIDEO_EXT.test(baseNameOf(relPath(f)))) ?? null
  );
}

/** 영상 파일명에서 base(확장자 제거) 추출. */
export function deriveBaseName(videoFile: File | null): string | null {
  if (!videoFile) return null;
  return baseNameOf(relPath(videoFile)).replace(VIDEO_EXT, '');
}

// ── 파서: 드론 프레임 (변경 없음) ─────────────────────────────────────

/**
 * 드론 프레임 CSV 파싱 (UTF-8 BOM, 헤더 이름 인덱스).
 * 헤더: frame_cnt,latitude,longitude,altitude,yaw,pitch,roll,focal_len
 * 루트(building 제외)의 <base>.csv 를 식별한다. base 가 없으면 루트 .csv 중
 * POI 가 아닌 첫 파일을 사용한다(영상명 비의존 폴백).
 */
export async function parseDroneFrames(
  files: File[],
  baseName: string | null,
): Promise<DroneFrame[]> {
  const rootCsv = files.filter((f) => {
    if (isInBuilding(f)) return false;
    const name = baseNameOf(relPath(f));
    if (!/\.csv$/i.test(name)) return false;
    if (/POI/i.test(name)) return false;
    return true;
  });
  if (!rootCsv.length) return [];

  // base 일치 우선, 없으면 첫 루트 csv
  const droneFile =
    (baseName && rootCsv.find((f) => baseNameOf(relPath(f)) === `${baseName}.csv`)) ||
    rootCsv[0];

  const rows = await readCsv(droneFile);
  if (rows.length < 2) return [];

  const fi = makeFieldIndexer(rows[0]);

  return rows
    .slice(1)
    .map((r) => ({
      frame: parseInt(cell(r, fi('frame_cnt', 0)), 10),
      lat: parseFloat(cell(r, fi('latitude', 1))),
      lon: parseFloat(cell(r, fi('longitude', 2))),
      altitude: parseFloat(cell(r, fi('altitude', 3))),
      yaw: parseFloat(cell(r, fi('yaw', 4))),
      pitch: parseFloat(cell(r, fi('pitch', 5))),
      roll: parseFloat(cell(r, fi('roll', 6))),
      focalLen: parseFloat(cell(r, fi('focal_len', 7))),
    }))
    .filter((f) => !isNaN(f.lat));
}

// ── 파서: 측점 + 방향전환점 ───────────────────────────────────────────

/** "02:05" → 125 (초). 파싱 실패 시 NaN. */
function mmssToSeconds(s: string): number {
  const m = s.match(/(\d+):(\d+)/);
  if (!m) return NaN;
  return parseInt(m[1], 10) * 60 + parseInt(m[2], 10);
}

/**
 * 측점 CSV 파싱 (building/01)측점.csv, EUC-KR).
 * 헤더: 측점,X좌표,Y좌표,Z좌표,비고,lat,lon,...
 *   title ← 측점, lat ← lat, lon ← lon, z ← Z좌표, category='측점', type='station'
 * 비고(방향전환점) 도 함께 추출한다: `방향전환점(상행->하행, 02:05)`.
 */
export async function parseStations(files: File[]): Promise<{
  stations: GeoPoint[];
  directionChanges: DirectionChange[];
}> {
  // 측점 파일 — building/ 또는 root(영상 옆) 어디든 허용(building 폴더 삭제 대비).
  const file =
    findBuildingFile(files, '01)측점') ??
    files.find((f) => baseNameOf(relPath(f)).includes('01)측점')) ??
    files.find((f) => baseNameOf(relPath(f)).includes('측점'));
  if (!file) return { stations: [], directionChanges: [] };

  const rows = await readCsv(file);
  if (rows.length < 2) return { stations: [], directionChanges: [] };

  const fi = makeFieldIndexer(rows[0]);
  const iTitle = fi('측점', 0);
  // Z좌표(col 3)는 로컬좌표(≈0). 실제 표고는 Z좌표_한국(EPSG:5186, 정표고)에 있다.
  // 없으면 Z좌표로 폴백.
  const iZKorea = fi('Z좌표_한국');
  const iZ = iZKorea >= 0 ? iZKorea : fi('Z좌표', 3);
  const iNote = fi('비고', 4);
  const iLat = fi('lat', 5);
  const iLon = fi('lon', 6);

  const stations: GeoPoint[] = [];
  const directionChanges: DirectionChange[] = [];

  for (const r of rows.slice(1)) {
    const lat = parseFloat(cell(r, iLat));
    const lon = parseFloat(cell(r, iLon));
    const title = cell(r, iTitle);
    if (isNaN(lat) || isNaN(lon)) continue;

    stations.push({
      title,
      category: '측점',
      lat,
      lon,
      z: parseFloat(cell(r, iZ)) || 0,
      type: 'station',
    });

    // 비고: 방향전환점(상행->하행, 02:05)
    const note = cell(r, iNote);
    const m = note.match(/방향전환점\s*\(\s*([^->]+?)\s*->\s*([^,)]+?)\s*,\s*([\d:]+)\s*\)/);
    if (m) {
      const atSeconds = mmssToSeconds(m[3]);
      directionChanges.push({
        station: title,
        from: m[1].trim(),
        to: m[2].trim(),
        atSeconds: isNaN(atSeconds) ? -1 : atSeconds,
      });
    }
  }

  return { stations, directionChanges };
}

// ── 파서: KMZ (원본) ─────────────────────────────────────────────────
//
// KMZ(=doc.kml zip)는 building CSV의 원본이다. 폴더가 02)지장물·03)교량·04)터널·05)출입문번호·
// 06)구교 로 1:1 대응하고, 각 Placemark 가 좌표 + description HTML표(속성)를 담는다.
// → KMZ 에서 직접 POI/구조물을 추출하면 CSV 추출 중복이 사라진다. (측점(01)은 KMZ에 없음 → CSV 유지)

/** HTML 태그 제거 + 엔티티 디코드 + 트림. */
function stripHtml(s: string): string {
  return s
    .replace(/<[^>]*>/g, ' ')
    .replace(/&gt;/g, '>').replace(/&lt;/g, '<').replace(/&amp;/g, '&')
    .replace(/&nbsp;/g, ' ').replace(/&quot;/g, '"').replace(/&#39;/g, "'")
    .replace(/\s+/g, ' ').trim();
}

/**
 * description CDATA(HTML표) → 속성 배열. 행/셀 단위로 견고하게 파싱.
 * 구글어스 재저장본은 `<tbody>`, `</b>` 뒤 `<br>`, `&gt;` 엔티티 등 변형이 있어
 * `<tr>` 안의 두 `<td>` 셀에서 태그를 벗겨 (키, 값)으로 만든다(옛/새 형식 모두 처리).
 */
function parseKmlDescProps(descHtml: string): { k: string; v: string }[] {
  const out: { k: string; v: string }[] = [];
  const trRe = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
  let tr: RegExpExecArray | null;
  while ((tr = trRe.exec(descHtml))) {
    const tds = [...tr[1].matchAll(/<td[^>]*>([\s\S]*?)<\/td>/gi)].map((m) => stripHtml(m[1]));
    if (tds.length >= 2 && tds[0]) out.push({ k: tds[0], v: tds[1] });
  }
  return out;
}

/** 폴더 내 직계 자식 중 localName 일치 첫 요소. */
function childLocal(el: Element, local: string): Element | null {
  for (const c of Array.from(el.children)) if (c.localName === local) return c;
  return null;
}

/** KMZ/KML 파싱 — 있으면 { pois, structures }, 없거나 실패 시 null.
 *  bare .kml(구글 직접 다운로드) 우선, 없으면 .kmz(zip) 해제. */
export async function parseKmz(
  files: File[],
): Promise<{ pois: GeoPoint[]; structures: RouteStructure[] } | null> {
  const kmlFile = files.find(
    (f) => !isInBuilding(f) && /\.kml$/i.test(baseNameOf(relPath(f))),
  );
  const kmzFile = files.find(
    (f) => !isInBuilding(f) && /\.kmz$/i.test(baseNameOf(relPath(f))),
  );
  if (!kmlFile && !kmzFile) return null;

  let kmlText: string;
  try {
    if (kmlFile) {
      kmlText = await kmlFile.text();   // 구글 KML 은 UTF-8
    } else {
      const buf = new Uint8Array(await kmzFile!.arrayBuffer());
      const entries = unzipSync(buf);
      const kmlName =
        Object.keys(entries).find((n) => /(^|\/)doc\.kml$/i.test(n)) ??
        Object.keys(entries).find((n) => /\.kml$/i.test(n));
      if (!kmlName || !entries[kmlName]) return null;
      kmlText = strFromU8(entries[kmlName]);
    }
  } catch (e) {
    console.warn('[KMZ/KML] 읽기 실패 → CSV 폴백', e);
    return null;
  }

  const doc = new DOMParser().parseFromString(kmlText, 'application/xml');
  if (doc.getElementsByTagName('parsererror').length) return null;

  const pois: GeoPoint[] = [];
  const structures: RouteStructure[] = [];
  let nStruct = 0;

  const handlePlacemark = (pm: Element, folder: string): void => {
    const name = childLocal(pm, 'name')?.textContent?.trim() ?? '';
    const desc = childLocal(pm, 'description')?.textContent ?? '';
    const props = parseKmlDescProps(desc);
    const pget = (k: string): string | undefined => props.find((p) => p.k === k)?.v;

    // 좌표: <coordinates> lon,lat[,alt] 우선, 없으면 속성 lat/lon.
    let lat = NaN, lon = NaN;
    const coordEl = pm.getElementsByTagName('coordinates')[0];
    if (coordEl?.textContent) {
      const p = coordEl.textContent.trim().split(/[\s,]+/).map(Number);
      if (p.length >= 2 && isFinite(p[0]) && isFinite(p[1])) { lon = p[0]; lat = p[1]; }
    }
    if (isNaN(lat) || isNaN(lon)) {
      lat = parseFloat(pget('lat') ?? pget('latitude') ?? '');
      lon = parseFloat(pget('lon') ?? pget('longitude') ?? '');
    }
    if (isNaN(lat) || isNaN(lon) || lat < 33 || lat > 39 || lon < 124 || lon > 132) return;

    const lenNum = parseFloat(pget('연장(m)') ?? '');
    if (folder.includes('교량')) {
      structures.push({ id: `교량-${nStruct++}`, type: 'bridge', category: '교량', name: pget('구분') || name, lat, lon, ...(isNaN(lenNum) ? {} : { lengthM: lenNum }), ...(pget('시설종별') ? { grade: pget('시설종별') } : {}), props });
    } else if (folder.includes('터널')) {
      structures.push({ id: `터널-${nStruct++}`, type: 'tunnel', category: '터널', name: pget('구분') || name, lat, lon, ...(isNaN(lenNum) ? {} : { lengthM: lenNum }), ...(pget('시설종별') ? { grade: pget('시설종별') } : {}), props });
    } else if (folder.includes('구교')) {
      structures.push({ id: `구교-${nStruct++}`, type: 'bridge', category: '구교', name: pget('시설물명') || name, lat, lon, ...(isNaN(lenNum) ? {} : { lengthM: lenNum }), props });
    } else if (folder.includes('출입문')) {
      const title = pget('출입문번호') || name;
      if (title) pois.push({ title, category: '출입문', lat, lon, z: parseFloat(pget('z') ?? pget('절대고도') ?? '0') || 0, type: 'poi', props });
    } else {
      // 02)지장물 등 = 지오코딩 POI. 모든 POI 는 영상에 표출(철도역 포함).
      if (!name) return;
      const src = pget('source');
      const cat = pget('category_clean') || '지장물';
      // 라벨: KML '구분' → 'title' → (없으면) placemark name 순.
      const label = pget('구분') || pget('title') || name;
      pois.push({ title: label, category: cat, lat, lon, z: parseFloat(pget('z') ?? pget('Z좌표') ?? pget('절대고도') ?? '0') || 0, type: 'poi', props });
      // 철도역(KAKAO_RAIL)은 영상 POI 에 더해 '역사'로 하단 스테이션바에도 표출(lat/lon 으로 배치).
      if (src === 'KAKAO_RAIL' || cat === '철도역' || cat === '역사') {
        structures.push({ id: `역사-${nStruct++}`, type: 'station', category: '역사', name: label, lat, lon, props });
      }
    }
  };

  // 폴더 트리 재귀 — Placemark 가 직접 속한 (가장 안쪽) 폴더명을 folder 로 전달.
  const walk = (el: Element, folder: string): void => {
    for (const c of Array.from(el.children)) {
      if (c.localName === 'Folder') {
        walk(c, childLocal(c, 'name')?.textContent?.trim() ?? folder);
      } else if (c.localName === 'Document') {
        walk(c, folder);
      } else if (c.localName === 'Placemark') {
        handlePlacemark(c, folder);
      }
    }
  };
  if (doc.documentElement) walk(doc.documentElement, '');

  return { pois, structures };
}

// ── route.json 보정 ───────────────────────────────────────────────────

/**
 * 폴더 보조 파일 route.json 파싱 (UTF-8, JSON).
 * `<base>.route.json` 우선(case-insensitive), 없으면 `route.json`.
 * building/ 제외, 루트 파일만. 파싱 실패 시 null.
 */
export async function parseRouteMeta(
  files: File[],
  baseName: string | null,
): Promise<RouteMeta | null> {
  const rootJson = files.filter(
    (f) => !isInBuilding(f) && /\.json$/i.test(baseNameOf(relPath(f))),
  );
  if (!rootJson.length) return null;

  const wantBase = baseName ? `${baseName}.route.json`.toLowerCase() : null;
  const file =
    (wantBase &&
      rootJson.find((f) => baseNameOf(relPath(f)).toLowerCase() === wantBase)) ||
    rootJson.find((f) => baseNameOf(relPath(f)).toLowerCase() === 'route.json');
  if (!file) return null;

  try {
    const text = await file.text();
    return JSON.parse(text) as RouteMeta;
  } catch {
    return null;
  }
}

/** 이름 정규화(괄호 이하 제거) — route.json ↔ CSV 구조물 매칭용. */
function structBaseName(name: string): string {
  return name.replace(/\s*[(（].*$/, '').trim();
}

/**
 * CSV 유래 구조물에 route.json structures 의 offset/station/이정 등을 보정(augment)한다.
 * name 이 일치(괄호 이하 제거 후 부분일치)하는 항목의 값으로 override 하고,
 * CSV 에 없는 구조물(route.json 전용)은 그대로 추가한다.
 */
export function mergeStructures(
  csvStructures: RouteStructure[],
  meta: RouteMeta | null,
): RouteStructure[] {
  const metaList = meta?.structures;
  if (!metaList || !metaList.length) return csvStructures;

  const result = csvStructures.map((s) => ({ ...s }));
  const used = new Set<number>();

  for (const m of metaList) {
    const mBase = structBaseName(m.name);
    const idx = result.findIndex((s) => {
      const sBase = structBaseName(s.name);
      return sBase === mBase || sBase.includes(mBase) || mBase.includes(sBase);
    });
    if (idx >= 0) {
      // 보정: route.json 에 명시된 필드만 override (CSV 좌표/연장은 유지).
      const tgt = result[idx];
      if (m.station != null) tgt.station = m.station;
      if (m.offset != null) tgt.offset = m.offset;
      if (m.startMileage != null) tgt.startMileage = m.startMileage;
      if (m.endMileage != null) tgt.endMileage = m.endMileage;
      if (m.lat != null) tgt.lat = m.lat;
      if (m.lon != null) tgt.lon = m.lon;
      used.add(idx);
    } else {
      // CSV 에 없는 route.json 전용 구조물 → 그대로 추가.
      result.push({ ...m });
    }
  }
  return result;
}

// ── POI 위치 보정 (마우스 드래그 저장값) ──────────────────────────────

/**
 * 폴더에서 POI 보정 파일을 읽는다. `<base>_poi_overrides.json` 우선,
 * 없으면 `poi_overrides.json`. building/ 제외, 루트만. 파싱 실패 시 {}.
 * 형식: { baseName?, overrides: { "<title>": {lat,lon,z}, ... } }  (또는 평면 맵).
 */
export async function parsePoiOverrides(
  files: File[],
  baseName: string | null,
): Promise<PoiOverrideMap> {
  const rootJson = files.filter(
    (f) => !isInBuilding(f) && /_poi_overrides\.json$/i.test(baseNameOf(relPath(f))),
  );
  if (!rootJson.length) return {};
  const wantBase = baseName ? `${baseName}_poi_overrides.json`.toLowerCase() : null;
  const file =
    (wantBase && rootJson.find((f) => baseNameOf(relPath(f)).toLowerCase() === wantBase)) ||
    rootJson[0];
  try {
    const obj = JSON.parse(await file.text());
    const map = (obj && typeof obj === 'object' && obj.overrides) ? obj.overrides : obj;
    const out: PoiOverrideMap = {};
    for (const [title, v] of Object.entries(map as Record<string, unknown>)) {
      const o = v as { lat?: number; lon?: number; z?: number };
      if (o && isFinite(o.lat as number) && isFinite(o.lon as number)) {
        out[title] = { lat: o.lat as number, lon: o.lon as number, z: Number(o.z) || 0 };
      }
    }
    return out;
  } catch {
    return {};
  }
}

/** 보정맵을 POI 배열에 적용 (title 일치 시 lat/lon/z 교체). 원본 불변. */
export function applyPoiOverrides(pois: GeoPoint[], overrides: PoiOverrideMap): GeoPoint[] {
  if (!overrides || !Object.keys(overrides).length) return pois;
  return pois.map((p) => {
    const o = overrides[p.title];
    return o ? { ...p, lat: o.lat, lon: o.lon, z: o.z } : p;
  });
}

// ── 중심선 생성 (v2.0엔 center.csv 없음) ──────────────────────────────

/**
 * 측점을 측점값(mileage) 순으로 정렬한 폴리라인을 중심선으로 생성한다.
 * v2.0엔 center.csv 가 없으므로 측점(01)측점)이 중심선의 소스다.
 * 이 중심선이 체이니지 투영/RoutePanel 에 쓰인다.
 */
export function buildCenterlineFromStations(stations: GeoPoint[]): CenterlinePoint[] {
  return [...stations]
    .filter((s) => !isNaN(s.lat) && !isNaN(s.lon))
    .sort((a, b) => stationOrder(a.title) - stationOrder(b.title))
    .map((s) => ({ lat: s.lat, lon: s.lon, z: s.z }));
}

// ── 통합 로더 ─────────────────────────────────────────────────────────

/**
 * 폴더 내 파일에서 영상 + 지리정보(v2.0 형상)를 모두 파싱한다.
 * 반환값을 geoStore.loadFromFolder 가 스토어에 적재한다.
 */
export async function loadFolderGeoData(
  input: FileList | File[],
): Promise<FolderGeoData> {
  const files = Array.from(input);

  const videoFile = findVideoFile(files);
  const baseName = deriveBaseName(videoFile);

  const [frames, stationResult, routeMeta, poiOverrides, kmz] = await Promise.all([
    parseDroneFrames(files, baseName),
    parseStations(files),
    parseRouteMeta(files, baseName),
    parsePoiOverrides(files, baseName),
    parseKmz(files),
  ]);

  // POI/구조물 출처: KMZ(원본)가 유일한 소스다(지장물·출입문·교량/터널/구교·철도역).
  // KMZ 정책상 필수 — 없거나 비어 있으면 "데이터 누락"으로 보고 경고만 남기고 빈 상태로 둔다
  // (CSV 폴백 없음. building/ POI·구조물 CSV는 사용하지 않음). 측점·중심선은 항상 CSV(측점)에서 온다.
  const kmzMissing = !kmz || (kmz.pois.length === 0 && kmz.structures.length === 0);
  if (kmzMissing) {
    console.warn(
      '[KMZ] 누락/비어있음 — POI·구조물이 표시되지 않습니다. ' +
      'KMZ(원본)를 포함해 데이터를 재구축·전달하세요. (측점·드론 정보는 정상 로드)',
    );
  } else {
    console.log(`[KMZ] POI ${kmz!.pois.length} · 구조물 ${kmz!.structures.length} 로드(원본)`);
  }
  const pois = kmz?.pois ?? [];
  const baseStructures = kmz?.structures ?? [];

  const stations = stationResult.stations.sort(
    (a, b) => stationOrder(a.title) - stationOrder(b.title),
  );
  const directionChanges = stationResult.directionChanges;

  // route.json 은 선택적 보정 레이어 — 있으면 구조물에 override/augment.
  // 철도역(역사)은 KMZ의 KAKAO_RAIL placemark에서 구조물로 생성됨(스테이션바 표출).
  const structures = mergeStructures(baseStructures, routeMeta);

  // v2.0엔 center.csv 가 없다 → 측점 폴리라인으로 중심선 생성.
  const centerline = buildCenterlineFromStations(stations);

  let origin: GeoOrigin | null = null;
  if (stations.length || frames.length) {
    origin = getWorldOrigin(frames, [...stations, ...pois]);
  } else if (centerline.length) {
    origin = { lat: centerline[0].lat, lon: centerline[0].lon, alt: centerline[0].z };
  }

  return {
    videoFile,
    baseName,
    frames,
    pois,
    stations,
    centerline,
    origin,
    routeMeta,
    structures,
    directionChanges,
    poiOverrides,
    kmzMissing,
  };
}
