"""DXF 도면에서 뷰 영역(평면도/정면도/측면도/단면도) 자동 검출.

작동 원리:
1. DXF의 TEXT/MTEXT에서 뷰 라벨 패턴 매칭 ("평면도", "정면도" 등)
2. 라벨 주변의 사각형 프레임(closed LWPOLYLINE 4점) 검출
3. 라벨-사각형 매칭 (라벨이 사각형 안 또는 바로 위/아래)
4. 각 사각형 안의 지오메트리를 해당 뷰에 할당

사용법:
    from view_detector import detect_view_regions
    views = detect_view_regions("plan.dxf")
    for v in views:
        print(f"{v.view_type}: {v.label_text} ({len(v.shapes)} shapes)")
"""

from __future__ import annotations

import re
import math
from dataclasses import dataclass
from pathlib import Path

import ezdxf

from dxf_geometry import (
    extract_structural_geometry,
    GeometryResult,
    Shape,
)


# ---------------------------------------------------------------------------
# 뷰 타입 라벨 패턴
# ---------------------------------------------------------------------------

VIEW_LABEL_PATTERNS = {
    "plan": [
        r"평\s*면\s*도",
        r"平\s*面\s*[圖図]",
        r"\bPLAN\s*VIEW\b",
        r"^PLAN$",
        r"\bTOP\s*VIEW\b",
    ],
    "front": [
        r"정\s*면\s*도",
        r"正\s*面\s*[圖図]",
        r"\bFRONT\s*(?:VIEW|ELEVATION)\b",
        r"\bELEVATION\b(?!\s*\.)",  # "ELEVATION" but not "EL."
    ],
    "side": [
        r"측\s*면\s*도",
        r"側\s*面\s*[圖図]",
        r"\bSIDE\s*(?:VIEW|ELEVATION)\b",
    ],
    "rear": [
        r"배\s*면\s*도",
        r"背\s*面\s*[圖図]",
        r"\bREAR\s*VIEW\b",
        r"\bBACK\s*VIEW\b",
    ],
    "bottom": [
        r"저\s*면\s*도",
        r"底\s*面\s*[圖図]",
        r"\bBOTTOM\s*VIEW\b",
    ],
    "section": [
        r"단\s*면\s*도",
        r"斷\s*面\s*[圖図]",
        r"\bSECTION\b",
        r"\bSEC\.\s*[A-Z]",
        r"[A-Z]\s*-\s*[A-Z]\s*단면",
    ],
    "detail": [
        r"상\s*세\s*도",
        r"詳\s*細\s*[圖図]",
        r"\bDETAIL\b",
    ],
    "elevation_generic": [
        r"입\s*면\s*도",
        r"立\s*面\s*[圖図]",
    ],
    "longitudinal": [
        r"종\s*단\s*면\s*도",
        r"종\s*단\s*면",
        r"\bLONGITUDINAL\b",
    ],
    "cross_section": [
        r"횡\s*단\s*면\s*도",
        r"횡\s*단\s*면",
        r"\bCROSS\s*SECTION\b",
    ],
}

# 축척(S=1:N) 패턴
SCALE_PATTERN = re.compile(r"S\s*=?\s*1\s*[:：]\s*(\d+)", re.IGNORECASE)


# ---------------------------------------------------------------------------
# 데이터 구조
# ---------------------------------------------------------------------------

@dataclass
class ViewRegion:
    """검출된 뷰 영역 하나."""
    view_type: str            # "plan" | "front" | "side" | ...
    label_text: str            # 원문 라벨
    label_pos: tuple           # (x, y) in m
    bounds: tuple              # (xmin, ymin, xmax, ymax) in m
    shapes: list               # 이 뷰 안의 Shape 목록
    scale_hint: str = ""       # "1:100" 등
    scale_value: int | None = None  # 100 (축척 분모)
    has_frame: bool = True     # 사각형 프레임이 명시적으로 있는지

    @property
    def view_type_ko(self) -> str:
        mapping = {
            "plan": "평면도", "front": "정면도", "side": "측면도",
            "rear": "배면도", "bottom": "저면도", "section": "단면도",
            "detail": "상세도", "elevation_generic": "입면도",
            "longitudinal": "종단면도", "cross_section": "횡단면도",
        }
        return mapping.get(self.view_type, self.view_type)

    @property
    def width(self) -> float:
        return self.bounds[2] - self.bounds[0]

    @property
    def height(self) -> float:
        return self.bounds[3] - self.bounds[1]

    @property
    def center(self) -> tuple:
        return ((self.bounds[0] + self.bounds[2]) / 2,
                (self.bounds[1] + self.bounds[3]) / 2)

    def get_local_shapes(self) -> list:
        """뷰 내 지오메트리를 뷰 좌표(bbox 좌하단을 원점)로 변환한 복사본."""
        ox, oy = self.bounds[0], self.bounds[1]
        out = []
        for s in self.shapes:
            new_pts = [(p[0] - ox, p[1] - oy) for p in s.points]
            new_shape = Shape(
                kind=s.kind, layer=s.layer, points=new_pts,
                closed=s.closed, extra=dict(s.extra),
            )
            out.append(new_shape)
        return out


