/**
 * 클라이언트 폴더 기반 지리정보 로딩 + 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 { detectCameraInfo } from './cameraMeta';
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;

/** 폴더 내 모든 영상 파일 (이름순 정렬 — DJI 분할본 _0002, _0003… 순서 보장). */
export function findVideoFiles(files: File[]): File[] {
  return files
    .filter((f) => !isInBuilding(f) && VIDEO_EXT.test(baseNameOf(relPath(f))))
    .sort((a, b) => baseNameOf(relPath(a)).localeCompare(baseNameOf(relPath(b))));
}

/** 영상 파일 찾기 (mp4/webm, building 제외). 여러 개면 이름순 첫 번째. */
export function findVideoFile(files: File[]): File | null {
  return findVideoFiles(files)[0] ?? null;
}

/** 영상 File 의 재생 길이(초) — 메타데이터만 읽는다(전체 로드 없음). 실패 시 null. */
export function probeVideoDuration(file: File): Promise<number | null> {
  return new Promise((resolve) => {
    const url = URL.createObjectURL(file);
    const v = document.createElement('video');
    v.preload = 'metadata';
    const done = (d: number | null): void => {
      URL.revokeObjectURL(url);
      v.removeAttribute('src');
      resolve(d);
    };
    v.onloadedmetadata = () => done(isFinite(v.duration) ? v.duration : null);
    v.onerror = () => done(null);
    v.src = url;
  });
}

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

// ── 파서: 드론 프레임 ─────────────────────────────────────────────────

/**
 * DJI 비행로그(시간 기반) → 프레임 환산용 가상 fps.
 * frame = 영상 상대시간(초) × 이 값. 소비측(effectiveFps = maxFrame ÷ 영상길이)이
 * 같은 값을 역산해 시간으로 되돌리므로 상수 자체는 임의여도 정합이 유지된다.
 * 59.94 는 이 데이터셋(DJI 4K60) 실제 fps 와 일치시켜 HUD 표기도 자연스럽게 한 것.
 */
export const DJI_VIRTUAL_FPS = 59.94;

/** 영상 세그먼트 창 — DJI 시간 기반 로그를 특정 영상 구간에 정렬할 때 사용. */
export interface SegmentWindow {
  /** 전체 녹화 기준 이 영상의 시작 오프셋(초). 첫 세그먼트는 0. */
  offsetSec: number;
  /** 이 영상의 길이(초). 미상(null)이면 클리핑 없이 전체 로그를 쓴다. */
  durationSec: number | null;
}

/**
 * 드론 프레임 CSV 파싱 (UTF-8 BOM, 헤더 이름 인덱스). 두 형식 지원:
 *
 * ① 프레임 기반(회덕 v2.0): frame_cnt,latitude,longitude,altitude,yaw,pitch,roll,focal_len
 * ② 시간 기반(DJI 비행로그, 제주): time(millisecond),latitude,longitude,…,
 *    altitude_above_seaLevel/compass_heading/gimbal_heading(degrees) 등.
 *    - 첫 행 time = 녹화 시작으로 보고(로그 폭 ≈ 분할영상 총길이 검증됨),
 *      frame = ((time−t0)/1000 − offsetSec) × DJI_VIRTUAL_FPS 로 환산.
 *    - 자세는 카메라 방향인 gimbal_* 우선, 없으면 기체 compass/pitch/roll 폴백.
 *    - focal_len 이 없어 35mm 환산 24mm(DJI 광각 기본)로 둔다 → 카메라 파라미터 UI로 보정.
 *    - segmentWindow.durationSec 가 있으면 해당 영상 구간 밖 행을 버린다
 *      (분할 영상에서 effectiveFps 자기보정이 성립하려면 필수).
 *
 * 루트(building 제외)의 <base>.csv 를 식별한다. base 가 없으면 루트 .csv 중
 * POI 가 아닌 첫 파일을 사용한다(영상명 비의존 폴백).
 */
