"""구조물 조감도 생성 UI 앱.

독립적인 CustomTkinter 애플리케이션.

워크플로우:
  Step 1: DXF 파일 업로드 (다중 파일 가능)
  Step 2: 구조물 유형 선택 (수문/건물/옹벽/교량/터널/일반)
  Step 3: 자동 파싱 결과 확인 & 파라미터 편집
  Step 4: 3D 미리보기 (인터랙티브 또는 캡처)
  Step 5: 저장 / AI 렌더링

실행:
  python structure_ui.py
"""

from __future__ import annotations

import os
import sys
import math
import json
import threading
from pathlib import Path
from tkinter import filedialog, messagebox

import customtkinter as ctk
import numpy as np
import pyvista as pv
from PIL import Image

from structure_templates import (
    REGISTRY,
    StructureTemplate,
    StructureParams,
    ParamField,
)

try:
    from filename_classifier import suggest_with_confidence
    FILENAME_CLASSIFIER_AVAILABLE = True
except ImportError:
    FILENAME_CLASSIFIER_AVAILABLE = False


# UI 테마 설정
ctk.set_appearance_mode("dark")
ctk.set_default_color_theme("blue")


class StructureUIApp(ctk.CTk):
    """구조물 조감도 생성 UI 메인 앱."""

    def __init__(self):
        super().__init__()

        self.title("EG-VIEW Structure: 구조물 조감도 생성기")
        self.geometry("1240x820")

        # 상태 변수
        self.dxf_paths: list[str] = []
        self.current_template: StructureTemplate | None = None
        self.current_params: StructureParams | None = None
        self.current_meshes: list = []
        self.param_entries: dict[str, ctk.CTkEntry] = {}
        self.current_capture: Image.Image | None = None
        self.ai_rendered: Image.Image | None = None

        # 실시간 렌더링용 지속 plotter
        self._live_plotter: pv.Plotter | None = None
        self._live_size: int = 600  # 실시간 미리보기 해상도 (빠른 응답을 위해 작게)
        self._render_after_id = None  # 디바운싱용 after() ID
        self._render_in_progress = False

        # 렌더 설정
        self.elevation_var = ctk.DoubleVar(value=35.0)
        self.azimuth_var = ctk.DoubleVar(value=225.0)
        self.zoom_var = ctk.DoubleVar(value=1.0)
        self.time_var = ctk.StringVar(value="낮 (Daytime)")
        self.api_key_var = ctk.StringVar(
            value=os.environ.get("GCP_PROJECT_ID", "")
                  or os.environ.get("GEMINI_API_KEY", "")
        )
        self.use_vertex_var = ctk.BooleanVar(
            value=bool(os.environ.get("GCP_PROJECT_ID", ""))
        )
        self.vertex_location_var = ctk.StringVar(
            value=os.environ.get("GCP_LOCATION", "us-central1")
        )

        self._build_layout()

        # 앱 종료 시 plotter 정리
        self.protocol("WM_DELETE_WINDOW", self._on_close)

    # ----------------------------------------------------------------------
    # 레이아웃
    # ----------------------------------------------------------------------

    def _build_layout(self):
        self.grid_columnconfigure(0, weight=0, minsize=420)
        self.grid_columnconfigure(1, weight=1)
        self.grid_rowconfigure(0, weight=1)

        # --- 왼쪽 사이드바 ---
        sidebar = ctk.CTkScrollableFrame(self, corner_radius=0, width=400)
        sidebar.grid(row=0, column=0, sticky="nsew", padx=0, pady=0)
        self.sidebar = sidebar

        # 로고
        ctk.CTkLabel(
            sidebar, text="EG-VIEW Structure",
            font=ctk.CTkFont(size=22, weight="bold"),
        ).pack(padx=20, pady=(20, 0), anchor="w")
        ctk.CTkLabel(
            sidebar, text="토목 구조물 도면 → 3D 조감도",
            font=ctk.CTkFont(size=11, slant="italic"),
            text_color="gray",
        ).pack(padx=20, pady=(0, 15), anchor="w")

        self._build_step1_upload(sidebar)
        self._build_step2_template(sidebar)
        self._build_step3_params(sidebar)
        self._build_step4_render(sidebar)
        self._build_step5_export(sidebar)

        # --- 오른쪽 메인 영역 ---
        main = ctk.CTkFrame(self, corner_radius=10)
        main.grid(row=0, column=1, sticky="nsew", padx=10, pady=10)
        main.grid_columnconfigure(0, weight=1)
        main.grid_rowconfigure(0, weight=3)
        main.grid_rowconfigure(1, weight=1)

        # 이미지 프리뷰
        self.preview_label = ctk.CTkLabel(
            main, text="3D 미리보기는 구조물 빌드 후 표시됩니다",
            font=ctk.CTkFont(size=14),
            fg_color="#1e1e1e", corner_radius=8,
        )
        self.preview_label.grid(row=0, column=0, sticky="nsew", padx=10, pady=(10, 5))

        # 로그
        self.log_box = ctk.CTkTextbox(
            main, height=180, font=ctk.CTkFont(family="Consolas", size=11),
        )
        self.log_box.grid(row=1, column=0, sticky="nsew", padx=10, pady=(5, 10))

        self.log("EG-VIEW Structure 앱 시작. 구조물 DXF 파일을 업로드하세요.")

    # ----------------------------------------------------------------------
    # Step 1: 파일 업로드
    # ----------------------------------------------------------------------

    def _build_step1_upload(self, parent):
        frame = self._section_frame(parent, "Step 1: DXF 파일 업로드")

        self.file_listbox = ctk.CTkTextbox(
            frame, height=90, font=ctk.CTkFont(family="Consolas", size=11),
        )
        self.file_listbox.pack(fill="x", padx=10, pady=(5, 5))
        self.file_listbox.insert("1.0", "(파일 없음)")

        btn_row = ctk.CTkFrame(frame, fg_color="transparent")
        btn_row.pack(fill="x", padx=10, pady=(0, 10))

        ctk.CTkButton(
            btn_row, text="파일 추가", width=100,
            command=self._on_add_files,
        ).pack(side="left", padx=2)
        ctk.CTkButton(
            btn_row, text="목록 초기화", width=100, fg_color="transparent",
            border_width=1, command=self._on_clear_files,
        ).pack(side="left", padx=2)

    def _on_add_files(self):
        paths = filedialog.askopenfilenames(
            title="구조물 DXF 파일 선택",
            filetypes=[("AutoCAD DXF", "*.dxf"), ("All Files", "*.*")],
        )
        if not paths:
            return
        for p in paths:
            if p not in self.dxf_paths:
                self.dxf_paths.append(p)
        self._refresh_file_list()

        # 파일명으로 구조물 유형 자동 추정
        if FILENAME_CLASSIFIER_AVAILABLE and self.dxf_paths:
            self._auto_suggest_template()

    def _auto_suggest_template(self):
        """업로드된 파일명들에서 구조물 유형 추정 → 드롭다운 자동 선택."""
        # 각 파일마다 추정하고 최다 득표 수집
        votes = {}  # {template_id: total_confidence}
        for path in self.dxf_paths:
            tid, conf = suggest_with_confidence(path)
            if tid:
                votes[tid] = votes.get(tid, 0.0) + conf

        if not votes:
            self.log("  파일명에서 구조물 유형을 추정하지 못함. 수동 선택하세요.")
            return

        # 최고 득표
        best_tid = max(votes.items(), key=lambda x: x[1])[0]
        best_score = votes[best_tid]

        # 현재 선택이 다르면 변경
        tpl = REGISTRY.get(best_tid)
        if tpl:
            current_name = self.template_var.get()
            target_name = tpl.name_ko
            if current_name != target_name:
                self.template_var.set(target_name)
                self._on_template_changed(target_name)
                self.log(f"  🔍 파일명 분석 → [{tpl.name_ko}] 자동 선택 (신뢰도 {best_score:.0%})")
            else:
                self.log(f"  🔍 파일명 확인 → [{tpl.name_ko}] 유형 일치 (신뢰도 {best_score:.0%})")

    def _on_clear_files(self):
        self.dxf_paths = []
        self._refresh_file_list()

    def _refresh_file_list(self):
        self.file_listbox.delete("1.0", "end")
        if not self.dxf_paths:
            self.file_listbox.insert("1.0", "(파일 없음)")
        else:
            for i, p in enumerate(self.dxf_paths, 1):
                self.file_listbox.insert("end", f"{i}. {os.path.basename(p)}\n")

    # ----------------------------------------------------------------------
    # Step 2: 템플릿 선택
    # ----------------------------------------------------------------------

    def _build_step2_template(self, parent):
        frame = self._section_frame(parent, "Step 2: 구조물 유형 선택")

        choices = REGISTRY.list_choices()
        self.template_var = ctk.StringVar(value=choices[0][1])

        # 이름→ID 매핑
        self._template_name_to_id = {name: tid for tid, name in choices}

        self.template_menu = ctk.CTkOptionMenu(
            frame, variable=self.template_var,
            values=[name for _, name in choices],
            command=self._on_template_changed,
            width=360,
        )
        self.template_menu.pack(fill="x", padx=10, pady=(5, 5))

        self.template_desc = ctk.CTkLabel(
            frame, text="", font=ctk.CTkFont(size=11),
            text_color="gray", wraplength=360, justify="left",
        )
        self.template_desc.pack(fill="x", padx=10, pady=(0, 5))

        ctk.CTkButton(
            frame, text="자동 파싱 실행", height=32,
            command=self._on_parse,
        ).pack(fill="x", padx=10, pady=(5, 10))

        # 초기 템플릿 설명
        self._on_template_changed(choices[0][1])

    def _on_template_changed(self, name: str):
        tid = self._template_name_to_id.get(name)
        tpl = REGISTRY.get(tid)
        if tpl:
            self.current_template = tpl
            self.template_desc.configure(
                text=f"{tpl.description}  (파일 {tpl.required_files[0]}~{tpl.required_files[1]}개)"
            )

    def _on_parse(self):
        if not self.dxf_paths:
            messagebox.showwarning("주의", "먼저 DXF 파일을 업로드하세요.")
            return
        if not self.current_template:
            messagebox.showwarning("주의", "구조물 유형을 선택하세요.")
            return

        tpl = self.current_template
        self.log(f">>> 파싱 시작 [{tpl.name_ko}]...")

        try:
            params = tpl.parse(self.dxf_paths)
            self.current_params = params

            # 검출된 뷰 로그
            views = params.get("_views") or []
            if views:
                self.log(f"  📐 뷰 {len(views)}개 검출:")
                for v in views:
                    frame = "프레임" if v.has_frame else "추정"
                    scale = f" {v.scale_hint}" if v.scale_hint else ""
                    label_short = v.label_text[:40]
                    self.log(f"    • [{v.view_type_ko}] \"{label_short}\" "
                             f"({frame}{scale}, {v.width:.1f}×{v.height:.1f}m, "
                             f"{len(v.shapes)}개 요소)")
            else:
                self.log(f"  뷰 라벨 없음 → geometry-based 처리")

            self._refresh_param_fields()
            self.log(f"  파싱 완료: 파라미터 {sum(1 for k in params.params if not k.startswith('_'))}개 (편집 가능)")
            for k, v in params.params.items():
                if k.startswith("_"):
                    continue
                if isinstance(v, (int, float)):
                    self.log(f"    {k} = {v}")
        except Exception as e:
            self.log(f"  파싱 오류: {e}")
            import traceback
            traceback.print_exc()
            messagebox.showerror("오류", f"파싱 중 오류:\n{e}")

    # ----------------------------------------------------------------------
    # Step 3: 파라미터 편집
    # ----------------------------------------------------------------------

    def _build_step3_params(self, parent):
        frame = self._section_frame(parent, "Step 3: 파라미터 확인/편집")

        self.param_frame = ctk.CTkFrame(frame, fg_color="transparent")
        self.param_frame.pack(fill="x", padx=10, pady=(5, 5))

        ctk.CTkLabel(
            self.param_frame, text="(파싱 후 표시됩니다)",
            text_color="gray", font=ctk.CTkFont(size=11),
        ).pack(padx=10, pady=10)

        ctk.CTkButton(
            frame, text="3D 모델 빌드 / 재생성", height=32,
            fg_color="#1f538d", hover_color="#14375e",
            command=self._on_build_3d,
        ).pack(fill="x", padx=10, pady=(5, 10))

    def _refresh_param_fields(self):
        # 기존 위젯 제거
        for w in self.param_frame.winfo_children():
            w.destroy()
        self.param_entries = {}

        if not self.current_template or not self.current_params:
            return

        schema = self.current_template.get_parameter_schema()
        params = self.current_params

        # 2열 그리드
        self.param_frame.grid_columnconfigure(0, weight=0)
        self.param_frame.grid_columnconfigure(1, weight=1)
        self.param_frame.grid_columnconfigure(2, weight=0)
        self.param_frame.grid_columnconfigure(3, weight=1)

        row = 0
        col = 0
        for field in schema:
            base_col = col * 2

            label_text = f"{field.label}"
            if field.unit:
                label_text += f" ({field.unit})"
            ctk.CTkLabel(
                self.param_frame, text=label_text,
                font=ctk.CTkFont(size=11),
                anchor="w",
            ).grid(row=row, column=base_col, padx=(5, 2), pady=3, sticky="w")

            current_val = params.get(field.name, field.default)

            if field.param_type == "choice" and field.choices:
                var = ctk.StringVar(
                    value=field.choices[int(current_val)] if isinstance(current_val, (int, float)) and 0 <= int(current_val) < len(field.choices) else field.choices[0]
                )
                menu = ctk.CTkOptionMenu(
                    self.param_frame, variable=var,
                    values=field.choices, width=140,
                )
                menu.grid(row=row, column=base_col + 1, padx=(2, 10), pady=3, sticky="w")
                self.param_entries[field.name] = var
            else:
                entry = ctk.CTkEntry(self.param_frame, width=120)
                entry.grid(row=row, column=base_col + 1, padx=(2, 10), pady=3, sticky="w")
                entry.insert(0, f"{current_val}")
                self.param_entries[field.name] = entry

            col += 1
            if col >= 2:
                col = 0
                row += 1

    def _collect_params(self) -> dict:
        """UI의 편집 값을 수집."""
        if not self.current_template:
            return {}

        schema = self.current_template.get_parameter_schema()
        collected = {}

        for field in schema:
            widget = self.param_entries.get(field.name)
            if widget is None:
                continue
            if field.param_type == "choice" and field.choices:
                # StringVar → 선택 인덱스
                val_str = widget.get()
                if val_str in field.choices:
                    collected[field.name] = field.choices.index(val_str)
                else:
                    collected[field.name] = int(field.default)
            else:
                try:
                    val_str = widget.get()
                    if field.param_type == "int":
                        collected[field.name] = int(float(val_str))
                    else:
                        collected[field.name] = float(val_str)
                except (ValueError, TypeError):
                    collected[field.name] = field.default

        return collected

    def _on_build_3d(self):
        if not self.current_template or not self.current_params:
            messagebox.showwarning("주의", "먼저 자동 파싱을 실행하세요.")
            return

        # UI 값 수집 → params 갱신
        collected = self._collect_params()
        self.current_params.update(collected)

        self.log(">>> 3D 모델 빌드 중...")
        try:
            meshes = self.current_template.build_meshes(self.current_params)
            self.current_meshes = meshes
            self.log(f"  {len(meshes)}개 메쉬 컴포넌트 생성 → 실시간 프리뷰 활성화")

            # 지속 plotter 셋업 (이후 슬라이더 이동 시 카메라만 갱신)
            self._setup_live_plotter()
            self._live_render()
        except Exception as e:
            self.log(f"  빌드 오류: {e}")
            import traceback
            traceback.print_exc()
            messagebox.showerror("오류", f"3D 빌드 오류:\n{e}")

    # ----------------------------------------------------------------------
    # Step 4: 렌더 컨트롤
    # ----------------------------------------------------------------------

    def _build_step4_render(self, parent):
        frame = self._section_frame(parent, "Step 4: 렌더 설정 (실시간)")

        ctk.CTkLabel(
            frame, text="슬라이더를 움직이면 3D 화면이 실시간으로 갱신됩니다",
            font=ctk.CTkFont(size=10), text_color="gray",
        ).pack(padx=10, pady=(2, 5), anchor="w")

        # 앙각/방위각 슬라이더
        self._build_slider(frame, "앙각", self.elevation_var, 0, 90, "°")
        self._build_slider(frame, "방위각", self.azimuth_var, 0, 360, "°")
        self._build_slider(frame, "줌", self.zoom_var, 0.5, 3.0, "×")

        # 시간대
        row = ctk.CTkFrame(frame, fg_color="transparent")
        row.pack(fill="x", padx=10, pady=(5, 5))
        ctk.CTkLabel(row, text="시간대", width=60).pack(side="left")
        ctk.CTkOptionMenu(
            row, variable=self.time_var,
            values=["낮 (Daytime)", "일몰 (Sunset)", "흐림 (Overcast)"],
            width=180,
        ).pack(side="left", padx=5)

        btn_row = ctk.CTkFrame(frame, fg_color="transparent")
        btn_row.pack(fill="x", padx=10, pady=(5, 10))
        ctk.CTkButton(
            btn_row, text="뷰 초기화", width=150,
            command=self._on_reset_view,
        ).pack(side="left", padx=2)
        ctk.CTkButton(
            btn_row, text="인터랙티브 3D", width=150, fg_color="transparent",
            border_width=1, command=self._on_interactive,
        ).pack(side="left", padx=2)

    def _on_reset_view(self):
        """카메라 슬라이더를 기본값으로 재설정."""
        self.elevation_var.set(35.0)
        self.azimuth_var.set(225.0)
        self.zoom_var.set(1.0)
        self._schedule_live_render(0)

    def _build_slider(self, parent, label, var, mn, mx, unit):
        row = ctk.CTkFrame(parent, fg_color="transparent")
        row.pack(fill="x", padx=10, pady=2)

        ctk.CTkLabel(row, text=label, width=60).pack(side="left")
        slider = ctk.CTkSlider(
            row, from_=mn, to=mx, variable=var, width=200,
        )
        slider.pack(side="left", padx=5)
        value_label = ctk.CTkLabel(row, text=f"{var.get():.1f}{unit}", width=50)
        value_label.pack(side="left")

        def _update(val):
            value_label.configure(text=f"{float(val):.1f}{unit}")
            # 실시간 프리뷰 트리거 (디바운싱)
            self._schedule_live_render()

        slider.configure(command=_update)

    def _capture_and_show(self):
        if not self.current_meshes:
            return

        try:
            # live plotter가 있으면 그걸 사용 (훨씬 빠름)
            if self._live_plotter is not None:
                self._live_render()
                return
            img = self._capture_3d(size=800)
            self.current_capture = img
            self._show_in_preview(img)
            self.log(f"  미리보기 갱신 완료 (800×800)")
        except Exception as e:
            self.log(f"  캡처 오류: {e}")

    # --- 실시간 렌더링 시스템 ---

    def _setup_live_plotter(self):
        """메쉬들을 한번만 로드한 지속 plotter 생성.

        이후 슬라이더 변경 시엔 카메라만 갱신하여 빠르게 재렌더.
        """
        # 기존 plotter 정리
        if self._live_plotter is not None:
            try:
                self._live_plotter.close()
            except Exception:
                pass
            self._live_plotter = None

        if not self.current_meshes:
            return

        plotter = pv.Plotter(off_screen=True, window_size=(self._live_size, self._live_size))
        plotter.set_background("#C8D4E0")

        for mesh, color, opacity in self.current_meshes:
            try:
                plotter.add_mesh(
                    mesh, color=color, opacity=opacity,
                    smooth_shading=True, specular=0.3, specular_power=10,
                )
            except Exception:
                pass

        plotter.enable_3_lights()

        # 초기 카메라 설정 및 렌더
        cam_pos, focal, up = self._compute_camera()
        plotter.camera_position = [cam_pos, focal, up]

        # show(auto_close=False)로 렌더 파이프라인 초기화
        try:
            plotter.show(auto_close=False)
        except Exception:
            pass

        self._live_plotter = plotter

    def _schedule_live_render(self, delay_ms: int = 30):
        """디바운싱: 슬라이더 이동 중 너무 많은 렌더 호출 방지.

        delay_ms 후 실제 렌더. 그 사이 추가 호출이 오면 취소하고 재스케줄.
        """
        if self._render_after_id is not None:
            try:
                self.after_cancel(self._render_after_id)
            except Exception:
                pass
        self._render_after_id = self.after(delay_ms, self._live_render)

    def _live_render(self):
        """카메라만 갱신하여 빠르게 재렌더."""
        self._render_after_id = None

        if self._live_plotter is None or not self.current_meshes:
            return
        if self._render_in_progress:
            # 이미 렌더링 중이면 끝난 후 다시 호출되도록 스케줄
            self._schedule_live_render(50)
            return

        self._render_in_progress = True
        try:
            cam_pos, focal, up = self._compute_camera()
            self._live_plotter.camera_position = [cam_pos, focal, up]

            img_arr = self._live_plotter.screenshot(
                return_img=True,
                window_size=(self._live_size, self._live_size),
            )
            img = Image.fromarray(img_arr)
            self.current_capture = img
            self._show_in_preview(img)
        except Exception as e:
            self.log(f"  실시간 렌더 오류: {e}")
        finally:
            self._render_in_progress = False

    def _on_close(self):
        """앱 종료 시 plotter 정리."""
        if self._live_plotter is not None:
            try:
                self._live_plotter.close()
            except Exception:
                pass
        self.destroy()

    def _capture_3d(self, size=1024) -> Image.Image:
        """현재 설정으로 3D 캡처."""
        if not self.current_meshes:
            return Image.new("RGB", (size, size), (50, 50, 50))

        plotter = pv.Plotter(off_screen=True, window_size=(size, size))
        plotter.set_background("#C8D4E0")

        for mesh, color, opacity in self.current_meshes:
            try:
                plotter.add_mesh(
                    mesh, color=color, opacity=opacity,
                    smooth_shading=True, specular=0.3, specular_power=10,
                )
            except Exception:
                pass

        # 카메라 설정
        cam_pos, focal, up = self._compute_camera()
        plotter.camera_position = [cam_pos, focal, up]
        plotter.enable_3_lights()

        img_arr = plotter.screenshot(return_img=True, window_size=(size, size))
        plotter.close()

        return Image.fromarray(img_arr)

    def _compute_camera(self):
        """모든 메쉬의 경계를 포함하는 카메라 계산."""
        all_pts = []
        for mesh, _, _ in self.current_meshes:
            all_pts.append(np.array(mesh.bounds).reshape(3, 2))

        if not all_pts:
            return (50, 50, 50), (0, 0, 0), (0, 0, 1)

        bounds = np.array(all_pts)
        mins = bounds[:, :, 0].min(axis=0)
        maxs = bounds[:, :, 1].max(axis=0)
        center = (mins + maxs) / 2
        diag = float(np.linalg.norm(maxs - mins))

        dist = diag * self.zoom_var.get()
        el_rad = math.radians(self.elevation_var.get())
        az_rad = math.radians(self.azimuth_var.get())

        dx = dist * math.cos(el_rad) * math.sin(az_rad)
        dy = -dist * math.cos(el_rad) * math.cos(az_rad)
        dz = dist * math.sin(el_rad)

        cam_pos = (float(center[0] + dx), float(center[1] + dy), float(center[2] + dz))
        focal = tuple(float(v) for v in center)
        up = (0, 0, 1)
        return cam_pos, focal, up

    def _on_interactive(self):
        if not self.current_meshes:
            messagebox.showwarning("주의", "먼저 3D 모델을 빌드하세요.")
            return

        self.log("  인터랙티브 3D 뷰어 열기 (q로 종료)...")

        def _run():
            plotter = pv.Plotter(title="EG-VIEW Structure: Interactive 3D")
            plotter.set_background("#2B3A4A")
            for mesh, color, opacity in self.current_meshes:
                try:
                    plotter.add_mesh(
                        mesh, color=color, opacity=opacity,
                        smooth_shading=True,
                    )
                except Exception:
                    pass

            cam_pos, focal, up = self._compute_camera()
            plotter.camera_position = [cam_pos, focal, up]
            plotter.enable_3_lights()
            plotter.show_grid(color="#555")
            plotter.add_axes()
            plotter.show()

        threading.Thread(target=_run, daemon=True).start()

    # ----------------------------------------------------------------------
    # Step 5: 저장 / AI 렌더
    # ----------------------------------------------------------------------

    def _build_step5_export(self, parent):
        frame = self._section_frame(parent, "Step 5: 저장 / AI 렌더")

        # Vertex AI 사용 체크박스
        vx_row = ctk.CTkFrame(frame, fg_color="transparent")
        vx_row.pack(fill="x", padx=10, pady=(5, 0))
        ctk.CTkCheckBox(
            vx_row, text="Vertex AI 사용 (GCP Project)",
            variable=self.use_vertex_var,
            command=self._on_toggle_vertex,
            font=ctk.CTkFont(size=11),
        ).pack(side="left")

        # API Key / GCP Project ID
        self._api_label = ctk.CTkLabel(frame, text="GCP Project ID (Vertex)" if self.use_vertex_var.get() else "Gemini API Key",
                     font=ctk.CTkFont(size=11))
        self._api_label.pack(fill="x", padx=10, pady=(5, 0), anchor="w")
        self._api_entry = ctk.CTkEntry(
            frame, textvariable=self.api_key_var,
            placeholder_text="예: my-project-12345" if self.use_vertex_var.get() else "aistudio.google.com",
            show="" if self.use_vertex_var.get() else "*",
        )
        self._api_entry.pack(fill="x", padx=10, pady=(0, 3))

        # Vertex AI Location
        self._loc_entry = ctk.CTkEntry(
            frame, textvariable=self.vertex_location_var,
            placeholder_text="Vertex AI location (us-central1)",
        )
        self._loc_entry.pack(fill="x", padx=10, pady=(0, 5))
        if not self.use_vertex_var.get():
            self._loc_entry.pack_forget()

        btn_row1 = ctk.CTkFrame(frame, fg_color="transparent")
        btn_row1.pack(fill="x", padx=10, pady=(5, 2))

        ctk.CTkButton(
            btn_row1, text="캡처 PNG 저장", width=180,
            command=self._on_save_capture,
        ).pack(side="left", padx=2)
        ctk.CTkButton(
            btn_row1, text="파라미터 JSON 저장", width=180, fg_color="transparent",
            border_width=1, command=self._on_save_params,
        ).pack(side="left", padx=2)

        btn_row2 = ctk.CTkFrame(frame, fg_color="transparent")
        btn_row2.pack(fill="x", padx=10, pady=(2, 2))

        ctk.CTkButton(
            btn_row2, text="AI 렌더링 실행", width=180,
            fg_color="#27AE60", hover_color="#1E8449",
            command=self._on_ai_render,
        ).pack(side="left", padx=2)
        ctk.CTkButton(
            btn_row2, text="AI 결과 저장", width=180, fg_color="transparent",
            border_width=1, command=self._on_save_ai,
        ).pack(side="left", padx=2)

        btn_row3 = ctk.CTkFrame(frame, fg_color="transparent")
        btn_row3.pack(fill="x", padx=10, pady=(2, 10))

        ctk.CTkButton(
            btn_row3, text="전체 내보내기 (ZIP)", width=360,
            command=self._on_export_all,
        ).pack(side="left", padx=2)

    def _on_save_capture(self):
        if self.current_capture is None:
            messagebox.showwarning("주의", "먼저 미리보기를 생성하세요.")
            return
        path = filedialog.asksaveasfilename(
            defaultextension=".png", filetypes=[("PNG", "*.png")],
            initialfile="structure_capture.png",
        )
        if path:
            # 고해상도로 다시 캡처
            high_res = self._capture_3d(size=1536)
            high_res.save(path)
            self.log(f"  캡처 저장: {path}")

    def _on_save_params(self):
        if not self.current_params:
            messagebox.showwarning("주의", "먼저 파싱을 실행하세요.")
            return

        # 최신 UI 값 반영
        self.current_params.update(self._collect_params())

        path = filedialog.asksaveasfilename(
            defaultextension=".json", filetypes=[("JSON", "*.json")],
            initialfile="structure_params.json",
        )
        if path:
            data = {
                "template_id": self.current_params.template_id,
                "name": self.current_params.name,
                "params": self._json_safe_params(self.current_params.params),
                "source_files": self.current_params.source_files,
            }
            with open(path, "w", encoding="utf-8") as f:
                json.dump(data, f, ensure_ascii=False, indent=2)
            self.log(f"  파라미터 저장: {path}")

    def _json_safe_params(self, params: dict) -> dict:
        """numpy/복잡 객체를 JSON 직렬화 가능 형태로 변환."""
        out = {}
        for k, v in params.items():
            if isinstance(v, (int, float, str, bool)) or v is None:
                out[k] = v
            elif isinstance(v, (list, tuple)):
                try:
                    out[k] = [list(p) if isinstance(p, (tuple, list)) else p for p in v]
                except Exception:
                    out[k] = str(v)
            elif isinstance(v, np.ndarray):
                out[k] = v.tolist()
            else:
                out[k] = str(v)
        return out

    def _on_toggle_vertex(self):
        """Vertex AI 체크박스 토글."""
        use_vertex = self.use_vertex_var.get()
        if use_vertex:
            self._api_label.configure(text="GCP Project ID (Vertex)")
            self._api_entry.configure(
                placeholder_text="예: my-project-12345", show="")
            self._loc_entry.pack(fill="x", padx=10, pady=(0, 5))
        else:
            self._api_label.configure(text="Gemini API Key")
            self._api_entry.configure(
                placeholder_text="aistudio.google.com", show="*")
            self._loc_entry.pack_forget()

    def _on_ai_render(self):
        if self.current_capture is None:
            messagebox.showwarning("주의", "먼저 미리보기를 생성하세요.")
            return
        credential = self.api_key_var.get().strip()
        use_vertex = self.use_vertex_var.get()
        location = self.vertex_location_var.get().strip() or "us-central1"

        if not credential:
            messagebox.showwarning(
                "주의",
                "GCP Project ID를 입력하세요." if use_vertex
                else "Gemini API Key를 입력하세요."
            )
            return

        self.log(f">>> AI 렌더링 실행 ({'Vertex AI' if use_vertex else 'Gemini API'})...")

        def _run():
            try:
                guide_img = self._capture_3d(size=1536)
                prompt = self._build_ai_prompt()
                self.log(f"  프롬프트: {prompt[:80]}...")

                rendered = self._call_gemini(
                    guide_img, prompt, credential,
                    use_vertex=use_vertex, location=location,
                )
                if rendered is None:
                    self.log("  AI 렌더링 실패")
                    return

                self.ai_rendered = rendered
                self._show_in_preview(rendered)
                self.log("  AI 렌더링 완료")
            except Exception as e:
                self.log(f"  AI 오류: {e}")
                import traceback
                traceback.print_exc()

        threading.Thread(target=_run, daemon=True).start()

    def _build_ai_prompt(self) -> str:
        tpl = self.current_template
        params = self.current_params
        time_desc = {
            "낮 (Daytime)": "bright daylight, clear blue sky, sharp shadows",
            "일몰 (Sunset)": "warm sunset light, orange golden glow",
            "흐림 (Overcast)": "soft overcast lighting, muted tones",
        }.get(self.time_var.get(), "bright daylight")

        type_desc = f"civil engineering structure: {tpl.name_ko} ({tpl.description})" if tpl else "civil engineering structure"

        return (
            f"Photorealistic bird's eye aerial view of a {type_desc}, "
            f"{time_desc}, "
            f"maintain exact structural geometry, layout, and proportions from the input image, "
            f"preserve all element positions precisely, "
            f"professional architectural rendering, "
            f"8K ultra sharp detail, high dynamic range, "
            f"realistic concrete texture, steel details, "
            f"natural ground texture, realistic vegetation if any"
        )

    def _call_gemini(self, img: Image.Image, prompt: str, credential: str,
                      use_vertex: bool = False,
                      location: str = "us-central1") -> Image.Image | None:
        """Gemini 이미지 생성 호출.

        Args:
            credential: API Key 또는 GCP Project ID
            use_vertex: True면 Vertex AI 클라이언트 사용
            location: Vertex AI region
        """
        try:
            from google import genai
            from google.genai import types as gtypes
            import io as _io

            if use_vertex:
                client = genai.Client(
                    vertexai=True,
                    project=credential,
                    location=location,
                )
                self.log(f"    Vertex AI 클라이언트 (project={credential}, loc={location})")
            else:
                client = genai.Client(api_key=credential)

            buf = _io.BytesIO()
            img.save(buf, format="PNG")
            img_bytes = buf.getvalue()

            response = client.models.generate_content(
                model="gemini-2.5-flash-image",
                contents=[
                    prompt,
                    gtypes.Part.from_bytes(data=img_bytes, mime_type="image/png"),
                ],
                config=gtypes.GenerateContentConfig(
                    response_modalities=["IMAGE", "TEXT"],
                ),
            )

            for part in response.candidates[0].content.parts:
                if hasattr(part, "inline_data") and part.inline_data:
                    return Image.open(_io.BytesIO(part.inline_data.data))

            return None
        except Exception as e:
            self.log(f"    Gemini 오류: {e}")
            return None

    def _on_save_ai(self):
        if self.ai_rendered is None:
            messagebox.showwarning("주의", "AI 렌더링을 먼저 실행하세요.")
            return
        path = filedialog.asksaveasfilename(
            defaultextension=".png", filetypes=[("PNG", "*.png")],
            initialfile="ai_rendered.png",
        )
        if path:
            self.ai_rendered.save(path)
            self.log(f"  AI 렌더 저장: {path}")

    def _on_export_all(self):
        if not self.current_meshes or self.current_capture is None:
            messagebox.showwarning("주의", "먼저 3D 빌드와 미리보기를 완료하세요.")
            return

        out_dir = filedialog.askdirectory(title="내보내기 폴더 선택")
        if not out_dir:
            return

        out_path = Path(out_dir) / "structure_export"
        out_path.mkdir(exist_ok=True)

        # 캡처 (고해상도)
        capture = self._capture_3d(size=1536)
        capture.save(out_path / "capture_high.png")

        # 파라미터 JSON
        if self.current_params:
            self.current_params.update(self._collect_params())
            data = {
                "template_id": self.current_params.template_id,
                "name": self.current_params.name,
                "params": self._json_safe_params(self.current_params.params),
                "source_files": self.current_params.source_files,
            }
            with open(out_path / "params.json", "w", encoding="utf-8") as f:
                json.dump(data, f, ensure_ascii=False, indent=2)

        # 다각도 캡처
        original_elev = self.elevation_var.get()
        original_azim = self.azimuth_var.get()
        views = [
            ("top_down", 75, 180),
            ("bird_eye_ne", 35, 225),
            ("bird_eye_nw", 35, 135),
            ("elevation", 5, 180),
        ]
        for name, el, az in views:
            self.elevation_var.set(el)
            self.azimuth_var.set(az)
            img = self._capture_3d(size=1024)
            img.save(out_path / f"view_{name}.png")
        self.elevation_var.set(original_elev)
        self.azimuth_var.set(original_azim)

        # AI 결과도
        if self.ai_rendered is not None:
            self.ai_rendered.save(out_path / "ai_rendered.png")

        self.log(f"  전체 내보내기 완료: {out_path}")
        messagebox.showinfo("완료", f"내보내기 완료:\n{out_path}")

    # ----------------------------------------------------------------------
    # 유틸리티
    # ----------------------------------------------------------------------

    def _section_frame(self, parent, title: str) -> ctk.CTkFrame:
        """섹션 프레임 + 타이틀 라벨."""
        container = ctk.CTkFrame(parent, corner_radius=8, fg_color="#2a2a2a")
        container.pack(fill="x", padx=15, pady=6)

        ctk.CTkLabel(
            container, text=title,
            font=ctk.CTkFont(size=13, weight="bold"),
        ).pack(padx=10, pady=(8, 0), anchor="w")

        return container

    def _show_in_preview(self, img: Image.Image):
        """이미지를 프리뷰 영역에 표시."""
        # 프리뷰 영역 크기에 맞춰 리사이즈
        self.update_idletasks()
        max_w = self.preview_label.winfo_width() - 20
        max_h = self.preview_label.winfo_height() - 20
        if max_w <= 1 or max_h <= 1:
            max_w, max_h = 800, 600

        img_ratio = img.width / img.height
        area_ratio = max_w / max_h

        if img_ratio > area_ratio:
            new_w = max_w
            new_h = int(max_w / img_ratio)
        else:
            new_h = max_h
            new_w = int(max_h * img_ratio)

        new_w = max(new_w, 100)
        new_h = max(new_h, 100)
        resized = img.resize((new_w, new_h), Image.LANCZOS)

        ctk_img = ctk.CTkImage(light_image=resized, dark_image=resized, size=(new_w, new_h))
        self.preview_label.configure(image=ctk_img, text="")
        self.preview_label.image = ctk_img  # GC 방지

    def log(self, msg: str):
        import datetime
        ts = datetime.datetime.now().strftime("[%H:%M:%S]")
        self.log_box.insert("end", f"{ts} {msg}\n")
        self.log_box.see("end")
        self.update_idletasks()


# ---------------------------------------------------------------------------
# 메인
# ---------------------------------------------------------------------------

def main():
    app = StructureUIApp()
    app.mainloop()


if __name__ == "__main__":
    main()