# ---------------------------------------------------------------------------
# 라벨 검출
# ---------------------------------------------------------------------------

def classify_view_label(text: str) -> str | None:
    """텍스트가 뷰 라벨인지 확인하고 타입 반환."""
    # 매우 긴 텍스트는 라벨이 아닐 가능성 (NOTES 등)
    if len(text) > 30:
        return None

    for view_type, patterns in VIEW_LABEL_PATTERNS.items():
        for pat in patterns:
            if re.search(pat, text, re.IGNORECASE):
                return view_type
    return None


def extract_scale(text: str) -> tuple[str, int | None]:
    """텍스트에서 축척 추출 (e.g., 'S=1:100')."""
    m = SCALE_PATTERN.search(text)
    if m:
        return f"1:{m.group(1)}", int(m.group(1))
    return "", None


def collect_view_labels(dxf_path: str, unit_scale: float) -> list[dict]:
    """DXF의 TEXT/MTEXT 중 뷰 라벨을 모두 수집.

    Returns:
        [{"text": str, "view_type": str, "pos": (x, y),
          "scale_hint": str, "scale_value": int}, ...]
    """
    doc = ezdxf.readfile(dxf_path)
    msp = doc.modelspace()

    labels = []
    # 축척이 라벨 바로 아래에 따로 배치된 경우 대비해서 모든 TEXT 저장
    all_texts = []

    for e in msp:
        et = e.dxftype()
        if et not in ("TEXT", "MTEXT"):
            continue
        try:
            txt = e.dxf.text if et == "TEXT" else (e.text or "")
            txt = txt.strip()
            if not txt:
                continue
            pos = e.dxf.insert
            x = pos.x * unit_scale
            y = pos.y * unit_scale
            all_texts.append({"text": txt, "pos": (x, y)})
        except Exception:
            continue

    for item in all_texts:
        txt = item["text"]
        view_type = classify_view_label(txt)
        if view_type is None:
            continue

        # 축척: 라벨 텍스트 안에서 찾기
        scale_hint, scale_val = extract_scale(txt)

        # 축척이 없으면 근처 텍스트에서 찾기 (30m 이내 아래쪽)
        if not scale_hint:
            lx, ly = item["pos"]
            for other in all_texts:
                ox, oy = other["pos"]
                if abs(ox - lx) < 15.0 and -15.0 < (oy - ly) < -0.5:
                    # 라벨 바로 아래 15m 이내
                    sh, sv = extract_scale(other["text"])
                    if sh:
                        scale_hint, scale_val = sh, sv
                        break

        labels.append({
            "text": txt,
            "view_type": view_type,
            "pos": item["pos"],
            "scale_hint": scale_hint,
            "scale_value": scale_val,
        })

    return labels


# ---------------------------------------------------------------------------
# 사각형 프레임 검출
# ---------------------------------------------------------------------------

def is_rectangle(shape: Shape, tolerance: float = 0.08) -> bool:
    """Shape가 축정렬 사각형인지 확인."""
    if shape.kind != "polyline" or not shape.closed:
        return False

    pts = shape.points
    # 끝점 중복 제거
    if len(pts) >= 2 and abs(pts[0][0] - pts[-1][0]) < 1e-6 and abs(pts[0][1] - pts[-1][1]) < 1e-6:
        pts = pts[:-1]

    if len(pts) != 4:
        return False

    # 각 엣지가 수평 또는 수직에 가까운지
    for i in range(4):
        p1 = pts[i]
        p2 = pts[(i + 1) % 4]
        dx = abs(p2[0] - p1[0])
        dy = abs(p2[1] - p1[1])
        seg_len = max(dx, dy, 1e-6)
        if not (dx / seg_len < tolerance or dy / seg_len < tolerance):
            return False

    return True