export async function parseDroneFrames(
  files: File[],
  baseName: string | null,
  segmentWindow: SegmentWindow | null = 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]);

  // ② 시간 기반(DJI 비행로그) — time(millisecond) 헤더로 감지.
  const iTime = fi('time(millisecond)');
  if (iTime >= 0) {
    const iLat = fi('latitude');
    const iLon = fi('longitude');
    const iAlt = fi('altitude_above_seaLevel(meter)');
    const iYaw = fi('gimbal_heading(degrees)') >= 0 ? fi('gimbal_heading(degrees)') : fi('compass_heading(degrees)');
    const iPitch = fi('gimbal_pitch(degrees)') >= 0 ? fi('gimbal_pitch(degrees)') : fi('pitch(degrees)');
    const iRoll = fi('gimbal_roll(degrees)') >= 0 ? fi('gimbal_roll(degrees)') : fi('roll(degrees)');

    const raw = rows
      .slice(1)
      .map((r) => ({
        timeMs: parseFloat(cell(r, iTime)),
        lat: parseFloat(cell(r, iLat)),
        lon: parseFloat(cell(r, iLon)),
        altitude: parseFloat(cell(r, iAlt)),
        yaw: parseFloat(cell(r, iYaw)),
        pitch: parseFloat(cell(r, iPitch)),
        roll: parseFloat(cell(r, iRoll)) || 0,
        focalLen: 24,
      }))
      .filter((f) => !isNaN(f.lat) && !isNaN(f.timeMs));
    if (!raw.length) return [];

    const t0 = raw[0].timeMs;
    const offsetSec = segmentWindow?.offsetSec ?? 0;
    const durationSec = segmentWindow?.durationSec ?? null;
    const maxFrame = durationSec != null ? durationSec * DJI_VIRTUAL_FPS : Infinity;

    const frames = raw
      .map(({ timeMs, ...rest }) => ({
        ...rest,
        frame: Math.round(((timeMs - t0) / 1000 - offsetSec) * DJI_VIRTUAL_FPS),
      }))
      .filter((f) => f.frame >= 0 && f.frame <= maxFrame);

    console.log(
      `[geo] DJI 시간기반 로그: ${raw.length}행 중 ${frames.length}행 사용 ` +
      `(구간 ${offsetSec.toFixed(1)}s~${durationSec != null ? (offsetSec + durationSec).toFixed(1) : '끝'}s, 가상fps ${DJI_VIRTUAL_FPS})`,
    );
    return frames;
  }

  // ① 프레임 기반(회덕 v2.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;
}

/** 한국어 색상명 → CSS 색 (KML 텍스트박스_색상 속성). 어두운 색은 렌더 시 밝은 외곽선 처리. */
const KOR_LABEL_COLORS: Record<string, string> = {
  검정: '#1f2937', 흰색: '#ffffff', 회색: '#9ca3af',
  빨강: '#ef4444', 주황: '#fb923c', 노랑: '#facc15',
  연두: '#a3e635', 초록: '#22c55e', 하늘: '#38bdf8',
  파랑: '#3b82f6', 남색: '#3730a3', 보라: '#a855f7', 분홍: '#f472b6',
};

function koreanColorToCss(v?: string): string | undefined {
  if (!v) return undefined;
  const key = v.trim();
  if (KOR_LABEL_COLORS[key]) return KOR_LABEL_COLORS[key];
  for (const k in KOR_LABEL_COLORS) if (key.includes(k)) return KOR_LABEL_COLORS[k];
  return undefined;
}

/** Placemark 인라인 IconStyle 색 — KML aabbggrr → CSS #rrggbb. */
function inlineIconColor(pm: Element): string | undefined {
  const icon = pm.getElementsByTagName('IconStyle')[0];
  const c = icon?.getElementsByTagName('color')[0]?.textContent?.trim();
  if (!c || !/^[0-9a-fA-F]{8}$/.test(c)) return undefined;
  const bb = c.slice(2, 4), gg = c.slice(4, 6), rr = c.slice(6, 8);
  return `#${rr}${gg}${bb}`.toLowerCase();
}

/** KMZ/KML 파싱 — 있으면 { pois, structures, stations }, 없거나 실패 시 null.
 *  bare .kml(구글 직접 다운로드) 우선, 없으면 .kmz(zip) 해제.
 *
 *  속성 소스 2종을 모두 props 로 수집한다:
 *   - description CDATA HTML 표 (구글어스/지오코딩 산출물 — 회덕 v2.0)
 *   - ExtendedData/SchemaData/SimpleData (CAD·GIS 내보내기 — 제주 도로 KML)
 *
 *  측점(STA): 이름이 도로 체이니지(`6+998`) 형식이거나 STA 속성이 있는 Point 는
 *  POI 가 아니라 측점(type='station')으로 수집한다. km 속성(단위 km) 우선, 없으면
 *  이름에서 미터값을 파싱해 기존 철도식 제목(`6k998`)으로 변환 — stationOrder/stationKm
 *  등 측점 파이프라인(스테이션바·노선패널·검색)과 그대로 호환된다. */
export async function parseKmz(
  files: File[],
): Promise<{ pois: GeoPoint[]; structures: RouteStructure[]; stations: GeoPoint[]; centerline: CenterlinePoint[] } | null> {
  // 폴더 내 모든 KML/KMZ 를 파싱해 병합한다 (예: 제주 — 측점 KML + 지장물 KML 별도 파일).
  // 단, `<이름>_byDem.kml`(DEM 표고 채운 파생본)이 있으면 그 원본 `<이름>.kml` 은 제외 —
  // 같은 지물이 두 번 파싱되는 것 방지 + 표고 보유 버전 우선.
  let kmlFiles = files.filter(
    (f) => !isInBuilding(f) && /\.kml$/i.test(baseNameOf(relPath(f))),
  );
  const byDemBases = new Set(
    kmlFiles
      .map((f) => baseNameOf(relPath(f)).toLowerCase().match(/^(.+)_bydem\.kml$/)?.[1])
      .filter((b): b is string => !!b),
  );
  if (byDemBases.size) {
    kmlFiles = kmlFiles.filter((f) => {
      const name = baseNameOf(relPath(f)).toLowerCase();
      const m = name.match(/^(.+)\.kml$/);
      return !(m && !m[1].endsWith('_bydem') && byDemBases.has(m[1]));
    });
  }
  const kmzFiles = files.filter(
    (f) => !isInBuilding(f) && /\.kmz$/i.test(baseNameOf(relPath(f))),
  );
  if (!kmlFiles.length && !kmzFiles.length) return null;

  const kmlTexts: string[] = [];
  for (const kf of kmlFiles) {
    try {
      kmlTexts.push(await kf.text());   // 구글 KML 은 UTF-8
    } catch (e) {
      console.warn(`[KML] 읽기 실패: ${baseNameOf(relPath(kf))}`, e);
    }
  }
  for (const kf of kmzFiles) {
    try {
      const buf = new Uint8Array(await kf.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]) kmlTexts.push(strFromU8(entries[kmlName]));
    } catch (e) {
      console.warn(`[KMZ] 읽기 실패: ${baseNameOf(relPath(kf))}`, e);
    }
  }
  if (!kmlTexts.length) return null;

  const pois: GeoPoint[] = [];
  const structures: RouteStructure[] = [];
  const stations: GeoPoint[] = [];
  // 선형중심선 (LineString) — 도로 중심선 폴리라인. 여러 개면 가장 긴 것 사용.
  let centerline: CenterlinePoint[] = [];
  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);
    // ExtendedData/SchemaData/SimpleData 속성 (CAD·GIS 내보내기) — HTML 표 props 와 통합.
    for (const sd of Array.from(pm.getElementsByTagName('SimpleData'))) {
      const k = sd.getAttribute('name') ?? '';
      const v = sd.textContent?.trim() ?? '';
      if (k) props.push({ k, v });
    }
    const pget = (k: string): string | undefined => props.find((p) => p.k === k)?.v;

    // 선형중심선 (LineString) — 도로 중심선 폴리라인으로 수집(POI 아님).
    // CAD 내보내기 KML(예: 제주_중산간도로.kml Folder 선형중심선)의 실제 도로 곡선.
    const lineEl = pm.getElementsByTagName('LineString')[0];
    if (lineEl) {
      const coordText = lineEl.getElementsByTagName('coordinates')[0]?.textContent ?? '';
      const pts: CenterlinePoint[] = [];
      let prevKey = '';
      for (const tok of coordText.trim().split(/\s+/)) {
        const p = tok.split(',').map(Number);
        if (p.length < 2 || !isFinite(p[0]) || !isFinite(p[1])) continue;
        if (p[1] < 33 || p[1] > 39 || p[0] < 124 || p[0] > 132) continue;
        const key = `${p[0].toFixed(8)},${p[1].toFixed(8)}`;
        if (key === prevKey) continue; // 연속 중복 정점 제거
        prevKey = key;
        pts.push({ lat: p[1], lon: p[0], z: p.length >= 3 && isFinite(p[2]) ? p[2] : 0 });
      }
      if (pts.length >= 2 && pts.length > centerline.length) {
        // z 톱니 노이즈 평활 — 원본(지형고도 샘플링)의 ±1m 양자화 잔차가 인접 정점에서
        // 부호 반전 톱니를 만들고, 영상의 낮은 시야각 투영에서 선이 지그재그로 꺾여 보임.
        // 도로 종단 경사는 완만하므로 ±3점 이동평균으로 톱니만 제거(경사 보존).
        const zs = pts.map((p) => p.z);
        for (let i = 0; i < pts.length; i++) {
          if (zs[i] <= 0) continue;
          let sum = 0, cnt = 0;
          for (let j = Math.max(0, i - 3); j <= Math.min(pts.length - 1, i + 3); j++) {
            if (zs[j] > 0) { sum += zs[j]; cnt++; }
          }
          if (cnt) pts[i].z = sum / cnt;
        }
        centerline = pts;
      }
      return;
    }

    // 좌표: <coordinates> lon,lat[,alt] 우선, 없으면 속성 lat/lon.
    // alt(3번째 성분)는 DEM 일괄 기록(docs/history 2026-07-13 참조) 등으로 채워진 지면 표고 —
    // 있으면(>0) POI/측점의 z 로 사용해 지형 기복을 정확히 따른다.
    let lat = NaN, lon = NaN;
    let coordAlt: number | undefined;
    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 (p.length >= 3 && isFinite(p[2]) && p[2] > 0) coordAlt = p[2];
      }
    }
    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;

    // 새 포맷: 구글어스 CSV 변환 지장물 KML — Schema(타입/타입상세/이름/연장/텍스트박스_색상/높이),
    // 폴더 구분 없이 Placemark 직속. '타입' 키 존재로 감지한다(회덕 desc표/STA 스키마엔 없음).
    // 좌표는 SimpleData X/Y(저정밀)가 아니라 Point coordinates(고정밀)를 쓴다(위에서 이미 추출).
    if (pget('타입') !== undefined) {
      const title = pget('이름') || name;
      if (!title) return;
      const category = pget('타입') || '지장물';
      const lenM = parseFloat(pget('연장') ?? '');
      // 라벨 색: 텍스트박스_색상(한국어명) 우선, 없으면 인라인 IconStyle 색.
      const labelColor = koreanColorToCss(pget('텍스트박스_색상')) ?? inlineIconColor(pm);
      // z: 좌표 고도(DEM 기록 지면 표고) 있으면 사용 — 지형 기복을 따라 정확히 배치.
      // ('높이' 속성은 표고가 아니라 구조물 높이라 사용하지 않음 — 팝업 props 로만 노출.)
      // 미상(0)이면 오버레이가 지면고도 모드에서 경로표고 앵커로 폴백.
      pois.push({
        title, category, lat, lon, z: coordAlt ?? 0, type: 'poi',
        ...(labelColor ? { labelColor } : {}),
        props,
      });
      // 교량/터널은 스테이션바·노선패널 마크용 구조물로도 등록.
      if (category.includes('교량')) {
        structures.push({ id: `교량-${nStruct++}`, type: 'bridge', category: '교량', name: title, lat, lon, ...(isFinite(lenM) ? { lengthM: lenM } : {}), props });
      } else if (category.includes('터널')) {
        structures.push({ id: `터널-${nStruct++}`, type: 'tunnel', category: '터널', name: title, lat, lon, ...(isFinite(lenM) ? { lengthM: lenM } : {}), props });
      }
      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 {
      // 도로 체이니지 측점 (예: "0+100" 또는 STA 속성) → type='station' 으로 수집.
      // km 속성(단위 km) 우선, 없으면 이름의 "k+m" 파싱. 제목은 철도식 NkNNN 으로 변환.
      const staName = pget('STA') || name;
      const staMatch = staName.match(/^(\d+)\+(\d+)$/);
      if (staMatch || pget('STA') !== undefined) {
        const kmProp = parseFloat(pget('km') ?? '');
        const meters = isFinite(kmProp)
          ? Math.round(kmProp * 1000)
          : staMatch ? parseInt(staMatch[1], 10) * 1000 + parseInt(staMatch[2], 10) : NaN;
        if (!isFinite(meters)) return;
        const title = `${Math.floor(meters / 1000)}+${String(meters % 1000).padStart(3, '0')}`;
        // z: 좌표 고도(DEM 기록값) 있으면 사용 — 없으면 0(미상, 오버레이가 경로표고 앵커).
        stations.push({ title, category: '측점', lat, lon, z: coordAlt ?? 0, type: 'station', props });
        return;
      }
      // 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);
      }
    }
  };
  for (const text of kmlTexts) {
    const doc = new DOMParser().parseFromString(text, 'application/xml');
    if (doc.getElementsByTagName('parsererror').length) {
      console.warn('[KML] XML 파싱 실패 — 해당 파일 건너뜀');
      continue;
    }
    if (doc.documentElement) walk(doc.documentElement, '');
  }

  // 선형이 측점 POI 를 정확히 통과하도록 정비 — 측점 기준.
  //   ① 측점 위치를 선형 폴리라인에 '정점으로 삽입' (긴 직선 현 중간의 측점도 통과 보장 —
  //      라벨과 선이 같은 점(lat/lon/z)을 투영하므로 어떤 카메라 자세에서도 화면에서 일치)
  //   ② 나머지 원본 정점 z 는 측점 z 의 체이니지 보간으로 교체 (톱니 평활 대체, 종단 = 측점)
  const staZ = stations.filter((s) => isFinite(s.z) && s.z > 0);
  if (centerline.length >= 2 && staZ.length >= 2) {
    const cosLat = Math.cos((centerline[0].lat * Math.PI) / 180);
    const ex = (p: { lat: number; lon: number }): number => (p.lon - centerline[0].lon) * 111320 * cosLat;
    const ey = (p: { lat: number; lon: number }): number => (p.lat - centerline[0].lat) * 111320;
    const cum: number[] = [0];
    for (let i = 1; i < centerline.length; i++) {
      cum.push(cum[i - 1] + Math.hypot(ex(centerline[i]) - ex(centerline[i - 1]), ey(centerline[i]) - ey(centerline[i - 1])));
    }
    // 측점 → 선형 세그먼트 투영 (선형에서 50m 이상 떨어진 측점은 제외)
    const anchors: { s: number; z: number; seg: number; t: number; lat: number; lon: number }[] = [];
    for (const st of staZ) {
      const px = ex(st), py = ey(st);
      let bestS = 0, bestD = Infinity, bestSeg = 0, bestT = 0;
      for (let i = 0; i < centerline.length - 1; i++) {
        const ax = ex(centerline[i]), ay = ey(centerline[i]);
        const dx = ex(centerline[i + 1]) - ax, dy = ey(centerline[i + 1]) - ay;
        const len2 = dx * dx + dy * dy;
        const t = len2 > 0 ? Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / len2)) : 0;
        const d = Math.hypot(px - (ax + dx * t), py - (ay + dy * t));
        if (d < bestD) { bestD = d; bestS = cum[i] + Math.sqrt(len2) * t; bestSeg = i; bestT = t; }
      }
      if (bestD < 50) anchors.push({ s: bestS, z: st.z, seg: bestSeg, t: bestT, lat: st.lat, lon: st.lon });
    }
    anchors.sort((a, b) => a.s - b.s);
    if (anchors.length >= 2) {
      // ② 원본 정점 z ← 측점 체이니지 보간
      for (let i = 0; i < centerline.length; i++) {
        const s = cum[i];
        if (s <= anchors[0].s) { centerline[i].z = anchors[0].z; continue; }
        if (s >= anchors[anchors.length - 1].s) { centerline[i].z = anchors[anchors.length - 1].z; continue; }
        let lo = 0, hi = anchors.length - 1;
        while (hi - lo > 1) { const m = (lo + hi) >> 1; if (anchors[m].s <= s) lo = m; else hi = m; }
        const a = anchors[lo], b = anchors[hi];
        const r = b.s > a.s ? (s - a.s) / (b.s - a.s) : 0;
        centerline[i].z = a.z + (b.z - a.z) * r;
      }
      // ① 측점 정점 삽입 — 세그먼트별로 t 순 삽입. 기존 정점과 1m 이내면 생략(중복 방지).
      const rebuilt: CenterlinePoint[] = [];
      let inserted = 0;
      for (let i = 0; i < centerline.length; i++) {
        rebuilt.push(centerline[i]);
        if (i === centerline.length - 1) break;
        const onSeg = anchors.filter((a) => a.seg === i).sort((a, b) => a.t - b.t);
        for (const a of onSeg) {
          const near = (p: CenterlinePoint): boolean =>
            Math.hypot(ex(a) - ex(p), ey(a) - ey(p)) < 1;
          if (near(centerline[i]) || near(centerline[i + 1])) continue;
          rebuilt.push({ lat: a.lat, lon: a.lon, z: a.z });
          inserted++;
        }
      }
      centerline = rebuilt;
      console.log(`[KML] 선형-측점 정합: 측점 정점 ${inserted}개 삽입 + 정점 z 를 측점 ${anchors.length}점 기준 재정렬`);
    }
  }

  // 곡선 평활 — 측점 정점 삽입/정비가 끝난 최종 선형에 적용(원 정점 전부 보존).
  if (centerline.length >= 4) {
    const before = centerline.length;
    centerline = smoothCenterlineCR(centerline);
    if (centerline.length !== before) {
      console.log(`[KML] 선형 곡선 평활(Catmull-Rom): ${before} → ${centerline.length}점`);
    }
  }

  return { pois, structures, stations, centerline };
}