def detect_rectangles(geom: GeometryResult,
                      min_area: float = 10.0,
                      max_area_ratio: float = 0.9) -> list[Shape]:
    """GeometryResult에서 축정렬 사각형들을 검출.

    Args:
        min_area: 최소 면적 (m²) — 너무 작은 건 무시
        max_area_ratio: 전체 bbox 대비 최대 면적 비율 — 제목블록/시트경계 제외
    """
    if not geom.shapes:
        return []

    total_area = max(
        (geom.total_bounds[2] - geom.total_bounds[0]) *
        (geom.total_bounds[3] - geom.total_bounds[1]),
        1.0,
    )
    max_area = total_area * max_area_ratio

    rectangles = []
    for s in geom.closed_shapes:
        if not is_rectangle(s):
            continue
        if s.area < min_area or s.area > max_area:
            continue
        rectangles.append(s)

    return rectangles


# ---------------------------------------------------------------------------
# 라벨 ↔ 사각형 매칭
# ---------------------------------------------------------------------------

def _point_in_bbox(pt: tuple, bbox: tuple, margin: float = 0.0) -> bool:
    return (bbox[0] - margin <= pt[0] <= bbox[2] + margin and
            bbox[1] - margin <= pt[1] <= bbox[3] + margin)


def _dist_point_to_bbox(pt: tuple, bbox: tuple) -> float:
    """점과 bbox 사이의 최소 거리."""
    dx = max(bbox[0] - pt[0], 0, pt[0] - bbox[2])
    dy = max(bbox[1] - pt[1], 0, pt[1] - bbox[3])
    return math.sqrt(dx * dx + dy * dy)


def match_label_to_rectangle(label_pos: tuple, rectangles: list[Shape],
                              max_distance: float = 30.0) -> Shape | None:
    """라벨 위치에 가장 적합한 사각형을 찾기.

    우선순위:
    1. 라벨이 사각형 안쪽
    2. 라벨이 사각형 바로 아래 (한국 토목도면 관례: 그림 아래 제목)
    3. 가장 가까운 사각형 (max_distance 이내)
    """
    # 1) 내부
    inside = [r for r in rectangles if _point_in_bbox(label_pos, r.bbox)]
    if inside:
        return min(inside, key=lambda r: r.area)  # 가장 작은 것 (내부 중 중첩된 경우)

    # 2) 바로 아래 (라벨 Y < 사각형 Y_min)
    below_candidates = []
    for r in rectangles:
        b = r.bbox
        dy = b[1] - label_pos[1]  # 사각형 아래쪽 가장자리 - 라벨 Y
        # 라벨이 사각형 아래에 있고, X 범위 안에 있음
        if -2.0 < dy < max_distance and b[0] - 5 < label_pos[0] < b[2] + 5:
            below_candidates.append((dy, r))
    if below_candidates:
        below_candidates.sort(key=lambda x: x[0])
        return below_candidates[0][1]

    # 3) 바로 위 (뒷산 관례 등)
    above_candidates = []
    for r in rectangles:
        b = r.bbox
        dy = label_pos[1] - b[3]  # 라벨 Y - 사각형 위쪽 가장자리
        if -2.0 < dy < max_distance and b[0] - 5 < label_pos[0] < b[2] + 5:
            above_candidates.append((dy, r))
    if above_candidates:
        above_candidates.sort(key=lambda x: x[0])
        return above_candidates[0][1]

    # 4) 가장 가까운 것 (폴백)
    dists = [(_dist_point_to_bbox(label_pos, r.bbox), r) for r in rectangles]
    dists = [(d, r) for d, r in dists if d < max_distance]
    if dists:
        dists.sort(key=lambda x: x[0])
        return dists[0][1]

    return None


# ---------------------------------------------------------------------------
# 라벨만 있는 경우: 라벨 주변 영역 추정 (프레임 없는 경우)
# ---------------------------------------------------------------------------