/**
 * 선형 곡선 평활 — Centripetal Catmull-Rom 으로 정점 사이를 세분(모든 원 정점 통과).
 * 곡선부(정점 ~20m 간격)의 직선 연결이 화면에서 각져 보이는 것을 해소한다.
 * 측점 삽입 정점도 그대로 통과하므로 선형-측점 정합 유지. 60m 이상 장대 세그먼트
 * (직선 구간)와 양끝 세그먼트는 세분하지 않는다(불필요·경계 불안정 방지).
 */
function smoothCenterlineCR(pts: CenterlinePoint[]): CenterlinePoint[] {
  if (pts.length < 4) return pts;
  const k = Math.cos((pts[0].lat * Math.PI) / 180) * 111320;
  const dist = (a: CenterlinePoint, b: CenterlinePoint): number =>
    Math.hypot((b.lon - a.lon) * k, (b.lat - a.lat) * 111320);
  const out: CenterlinePoint[] = [];
  for (let i = 0; i < pts.length - 1; i++) {
    const p1 = pts[i], p2 = pts[i + 1];
    out.push(p1);
    if (i === 0 || i === pts.length - 2) continue;   // 양끝 세그먼트는 직선 유지
    const len = dist(p1, p2);
    if (len < 2 || len >= 60) continue;              // 중복/장대 세그먼트 제외
    const p0 = pts[i - 1], p3 = pts[i + 2];
    const sub = Math.min(6, Math.max(2, Math.ceil(len / 6)));
    const t0 = 0;
    const t1 = t0 + Math.max(0.7, Math.sqrt(dist(p0, p1)));
    const t2 = t1 + Math.max(0.7, Math.sqrt(len));
    const t3 = t2 + Math.max(0.7, Math.sqrt(dist(p2, p3)));
    const bg = (c0: number, c1: number, c2: number, c3: number, t: number): number => {
      const A1 = ((t1 - t) * c0 + (t - t0) * c1) / (t1 - t0);
      const A2 = ((t2 - t) * c1 + (t - t1) * c2) / (t2 - t1);
      const A3 = ((t3 - t) * c2 + (t - t2) * c3) / (t3 - t2);
      const B1 = ((t2 - t) * A1 + (t - t0) * A2) / (t2 - t0);
      const B2 = ((t3 - t) * A2 + (t - t1) * A3) / (t3 - t1);
      return ((t2 - t) * B1 + (t - t1) * B2) / (t2 - t1);
    };
    for (let j = 1; j < sub; j++) {
      const t = t1 + ((t2 - t1) * j) / sub;
      out.push({
        lat: bg(p0.lat, p1.lat, p2.lat, p3.lat, t),
        lon: bg(p0.lon, p1.lon, p2.lon, p3.lon, t),
        z: bg(p0.z, p1.z, p2.z, p3.z, t),
      });
    }
  }
  out.push(pts[pts.length - 1]);
  return out;
}

// ── 카메라 파라미터 파일 (<base>.camera.json) ─────────────────────────

/**
 * 폴더에서 카메라 파라미터 파일을 읽는다. 'PC에 저장' 버튼이 만든 형식:
 *   { "camera": { focalLen, sensorW, sensorH, yawOffset, pitch, roll, offX, offY, offZ, geoidOffset, cx0, cy0 }, ... }
 * 폴더 공용 설정 — 파일명 규칙(우선순위):
 *   ① `camera.json` (단순 이름 — 권장)
 *   ② `<영상 base>.camera.json` / `<영상 base>.json` (어느 분할본 base 든)
 *   ③ 임의 이름의 `*.camera.json` (예: "제주 중산간도로 경로 1-1.camera.json")
 * 같은 폴더의 모든 영상(분할본 전체)에 동일하게 적용된다. 숫자 필드만 통과시킨다.
 */
export async function parseCameraJson(
  files: File[],
  videoFiles: File[],
): Promise<Record<string, number> | null> {
  const bases = videoFiles.map((v) => baseNameOf(relPath(v)).replace(VIDEO_EXT, '').toLowerCase());
  const rootJson = files.filter((f) => {
    if (isInBuilding(f)) return false;
    const name = baseNameOf(relPath(f)).toLowerCase();
    return /\.json$/.test(name) && !/^route\.json$|\.route\.json$|^display\.json$|\.display\.json$|_poi_overrides\.json$/.test(name);
  });
  const file =
    rootJson.find((f) => baseNameOf(relPath(f)).toLowerCase() === 'camera.json') ??
    rootJson.find((f) => {
      const m = baseNameOf(relPath(f)).toLowerCase().match(/^(.+?)(\.camera)?\.json$/);
      return !!m && bases.includes(m[1]);
    }) ??
    rootJson.find((f) => /\.camera\.json$/i.test(baseNameOf(relPath(f))));
  if (!file) return null;
  try {
    const obj = JSON.parse(await file.text());
    const cam = obj && typeof obj === 'object' ? (obj.camera ?? obj) : null;
    if (!cam || typeof cam !== 'object') return null;
    const out: Record<string, number> = {};
    for (const [k, v] of Object.entries(cam)) {
      if (typeof v === 'number' && isFinite(v)) out[k] = v;
    }
    return Object.keys(out).length ? out : null;
  } catch {
    return null;
  }
}