def estimate_region_without_frame(label_pos: tuple, all_labels: list[dict],
                                   geom_bounds: tuple) -> tuple:
    """프레임 없이 라벨만 있을 때, 라벨 주변의 합리적 영역을 추정.

    다른 라벨들의 위치를 경계로 사용.
    """
    lx, ly = label_pos

    # 현재 라벨과 다른 모든 라벨의 위치
    others = [l["pos"] for l in all_labels
              if abs(l["pos"][0] - lx) > 0.01 or abs(l["pos"][1] - ly) > 0.01]

    # 기본: 전체 bbox 기준 (default_w/h 사용 안 함 — 라벨 간 분할만 적용)
    total_w = geom_bounds[2] - geom_bounds[0]
    total_h = geom_bounds[3] - geom_bounds[1]

    # 이웃 라벨과의 중간 지점까지를 경계로
    x_min = geom_bounds[0]
    x_max = geom_bounds[2]
    y_min = geom_bounds[1]
    y_max = geom_bounds[3]

    for ox, oy in others:
        # 좌우
        if abs(oy - ly) < total_h * 0.3:
            if ox < lx and (lx + ox) / 2 > x_min:
                x_min = (lx + ox) / 2
            elif ox > lx and (lx + ox) / 2 < x_max:
                x_max = (lx + ox) / 2
        # 상하
        if abs(ox - lx) < total_w * 0.3:
            if oy < ly and (ly + oy) / 2 > y_min:
                y_min = (ly + oy) / 2
            elif oy > ly and (ly + oy) / 2 < y_max:
                y_max = (ly + oy) / 2

    # 라벨 자체는 영역 아래쪽이라 가정 (라벨 위가 뷰)
    return (x_min, y_min, x_max, y_max)


# ---------------------------------------------------------------------------
# 메인 검출 함수
# ---------------------------------------------------------------------------

def detect_view_regions(dxf_path: str) -> list[ViewRegion]:
    """DXF에서 뷰 영역들을 검출.

    Returns:
        ViewRegion 목록 (검출 순서 = 뷰 타입 우선순위)
    """
    # 지오메트리 + 단위 추출
    geom = extract_structural_geometry(dxf_path)

    # 라벨 수집
    labels = collect_view_labels(dxf_path, geom.unit_scale)

    if not labels:
        return []

    # 사각형 검출
    rectangles = detect_rectangles(geom)

    # 각 라벨을 사각형에 매칭
    regions = []
    used_rectangles = set()

    for lbl in labels:
        rect = match_label_to_rectangle(lbl["pos"], rectangles)

        if rect is not None and id(rect) not in used_rectangles:
            # 프레임 기반 영역
            used_rectangles.add(id(rect))
            bounds = rect.bbox
            has_frame = True
        else:
            # 프레임 없음 → 주변 추정
            bounds = estimate_region_without_frame(
                lbl["pos"], labels, geom.total_bounds
            )
            has_frame = False

        # 해당 영역 안의 지오메트리 수집 (프레임 자체는 제외)
        inside_shapes = []
        for s in geom.shapes:
            if rect is not None and s is rect:
                continue  # 프레임 자체 제외
            # Shape bbox 중심이 영역 안에 있으면 포함
            cx = (s.bbox[0] + s.bbox[2]) / 2
            cy = (s.bbox[1] + s.bbox[3]) / 2
            if _point_in_bbox((cx, cy), bounds, margin=1.0):
                inside_shapes.append(s)

        regions.append(ViewRegion(
            view_type=lbl["view_type"],
            label_text=lbl["text"],
            label_pos=lbl["pos"],
            bounds=bounds,
            shapes=inside_shapes,
            scale_hint=lbl["scale_hint"],
            scale_value=lbl["scale_value"],
            has_frame=has_frame,
        ))

    return regions


def detect_views_multi(dxf_paths: list[str]) -> list[ViewRegion]:
    """여러 DXF의 뷰를 모두 검출."""
    all_views = []
    for p in dxf_paths:
        try:
            views = detect_view_regions(p)
            for v in views:
                v.label_text = f"[{Path(p).stem[:20]}] {v.label_text}"
            all_views.extend(views)
        except Exception as e:
            print(f"  뷰 검출 실패 ({p}): {e}")
    return all_views


def get_view_by_type(views: list[ViewRegion], view_type: str) -> ViewRegion | None:
    """타입으로 뷰 검색. 같은 타입이 여러 개면 첫 번째."""
    for v in views:
        if v.view_type == view_type:
            return v
    return None


# ---------------------------------------------------------------------------
# 테스트
# ---------------------------------------------------------------------------

if __name__ == "__main__":
    import glob

    samples = sorted(glob.glob("SAMPLE_CAD/*.dxf"))
    for p in samples:
        print(f"\n=== {Path(p).name} ===")
        try:
            views = detect_view_regions(p)
            if not views:
                print("  (뷰 라벨 검출 안 됨)")
                continue
            for v in views:
                frame = "프레임" if v.has_frame else "추정"
                scale = f" S={v.scale_hint}" if v.scale_hint else ""
                print(f"  [{v.view_type_ko}] \"{v.label_text[:40]}\" "
                      f"({frame}{scale}, {len(v.shapes)}개 shape, "
                      f"bbox {v.width:.1f}×{v.height:.1f}m)")
        except Exception as e:
            print(f"  오류: {e}")