// ── 화면표시 옵션 파일 (<base>.display.json) ──────────────────────────

/**
 * 폴더에서 화면표시 옵션 파일을 읽는다. '화면표시 옵션 PC 저장' 버튼이 만든
 * `{ display: {...}, savedAt }` 형식. 폴더 공용 설정 — 파일명 규칙(우선순위):
 *   ① `display.json` (단순 이름 — 권장)
 *   ② `<영상 base>.display.json`
 *   ③ 임의 이름의 `*.display.json` (예: "제주 중산간도로 경로 1-1.display.json")
 * 폴더 내 모든 영상에 동일 적용.
 */
export async function parseDisplayJson(
  files: File[],
  videoFiles: File[],
): Promise<Record<string, unknown> | null> {
  const bases = videoFiles.map((v) => baseNameOf(relPath(v)).replace(VIDEO_EXT, '').toLowerCase());
  const rootDisplay = files.filter(
    (f) => !isInBuilding(f) && /(^|\.)display\.json$/i.test(baseNameOf(relPath(f)).toLowerCase()),
  );
  const file =
    rootDisplay.find((f) => baseNameOf(relPath(f)).toLowerCase() === 'display.json') ??
    rootDisplay.find((f) => {
      const m = baseNameOf(relPath(f)).toLowerCase().match(/^(.+?)\.display\.json$/);
      return !!m && bases.includes(m[1]);
    }) ?? rootDisplay[0];
  if (!file) return null;
  try {
    const obj = JSON.parse(await file.text());
    const d = obj && typeof obj === 'object' ? (obj.display ?? obj) : null;
    return d && typeof d === 'object' ? (d as Record<string, unknown>) : null;
  } catch {
    return null;
  }
}

// ── 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') ||
    // 임의 접두 `<이름>.route.json` 허용 (예: 제주 중산간도로 경로 1-1.route.json)
    rootJson.find((f) => /\.route\.json$/i.test(baseNameOf(relPath(f))));
  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 videoFiles = findVideoFiles(files);
  const videoFile = videoFiles[0] ?? null;
  const baseName = deriveBaseName(videoFile);

  // 각 영상 길이를 메타데이터로 측정 — DJI 시간 기반 로그의 세그먼트 정렬(누적 오프셋)과
  // ended 연속재생(geoStore.loadSegment)에 쓴다. 분할본이 아니면 1개만 측정.
  const videoDurations: (number | null)[] = [];
  for (const vf of videoFiles) videoDurations.push(await probeVideoDuration(vf));

  // 첫 세그먼트 창 — 로그는 전체 녹화(분할본 합)를 커버하므로 첫 영상 구간만 잘라 쓴다.
  const segmentWindow: SegmentWindow | null = videoFile
    ? { offsetSec: 0, durationSec: videoDurations[0] }
    : null;
  if (videoFiles.length > 1) {
    console.log(
      `[geo] 분할 영상 ${videoFiles.length}개 감지 (이름 오름차순 연속재생): ` +
      videoFiles.map((vf, i) => `${baseNameOf(relPath(vf))}(${videoDurations[i]?.toFixed(1) ?? '?'}s)`).join(' → '),
    );
  }

  const [frames, fullFrames, stationResult, routeMeta, poiOverrides, kmz, cameraInfo, cameraJson, displayJson] = await Promise.all([
    parseDroneFrames(files, baseName, segmentWindow),
    // 전체 비행(모든 세그먼트) 프레임 — 측점 '전 구간' 검색용(표시용 frames 와 별개)
    parseDroneFrames(files, baseName, null),
    parseStations(files),
    parseRouteMeta(files, baseName),
    parsePoiOverrides(files, baseName),
    parseKmz(files),
    videoFile ? detectCameraInfo(videoFile) : Promise.resolve(null),
    parseCameraJson(files, videoFiles),
    parseDisplayJson(files, videoFiles),
  ]);

  if (displayJson) {
    console.log('[display] <base>.display.json 발견 — 저장된 화면표시 옵션 적용 예정');
  }

  if (cameraJson) {
    console.log('[camera] <base>.camera.json 발견 — 저장된 카메라 파라미터 적용:', cameraJson);
  }

  // 타원체고 기준 데이터셋 자동 변환 — route.json 에 ellipsoidal_height 가 선언된 폴더는
  // 지물(측점/지장물) z 가 타원체고이므로, 드론 고도(CSV 정표고)를 영상 메타(djmd)의
  // 타원체고와의 차이(= 그 지점 지오이드고)만큼 올려 기준을 통일한다. 지오이드/offZ 수동 보정 불필요.
  let altitudeOffsetM = 0;
  if (
    routeMeta?.routeInfo?.ellipsoidal_height != null &&
    cameraInfo?.ellipsoidalAltM != null &&
    frames.length
  ) {
    const geoidN = cameraInfo.ellipsoidalAltM - frames[0].altitude;
    if (isFinite(geoidN) && Math.abs(geoidN) < 60) {
      altitudeOffsetM = Math.round(geoidN * 100) / 100;
      for (const f of frames) f.altitude += altitudeOffsetM;
      for (const f of fullFrames) f.altitude += altitudeOffsetM;
      console.log(
        `[geo] 타원체고 기준 데이터셋 — 드론 고도를 타원체고로 변환 (+${altitudeOffsetM.toFixed(2)}m = ` +
        `djmd ${cameraInfo.ellipsoidalAltM.toFixed(1)} − CSV ${(frames[0].altitude - altitudeOffsetM).toFixed(1)})`,
      );
    }
  }

  // 감지된 초점거리를 드론 프레임 focalLen 에도 반영 (검색/투영 FOV 판정 일관성).
  if (cameraInfo?.focalLen35) {
    for (const f of frames) f.focalLen = cameraInfo.focalLen35;
    for (const f of fullFrames) f.focalLen = cameraInfo.focalLen35;
    console.log(
      `[camera] ${cameraInfo.model} 감지 — focal ${cameraInfo.focalLen35}mm` +
      (cameraInfo.width ? `, ${cameraInfo.width}x${cameraInfo.height}@${cameraInfo.fps ?? '?'}fps` : ''),
    );
  } else if (cameraInfo) {
    console.log(`[camera] ${cameraInfo.model} 감지 (초점거리 정보 없음)`);
  }

  // POI/구조물 출처: KMZ(원본)가 유일한 소스다(지장물·출입문·교량/터널/구교·철도역).
  // KMZ 파일 자체가 없을 때만 "데이터 누락"(kmzMissing=alert 대상)으로 본다.
  // 파일은 있는데 POI/구조물이 0건인 경우(예: 제주 — 노선 KML만 있고 POI 없음)는
  // 정상 데이터셋으로 취급하고 콘솔 경고만 남긴다.
  const kmzMissing = !kmz;
  if (kmzMissing) {
    console.warn(
      '[KMZ] 누락 — POI·구조물이 표시되지 않습니다. ' +
      'KMZ(원본)를 포함해 데이터를 재구축·전달하세요. (측점·드론 정보는 정상 로드)',
    );
  } else if (kmz!.pois.length === 0 && kmz!.structures.length === 0 && kmz!.stations.length === 0) {
    console.warn('[KMZ] POI·구조물·측점 0건 — 이 데이터셋엔 표출할 지물이 없습니다(드론 궤적은 무관).');
  } else {
    console.log(`[KMZ] POI ${kmz!.pois.length} · 구조물 ${kmz!.structures.length} · 측점 ${kmz!.stations.length} · 중심선 ${kmz!.centerline.length}점 로드(원본)`);
  }
  const pois = kmz?.pois ?? [];
  const baseStructures = kmz?.structures ?? [];

  // 측점 소스: CSV(01)측점 — 실측 표고 보유) 우선, 없으면 KML 체이니지 측점(STA) 사용.
  // KML 측점은 표고 미상 → z=0 그대로 둔다(미상 표식). 드론고도 기반 근사(−24m)는
  // 실제 지면과 수십 m 어긋나 라벨이 공중에 떠서 시차 불일치(카메라 전진 시 라벨 밀림)를
  // 유발했음 → 오버레이가 z=0 라벨을 '경로 표고' 평면에 앵커하는 방식으로 대체.
  let rawStations = stationResult.stations;
  if (!rawStations.length && kmz?.stations.length) {
    rawStations = kmz.stations;
    const withZ = rawStations.filter((s) => s.z > 0).length;
    console.log(
      `[KMZ] 체이니지 측점 ${rawStations.length}개 로드` +
      (withZ ? ` (DEM 표고 보유 ${withZ}개)` : ' (표고 미상 → 오버레이 경로표고 앵커)'),
    );
  }

  const stations = rawStations.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);

  // 중심선 소스 우선순위: KML 선형중심선(LineString — 실제 도로 곡선, 예: 제주 146정점)
  // → 없으면 측점 폴리라인(100m 간격 직선 연결). v2.0엔 center.csv 가 없다.
  const centerline = (kmz?.centerline.length ?? 0) >= 2
    ? kmz!.centerline
    : buildCenterlineFromStations(stations);
  if ((kmz?.centerline.length ?? 0) >= 2) {
    console.log(`[geo] 중심선: KML 선형중심선 ${kmz!.centerline.length}점 사용`);
  }

  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,
    fullFrames,
    pois,
    stations,
    centerline,
    origin,
    routeMeta,
    structures,
    directionChanges,
    poiOverrides,
    kmzMissing,
    videoFiles,
    videoDurations,
    folderFiles: files,
    cameraInfo,
    cameraJson,
    displayJson,
    altitudeOffsetM,
  };
}
