
import os
import re
import time
import random
import math
from pathlib import Path


import numpy as np
import pandas as pd
import geopandas as gpd
from pyproj import CRS

from shapely.geometry.base import BaseGeometry
from shapely.geometry import Polygon, MultiPolygon
from shapely.geometry import LineString, MultiLineString
from shapely.ops import transform, snap, unary_union


from qgis.PyQt import uic, QtWidgets
from qgis.PyQt.QtWidgets import QApplication, QDialog, QMessageBox, QWidget
from qgis.core import QgsVectorLayer


import common
from urbanq.logging.logging_config import logger
from urbanq.function.qss import gradient_style, default_style


from urbanq.function.file import (
    export_gdf,
    keep_columns_gdf,
    load_geojson_gdf,
    load_txt_or_csv_df,
    load_json_df_or_gdf,
    load_layer_or_shp_gdf,
    update_shapefile_layer,
    df_to_empty_geometry_gdf,
)

from urbanq.function.geo import (
    is_real_null,
    is_empty_value,
    normalize_null_values,
)

from urbanq.function.widgetutils import (
    show_progress,
    update_progress,
)


from urbanq.menu.autoUI.fileRread_dockwidget import fileRreadDockWidget
from urbanq.menu.autoUI.fileSave_dockwidget import fileSaveDockWidget
from urbanq.menu.autoUI.fileSetting_dockwidget import fileSettingDockWidget
from urbanq.menu.autoUI.ImageDescription_dockwidget import ImageDescriptionDockWidget



FORM_CLASS, _ = uic.loadUiType(os.path.join(
    os.path.dirname(__file__), 'GeometryEditing_dockwidget_base.ui'))


class GeometryEditingDockWidget(QDialog, FORM_CLASS):  
    def __init__(self, parent=None):
        
        super(GeometryEditingDockWidget, self).__init__(parent)  
        
        
        
        
        
        self.setupUi(self)

        
        show_progress(self.progressBar, False)

        
        self.menuPushButton.setProperty("class", "boldText")
        self.nextStepPushButton.setProperty("class", "boldText")
        self.previousStepPushButton.setProperty("class", "boldText")

        
        self.menuPushButton.clicked.connect(self.go_back_to_data_conversion)

        
        self.nextStepPushButton.clicked.connect(lambda: self.next_previous_clicked(1))
        self.nextStepPushButton.clicked.connect(lambda: self.update_current_progress(self.stackedWidget.currentIndex()))
        self.nextStepPushButton.clicked.connect(lambda: self.load_menu_ui(self.stackedWidget.currentIndex()))

        
        self.previousStepPushButton.clicked.connect(lambda: self.next_previous_clicked(-1))
        self.previousStepPushButton.clicked.connect(lambda: self.update_current_progress(self.stackedWidget.currentIndex()))
        self.previousStepPushButton.clicked.connect(lambda: self.load_menu_ui(self.stackedWidget.currentIndex()))

        
        self.job_index = common.job_info.get("job_index") if common.job_info else None
        self.job_title = common.job_info.get("job_title") if common.job_info else None

        
        self.option = self.get_widget_option(self.job_index, self.job_title)

        
        self.pages_and_files = self.configure_pages_and_files()

        
        self.update_current_progress(0)

        
        self.stackedWidget.setCurrentIndex(0)

        
        self.load_menu_ui(0)

    
    
    

    def configure_pages_and_files(self):
        
        try:
            pages = []

            
            pages.append((True, self.current_step_1, ImageDescriptionDockWidget, None, None))

            
            pages.append((True, self.current_step_2, fileRreadDockWidget, self.option, None))

            
            read_required = any([
                self.option["setting_by_text"],
                self.option["setting_by_array"],
                self.option["setting_by_expression"],
                self.option["setting_by_section"]["enabled"],
                self.option["setting_by_numeric"]["enabled"],
                self.option["setting_by_combo"]["enabled"],
            ])
            pages.append((read_required, self.current_step_3, fileSettingDockWidget, self.option, None))

            
            save_required = any([
                self.option["output_by_file"],
                self.option["output_by_field"],
                self.option["output_by_table"]
            ])
            pages.append((save_required, self.current_step_4, fileSaveDockWidget, self.option, None))

            return pages

        except Exception as e:
            logger.error("에러 발생: %s", e, exc_info=True)

    def go_back_to_data_conversion(self):
        
        try:
            from urbanq.menu.spatialAnalysis.spatialAnalysis_dockwidget import spatialAnalysisDockWidget  
            parent_ui = spatialAnalysisDockWidget(self)  
            main_page_layout = self.parent().parent().findChild(QWidget, "page_spatialAnalysis").layout()
            if main_page_layout:
                
                for i in reversed(range(main_page_layout.count())):
                    main_page_layout.itemAt(i).widget().deleteLater()
                main_page_layout.addWidget(parent_ui)

        except Exception as e:
            logger.error("에러 발생: %s", e, exc_info=True)

    def load_menu_ui(self, index):
        
        try:
            widget_enabled, widget_process, widget_class, widget_option, widget_instance = self.pages_and_files[index]
            page = self.stackedWidget.widget(index)

            
            if widget_instance is None:

                
                widget_instance = widget_class(self, self.option)
                page.layout().addWidget(widget_instance)
                self.pages_and_files[index] = (
                    self.pages_and_files[index][0],
                    self.pages_and_files[index][1],
                    self.pages_and_files[index][2],
                    self.pages_and_files[index][3],
                    widget_instance
                )

        except Exception as e:
            logger.error("에러 발생: %s", e, exc_info=True)

    def update_current_progress(self, index):
        
        try:
            step = 1
            for i, (widget_enabled, widget_process, _, _, _) in enumerate(self.pages_and_files):
                if not widget_enabled:
                    widget_process.hide()
                    continue
                else:
                    updated_text = re.sub(r"\[\d+단계\]", f"[{step}단계]", widget_process.text())
                    widget_process.setText(updated_text)
                    step += 1

                
                widget_process.show()

                if i == index:
                    widget_process.setStyleSheet(gradient_style)
                else:
                    widget_process.setStyleSheet(default_style)

        except Exception as e:
            logger.error("에러 발생: %s", e, exc_info=True)

    def get_safe_page_index(self, current_index: int, direction: int) -> int:
        
        try:
            new_index = current_index

            while True:
                
                new_index += direction

                
                new_index = max(0, min(new_index, len(self.pages_and_files) - 1))

                
                if self.pages_and_files[new_index][0]:
                    return new_index

                
                if new_index == 0 and direction == -1:
                    return current_index

                
                if new_index == len(self.pages_and_files) - 1 and direction == 1:
                    return current_index

        except Exception as e:
            logger.error("에러 발생: %s", e, exc_info=True)

    def next_previous_clicked(self, direction):
        
        def get_last_valid_page_index(pages_and_files) -> int:
            
            for i in reversed(range(len(pages_and_files))):
                if pages_and_files[i][0]:
                    return i
            return -1  

        try:
            
            current_index = self.stackedWidget.currentIndex()

            
            if self.pages_and_files[current_index][0]:
                instance = self.pages_and_files[current_index][4]
                if direction > 0 and not instance.set_fileResults():
                    return

            
            new_index = self.get_safe_page_index(current_index, direction)

            
            last_page_index = get_last_valid_page_index(self.pages_and_files)

            
            self.nextStepPushButton.setText("실행하기 " if new_index == last_page_index else "다음 단계 ▶")

            
            self.stackedWidget.setCurrentIndex(new_index)

            
            if current_index == last_page_index and direction > 0:
                self.run_job_process()

        except Exception as e:
            logger.error("에러 발생: %s", e, exc_info=True)

    
    
    

    def get_file_data_frame(self, source_file_type, source_file_path, file_path, file_encoding, file_delimiter, file_has_header):
        
        try:
            
            gdf = None

            
            if source_file_type == "shp":
                gdf = load_layer_or_shp_gdf(shp_path=file_path, file_encoding=file_encoding)

            
            elif source_file_type == "layer":
                qgs_project_layer = source_file_path
                gdf = load_layer_or_shp_gdf(layer=qgs_project_layer, file_encoding=file_encoding)

            
            elif source_file_type == "json":
                df, _ = load_json_df_or_gdf(file_path=file_path, file_encoding=file_encoding)
                gdf = df_to_empty_geometry_gdf(df)

            
            elif source_file_type == "geojson":
                gdf = load_geojson_gdf(file_path=file_path, file_encoding=file_encoding)

            
            elif source_file_type == "txt":
                df = load_txt_or_csv_df(file_path, file_encoding, file_delimiter, file_has_header)
                gdf = df_to_empty_geometry_gdf(df)

            
            elif source_file_type == "csv":
                df = load_txt_or_csv_df(file_path, file_encoding, file_delimiter, file_has_header)
                gdf = df_to_empty_geometry_gdf(df)

            
            elif source_file_type == "folder":
                df = load_txt_or_csv_df(file_path, file_encoding, file_delimiter, file_has_header)
                gdf = df_to_empty_geometry_gdf(df)

            if gdf is None:
                return

            return gdf

        except Exception as e:
            logger.error("에러 발생: %s", e, exc_info=True)

    def run_job_process(self):
        
        try:
            
            show_progress(self.progressBar)

            
            total_files = len(common.fileInfo_1.file_preview)  
            steps_per_file = 4  
            total_steps = total_files * steps_per_file  
            base_progress = 20  
            step_weight = (100 - base_progress) / total_steps  
            current_step = 0  

            
            source_file_type, source_file_path, _ = common.fileInfo_1.file_record.get_record()
            result_file_type, result_file_path, _ = common.fileInfo_1.result_record.get_record()

            
            status_flags = []  
            for index, file_preview in enumerate(common.fileInfo_1.file_preview):

                
                file_path, file_encoding, file_delimiter, file_has_header = file_preview.get_info()
                current_step += 1
                update_progress(self.progressBar, int(base_progress + current_step * step_weight))

                
                if source_file_type == "folder":
                    
                    file_name_with_ext = os.path.basename(file_path)
                    new_file_path = os.path.join(result_file_path, file_name_with_ext)
                elif result_file_type == "layer":
                    new_file_path = file_path
                else:
                    new_file_path = result_file_path

                
                gdf = self.get_file_data_frame(source_file_type, source_file_path, file_path, file_encoding, file_delimiter, file_has_header)
                current_step += 1
                update_progress(self.progressBar, int(base_progress + current_step * step_weight))

                
                result = self.run_job_by_index(gdf, index)
                current_step += 1
                update_progress(self.progressBar, int(base_progress + current_step * step_weight))

                
                if result is None:
                    status_flags.append(False)
                    break
                elif result is True:
                    
                    
                    status_flags.append(True)

                try:
                    
                    if result_file_type == 'layer':

                        
                        layer_widget = self.pages_and_files[1][4].get_qgs_layer_widget()

                        
                        layer_widget_index = layer_widget.currentIndex()

                        
                        layer = source_file_path

                        
                        new_layer = update_shapefile_layer(layer, result)

                        
                        if 0 <= layer_widget_index < layer_widget.count():
                            layer_widget.setCurrentIndex(layer_widget_index)

                        
                        common.fileInfo_1.file_record.file_path[result_file_type] = new_layer

                        
                        status_flags.append(True)

                    else:
                        
                        if new_file_path:

                            
                            if isinstance(result, gpd.GeoDataFrame):
                                export_success = export_gdf(result, new_file_path)

                                
                                status_flags.append(export_success)

                            elif isinstance(result, list) and result:
                                
                                file_type, _, file_name = common.fileInfo_1.file_record.get_record()
                                base_dir = Path(new_file_path)
                                base_name = Path(file_name).stem
                                ext = f".{file_type}"

                                
                                export_success = []
                                for i, part in enumerate(result, start=1):
                                    output_path = base_dir / f"{base_name}_{i:03d}{ext}"
                                    export_success.append(export_gdf(part, output_path))

                                
                                status_flags.append(all(export_success))

                            else:
                                
                                QMessageBox.information(self, "파일 오류", "파일 저장 중 오류가 발생했습니다.", QMessageBox.Ok)
                                status_flags.append(False)

                except Exception as e:
                    
                    QMessageBox.information(self, "파일 오류", f"GeoDataFrame export 실패: {e}", QMessageBox.Ok)
                    status_flags.append(False)

                
                current_step += 1
                update_progress(self.progressBar, int(base_progress + current_step * step_weight))

            
            if status_flags and all(status_flags):
                update_progress(self.progressBar, 100)  
                QMessageBox.information(self, "알림", "축하합니다. 작업이 완료했습니다!", QMessageBox.Ok)

        except Exception as e:
            logger.error("에러 발생: %s", e, exc_info=True)

        finally:
            show_progress(self.progressBar, False)

    
    
    

    
    def extract_centroid_points(self, gdf):
        
        try:
            
            gdf_copy = gdf.copy()

            
            geometry_col = gdf_copy.geometry.name

            
            gdf_copy = gdf_copy[~gdf_copy[geometry_col].isna()].copy()
            gdf_copy = gdf_copy[~gdf_copy[geometry_col].is_empty].copy()

            if gdf_copy.empty:
                QMessageBox.information(self, "결과", "유효한 지오메트리가 없습니다.", QMessageBox.Ok)
                return None

            
            gdf_copy[geometry_col] = gdf_copy[geometry_col].centroid

            return gdf_copy.copy()

        except Exception as e:
            QMessageBox.information(self, "작업 오류", "중심점 추출 중 오류가 발생하였습니다.", QMessageBox.Ok)
            logger.error("에러 발생: %s", e, exc_info=True)
            return None

    
    def calculate_polygon_area_field(self, gdf, area_field: str, metric_epsg=5179):
        
        try:
            
            if gdf is None or len(gdf) == 0:
                QMessageBox.information(self, "결과", "처리할 레코드가 없습니다.", QMessageBox.Ok)
                return None

            
            if gdf.crs is None or str(gdf.crs).strip() == "":
                QMessageBox.information(
                    self, "좌표계 안내",
                    "이 레이어에는 좌표계(CRS)가 없습니다.\n"
                    "면적(㎡) 계산은 좌표계가 필요하므로\n"
                    "CRS를 먼저 지정한 후 다시 실행해 주세요.",
                    QMessageBox.Ok
                )
                return None

            
            geom_types = gdf.geometry.geom_type.unique()
            if not set(geom_types).issubset({"Polygon", "MultiPolygon"}):
                QMessageBox.information(
                    self, "대상 오류",
                    "면적 계산은 폴리곤 레이어에서만 가능합니다.",
                    QMessageBox.Ok
                )
                return None

            
            gdf_copy = gdf.copy()
            geometry_col = gdf_copy.geometry.name

            
            gdf_copy = gdf_copy[~gdf_copy[geometry_col].isna()].copy()
            gdf_copy = gdf_copy[~gdf_copy[geometry_col].is_empty].copy()

            if gdf_copy.empty:
                QMessageBox.information(self, "결과", "유효한 지오메트리가 없습니다.", QMessageBox.Ok)
                return None

            
            crs = CRS.from_user_input(gdf_copy.crs)

            
            if crs.is_geographic:
                gdf_metric = gdf_copy.to_crs(epsg=metric_epsg)
                areas_m2 = gdf_metric.geometry.area
            else:
                areas_m2 = gdf_copy.geometry.area

            
            gdf_copy[area_field] = areas_m2.astype(float)

            return gdf_copy.copy()

        except Exception as e:
            QMessageBox.information(self, "작업 오류", "면적 계산 중 오류가 발생하였습니다.", QMessageBox.Ok)
            logger.error("에러 발생: %s", e, exc_info=True)
            return None

    
    def create_buffer(self, gdf, buffer_distance, metric_epsg=5179):
        
        try:
            
            if gdf is None or len(gdf) == 0:
                QMessageBox.information(self, "결과", "처리할 레코드가 없습니다.", QMessageBox.Ok)
                return None

            
            if gdf.crs is None or str(gdf.crs).strip() == "":
                QMessageBox.information(
                    self, "좌표계 안내",
                    "이 레이어에는 좌표계(CRS)가 없습니다.\n"
                    "버퍼 생성은 거리(미터) 계산이 필요하므로\n"
                    "CRS를 먼저 지정한 후 다시 실행해 주세요.",
                    QMessageBox.Ok
                )
                return None

            
            geom_types = gdf.geometry.geom_type.unique()
            if not set(geom_types).issubset({"Polygon", "MultiPolygon"}):
                QMessageBox.information(
                    self, "대상 오류",
                    "버퍼 계산은 폴리곤 레이어에서만 가능합니다.",
                    QMessageBox.Ok
                )
                return None

            
            try:
                dist_m = float(buffer_distance)
            except Exception:
                QMessageBox.information(self, "입력 오류", "버퍼 거리 값은 숫자여야 합니다.", QMessageBox.Ok)
                return None

            if dist_m <= 0:
                QMessageBox.information(self, "입력 오류", "버퍼 거리는 0보다 큰 값이어야 합니다.", QMessageBox.Ok)
                return None

            if dist_m >= 500:  
                QMessageBox.information(
                    self, "안내",
                    "버퍼 거리가 매우 큽니다.\n"
                    "데이터가 복잡하거나 객체 수가 많으면 시간이 오래 걸릴 수 있습니다.",
                    QMessageBox.Ok
                )

            
            gdf_copy = gdf.copy()
            geometry_col = gdf_copy.geometry.name

            
            gdf_copy = gdf_copy[~gdf_copy[geometry_col].isna()].copy()
            gdf_copy = gdf_copy[~gdf_copy[geometry_col].is_empty].copy()

            if gdf_copy.empty:
                QMessageBox.information(self, "결과", "유효한 지오메트리가 없습니다.", QMessageBox.Ok)
                return None

            
            crs = CRS.from_user_input(gdf_copy.crs)

            
            if crs.is_geographic:
                gdf_metric = gdf_copy.to_crs(epsg=metric_epsg)
                geom_col_m = gdf_metric.geometry.name

                gdf_metric[geom_col_m] = gdf_metric.geometry.buffer(
                    dist_m, join_style=2, cap_style=3
                )
                return gdf_metric.to_crs(gdf.crs)

            
            gdf_copy[geometry_col] = gdf_copy.geometry.buffer(
                dist_m, join_style=2, cap_style=3
            )

            return gdf_copy.copy()

        except Exception as e:
            QMessageBox.information(self, "작업 오류", "버퍼 생성 중 오류가 발생하였습니다.", QMessageBox.Ok)
            logger.error("에러 발생: %s", e, exc_info=True)
            return None

    
    def simplify_geometry_strong_polygon(
            self,
            gdf,
            percent,  
            min_part_ratio=0.0,
            min_hole_ratio=0.0,
            round_decimals=None,
            metric_epsg=5179,
            preserve_topology=False  
    ):
        try:
            
            if gdf is None or len(gdf) == 0:
                QMessageBox.information(self, "결과", "처리할 레코드가 없습니다.", QMessageBox.Ok)
                return None

            
            if gdf.crs is None or str(gdf.crs).strip() == "":
                QMessageBox.information(
                    self, "좌표계 안내",
                    "이 레이어에는 좌표계(CRS)가 없습니다.\n"
                    "이 기능은 거리/면적 기준 계산이 필요하므로\n"
                    "CRS를 먼저 지정한 후 다시 실행해 주세요.",
                    QMessageBox.Ok
                )
                return None

            
            gdf_m = gdf.to_crs(epsg=metric_epsg).copy()
            geom_col = gdf_m.geometry.name

            
            gdf_m = gdf_m[~gdf_m[geom_col].isna()].copy()
            gdf_m = gdf_m[~gdf_m[geom_col].is_empty].copy()
            if gdf_m.empty:
                return None

            
            minx, miny, maxx, maxy = gdf_m.total_bounds
            width = maxx - minx
            height = maxy - miny
            dataset_scale = max(width, height)
            if dataset_scale <= 0:
                return gdf.copy()

            pct = float(percent)
            if pct <= 0:
                return gdf.copy()

            tol = dataset_scale * (pct / 100.0)

            
            tol = min(tol, dataset_scale * 0.30)

            
            bbox_area = max(width * height, 0.0)
            min_part_area = bbox_area * float(min_part_ratio or 0.0)
            min_hole_area = bbox_area * float(min_hole_ratio or 0.0)

            
            def remove_small_holes(poly: Polygon):
                if min_hole_area <= 0:
                    return poly
                try:
                    new_interiors = []
                    for ring in poly.interiors:
                        if Polygon(ring).area >= min_hole_area:
                            new_interiors.append(ring)
                    return Polygon(poly.exterior, new_interiors)
                except Exception:
                    return poly

            def clean_geom(geom):
                if geom is None or geom.is_empty:
                    return None

                if geom.geom_type == "Polygon":
                    if min_part_area > 0 and geom.area < min_part_area:
                        return None
                    return remove_small_holes(geom)

                if geom.geom_type == "MultiPolygon":
                    parts = []
                    for p in geom.geoms:
                        if p is None or p.is_empty:
                            continue
                        if min_part_area > 0 and p.area < min_part_area:
                            continue
                        parts.append(remove_small_holes(p))
                    return MultiPolygon(parts) if parts else None

                
                return geom

            if min_part_area > 0 or min_hole_area > 0:
                gdf_m[geom_col] = gdf_m[geom_col].apply(clean_geom)
                gdf_m = gdf_m[~gdf_m[geom_col].isna()].copy()
                gdf_m = gdf_m[~gdf_m[geom_col].is_empty].copy()
                if gdf_m.empty:
                    
                    return gdf.copy()

            
            gdf_m[geom_col] = gdf_m[geom_col].simplify(tol, preserve_topology=preserve_topology)

            
            gdf_m = gdf_m[~gdf_m[geom_col].isna()].copy()
            gdf_m = gdf_m[~gdf_m[geom_col].is_empty].copy()
            if gdf_m.empty:
                
                
                return gdf.copy()

            
            def fix_polygon(geom):
                if geom is None or geom.is_empty:
                    return None
                if geom.geom_type in ("Polygon", "MultiPolygon"):
                    try:
                        out = geom.buffer(0)
                        return None if (out is None or out.is_empty) else out
                    except Exception:
                        return geom
                return geom

            gdf_m[geom_col] = gdf_m[geom_col].apply(fix_polygon)

            
            gdf_m = gdf_m[~gdf_m[geom_col].isna()].copy()
            gdf_m = gdf_m[~gdf_m[geom_col].is_empty].copy()
            if gdf_m.empty:
                return gdf.copy()

            
            if round_decimals is not None:
                nd = int(round_decimals)

                def _rounder(x, y, z=None):
                    rx = round(x, nd)
                    ry = round(y, nd)
                    return (rx, ry) if z is None else (rx, ry, round(z, nd))

                def round_geom(geom):
                    if geom is None or geom.is_empty:
                        return None
                    try:
                        return transform(lambda x, y, z=None: _rounder(x, y, z), geom)
                    except Exception:
                        return geom

                gdf_m[geom_col] = gdf_m[geom_col].apply(round_geom)
                gdf_m = gdf_m[~gdf_m[geom_col].isna()].copy()
                gdf_m = gdf_m[~gdf_m[geom_col].is_empty].copy()
                if gdf_m.empty:
                    return gdf.copy()

            
            return gdf_m.to_crs(gdf.crs)

        except Exception as e:
            logger.error(e, exc_info=True)
            QMessageBox.critical(self, "단순화 오류", str(e))
            return None

    
    def simplify_geometry_strong_line(self, gdf, percent, metric_epsg=5179):
        
        try:
            
            if gdf is None or len(gdf) == 0:
                QMessageBox.information(self, "결과", "처리할 레코드가 없습니다.", QMessageBox.Ok)
                return None

            
            if gdf.crs is None or str(gdf.crs).strip() == "":
                QMessageBox.information(
                    self, "좌표계 안내",
                    "이 레이어에는 좌표계(CRS)가 없습니다.\n"
                    "라인 지오메트리 단순화는 허용오차(거리) 기준 계산이 필요하므로\n"
                    "CRS를 먼저 지정한 후 다시 실행해 주세요.",
                    QMessageBox.Ok
                )
                return None

            
            p = max(0.0, min(100.0, float(percent)))
            if p <= 0:
                return gdf.copy()

            
            gdf_m = gdf.to_crs(epsg=metric_epsg).copy()
            geom_col = gdf_m.geometry.name

            gdf_m = gdf_m[~gdf_m[geom_col].isna()].copy()
            gdf_m = gdf_m[~gdf_m[geom_col].is_empty].copy()
            if gdf_m.empty:
                return None

            
            minx, miny, maxx, maxy = gdf_m.total_bounds
            width = maxx - minx
            height = maxy - miny
            dataset_scale = max(width, height)
            if dataset_scale <= 0:
                return gdf.copy()

            
            def vertex_count(geom):
                if geom is None or geom.is_empty:
                    return 0
                if geom.geom_type == "LineString":
                    return len(geom.coords)
                if geom.geom_type == "MultiLineString":
                    return sum(len(ls.coords) for ls in geom.geoms)
                return 0

            before_v = int(gdf_m[geom_col].apply(vertex_count).sum())

            
            
            
            
            def sample_segment_lengths(series, max_geom=200, max_seg=20000):
                segs = []
                cnt = 0
                for geom in series.head(max_geom):
                    if geom is None or geom.is_empty:
                        continue
                    if geom.geom_type == "LineString":
                        coords = list(geom.coords)
                        for i in range(1, len(coords)):
                            x1, y1 = coords[i - 1][0], coords[i - 1][1]
                            x2, y2 = coords[i][0], coords[i][1]
                            segs.append(math.hypot(x2 - x1, y2 - y1))
                            cnt += 1
                            if cnt >= max_seg:
                                return segs
                    elif geom.geom_type == "MultiLineString":
                        for ls in geom.geoms:
                            coords = list(ls.coords)
                            for i in range(1, len(coords)):
                                x1, y1 = coords[i - 1][0], coords[i - 1][1]
                                x2, y2 = coords[i][0], coords[i][1]
                                segs.append(math.hypot(x2 - x1, y2 - y1))
                                cnt += 1
                                if cnt >= max_seg:
                                    return segs
                return segs

            segs = sample_segment_lengths(gdf_m[geom_col])
            if segs:
                base = float(np.percentile(segs, 70))  
                base = max(base, float(np.median(segs)))
            else:
                base = dataset_scale * 0.0001

            
            eps = base * (p / 100.0)

            
            eps = max(eps, base * 0.01)

            
            eps = min(eps, dataset_scale * 0.02)

            
            def point_to_segment_distance(px, py, ax, ay, bx, by):
                
                vx, vy = bx - ax, by - ay
                wx, wy = px - ax, py - ay
                vv = vx * vx + vy * vy
                if vv == 0:
                    return math.hypot(px - ax, py - ay)
                t = (wx * vx + wy * vy) / vv
                if t <= 0:
                    return math.hypot(px - ax, py - ay)
                if t >= 1:
                    return math.hypot(px - bx, py - by)
                projx = ax + t * vx
                projy = ay + t * vy
                return math.hypot(px - projx, py - projy)

            def simplify_collinear_linestring(ls: LineString, eps_m: float):
                coords = list(ls.coords)
                if len(coords) <= 2:
                    return ls

                kept = [coords[0]]
                i = 1
                while i < len(coords) - 1:
                    
                    
                    ax, ay = kept[-1][0], kept[-1][1]
                    cx, cy = coords[i][0], coords[i][1]
                    bx, by = coords[i + 1][0], coords[i + 1][1]

                    d = point_to_segment_distance(cx, cy, ax, ay, bx, by)
                    if d <= eps_m:
                        
                        i += 1
                        continue
                    else:
                        kept.append(coords[i])
                        i += 1

                kept.append(coords[-1])

                
                if len(kept) < 2:
                    return ls
                return LineString(kept)

            def simplify_line_geom(geom):
                if geom is None or geom.is_empty:
                    return None

                if geom.geom_type == "LineString":
                    
                    s1 = simplify_collinear_linestring(geom, eps)
                    
                    
                    tol = min(eps * 1.2, dataset_scale * 0.02)
                    s2 = s1.simplify(tol, preserve_topology=False)
                    return s2 if (s2 is not None and not s2.is_empty) else s1

                if geom.geom_type == "MultiLineString":
                    parts = []
                    for ls in geom.geoms:
                        if ls is None or ls.is_empty:
                            continue
                        s = simplify_line_geom(ls)
                        if s is None or s.is_empty:
                            continue
                        
                        if s.geom_type == "MultiLineString":
                            parts.extend([p for p in s.geoms if p and (not p.is_empty)])
                        else:
                            parts.append(s)
                    return MultiLineString(parts) if parts else None

                return geom  

            gdf_m[geom_col] = gdf_m[geom_col].apply(simplify_line_geom)

            gdf_m = gdf_m[~gdf_m[geom_col].isna()].copy()
            gdf_m = gdf_m[~gdf_m[geom_col].is_empty].copy()
            if gdf_m.empty:
                return gdf.copy()

            after_v = int(gdf_m[geom_col].apply(vertex_count).sum())
            logger.info(
                f"[LINE SIMPLIFY COLLINEAR] percent={p:.0f}, eps={eps:.4f}m, before={before_v}, after={after_v}, reduction={(before_v - after_v) / max(before_v, 1):.1%}")

            
            return gdf_m.to_crs(gdf.crs)

        except Exception as e:
            logger.error(e, exc_info=True)
            QMessageBox.critical(self, "라인 단순화 오류", str(e))
            return None

    
    def snap_geometry_align_percent(
            self,
            gdf,
            percent,  
            metric_epsg=5179,
            reference_gdf=None,  
            iterations=3,  
            area_change_limit=0.02,  
    ):
        
        try:
            
            if gdf is None or len(gdf) == 0:
                QMessageBox.information(self, "결과", "처리할 레코드가 없습니다.", QMessageBox.Ok)
                return None

            
            if gdf.crs is None or str(gdf.crs).strip() == "":
                QMessageBox.information(
                    self, "좌표계 안내",
                    "이 레이어에는 좌표계(CRS)가 없습니다.\n"
                    "지오메트리 스냅 정렬은 허용오차(거리) 기준 계산이 필요하므로\n"
                    "CRS를 먼저 지정한 후 다시 실행해 주세요.",
                    QMessageBox.Ok
                )
                return None

            p = max(1.0, min(100.0, float(percent)))

            
            gdf_m = gdf.to_crs(epsg=metric_epsg).copy()
            geom_col = gdf_m.geometry.name

            gdf_m = gdf_m[~gdf_m[geom_col].isna()].copy()
            gdf_m = gdf_m[~gdf_m[geom_col].is_empty].copy()
            if gdf_m.empty:
                return None

            
            ref_boundary_union = None
            if reference_gdf is not None:
                if reference_gdf.crs is None or str(reference_gdf.crs).strip() == "":
                    QMessageBox.information(
                        self, "좌표계 안내",
                        "참조 레이어(reference_gdf)에 CRS가 없습니다.\n"
                        "참조 레이어 CRS를 먼저 지정해 주세요.",
                        QMessageBox.Ok
                    )
                    return None

                ref_m = reference_gdf.to_crs(epsg=metric_epsg).copy()
                ref_geom_col = ref_m.geometry.name
                ref_m = ref_m[~ref_m[ref_geom_col].isna()].copy()
                ref_m = ref_m[~ref_m[ref_geom_col].is_empty].copy()

                if not ref_m.empty:
                    
                    ref_boundary_union = unary_union(ref_m[ref_geom_col].boundary.values)

            
            
            def collect_segment_lengths_from_boundary(geom, cap=5000):
                segs = []
                if geom is None or geom.is_empty:
                    return segs

                b = geom.boundary
                
                if b.geom_type == "LineString":
                    coords = list(b.coords)
                    for i in range(1, len(coords)):
                        x1, y1 = coords[i - 1]
                        x2, y2 = coords[i]
                        segs.append(math.hypot(x2 - x1, y2 - y1))
                        if len(segs) >= cap:
                            break
                elif b.geom_type == "MultiLineString":
                    for ls in b.geoms:
                        coords = list(ls.coords)
                        for i in range(1, len(coords)):
                            x1, y1 = coords[i - 1]
                            x2, y2 = coords[i]
                            segs.append(math.hypot(x2 - x1, y2 - y1))
                            if len(segs) >= cap:
                                return segs
                return segs

            segs = []
            for geom in gdf_m[geom_col].head(200):  
                segs.extend(collect_segment_lengths_from_boundary(geom, cap=2000))
                if len(segs) >= 12000:
                    break

            if not segs:
                minx, miny, maxx, maxy = gdf_m.total_bounds  
                dataset_scale = max(maxx - minx, maxy - miny)
                tol = max(0.1, dataset_scale * 0.001)
            else:
                base = float(np.median(segs))  
                
                
                tol = base * (p / 100.0)

                
                tol = max(tol, base * 0.10)
                
                tol = min(tol, base * 2.0)

            logger.info(f"[SNAP ALIGN] percent={p:.0f}, tolerance≈{tol:.4f} m, iterations={iterations}")

            
            def make_target_boundary_union(df):
                return unary_union(df[geom_col].boundary.values)

            
            out = gdf_m.copy()
            original_area = None

            
            poly_mask = out[geom_col].apply(
                lambda g: g is not None and (not g.is_empty) and (g.geom_type in ("Polygon", "MultiPolygon")))
            if poly_mask.any():
                original_area = float(out.loc[poly_mask, geom_col].area.sum())

            for it in range(max(1, int(iterations))):
                
                cur_target = make_target_boundary_union(out)
                if ref_boundary_union is not None:
                    
                    cur_target = unary_union([cur_target, ref_boundary_union])

                def _snap_one(geom):
                    if geom is None or geom.is_empty:
                        return None
                    try:
                        snapped = snap(geom, cur_target, tol)
                        if snapped is None or snapped.is_empty:
                            return geom
                        
                        if snapped.geom_type in ("Polygon", "MultiPolygon"):
                            try:
                                fixed = snapped.buffer(0)
                                if fixed is not None and (not fixed.is_empty):
                                    snapped = fixed
                            except Exception:
                                pass
                        return snapped
                    except Exception:
                        return geom

                out[geom_col] = out[geom_col].apply(_snap_one)

                out = out[~out[geom_col].isna()].copy()
                out = out[~out[geom_col].is_empty].copy()
                if out.empty:
                    return gdf.copy()

            
            if original_area is not None and original_area > 0:
                new_area = float(out[geom_col].area.sum())
                change = abs(new_area - original_area) / original_area
                logger.info(f"[SNAP ALIGN] area change = {change:.2%}")
                if change > float(area_change_limit):
                    
                    return gdf.copy()

            
            return out.to_crs(gdf.crs)

        except Exception as e:
            logger.error(e, exc_info=True)
            QMessageBox.critical(self, "스냅 정렬 오류", str(e))
            return None

    
    def remove_polygon_holes(
            self,
            gdf,
            remove_mode="all",  
            hole_area_percent=0.0,  
            hole_area_m2=None,  
            metric_epsg=5179,
            fix_polygon=True  
    ):
        
        try:
            
            if gdf is None or len(gdf) == 0:
                QMessageBox.information(self, "결과", "처리할 레코드가 없습니다.", QMessageBox.Ok)
                return None

            
            if gdf.crs is None or str(gdf.crs).strip() == "":
                QMessageBox.information(
                    self, "좌표계 안내",
                    "이 레이어에는 좌표계(CRS)가 없습니다.\n"
                    "폴리곤 홀(Hole) 제거는 홀 면적(㎡) 기준 계산이 필요하므로\n"
                    "CRS를 먼저 지정한 후 다시 실행해 주세요.",
                    QMessageBox.Ok
                )
                return None

            mode = (remove_mode or "all").strip().lower()
            if mode not in ("all", "small"):
                mode = "all"

            
            gdf_m = gdf.to_crs(epsg=metric_epsg).copy()
            geom_col = gdf_m.geometry.name

            gdf_m = gdf_m[~gdf_m[geom_col].isna()].copy()
            gdf_m = gdf_m[~gdf_m[geom_col].is_empty].copy()
            if gdf_m.empty:
                return None

            
            def _remove_holes_poly(poly: Polygon):
                if poly is None or poly.is_empty:
                    return None

                
                if not poly.interiors:
                    return poly

                
                if mode == "all":
                    out = Polygon(poly.exterior)  
                    return out

                
                poly_area = float(poly.area)
                if poly_area <= 0:
                    
                    return Polygon(poly.exterior)

                
                if hole_area_m2 is not None:
                    thr = float(hole_area_m2)
                else:
                    pct = max(0.0, min(100.0, float(hole_area_percent or 0.0)))
                    thr = poly_area * (pct / 100.0)

                
                kept_interiors = []
                for ring in poly.interiors:
                    try:
                        a = float(Polygon(ring).area)
                    except Exception:
                        a = 0.0

                    if a > thr:
                        kept_interiors.append(ring)

                return Polygon(poly.exterior, kept_interiors)

            def _process_geom(geom):
                if geom is None or geom.is_empty:
                    return None

                gt = geom.geom_type

                if gt == "Polygon":
                    out = _remove_holes_poly(geom)

                elif gt == "MultiPolygon":
                    parts = []
                    for p in geom.geoms:
                        if p is None or p.is_empty:
                            continue
                        rp = _remove_holes_poly(p)
                        if rp is None or rp.is_empty:
                            continue
                        parts.append(rp)
                    out = MultiPolygon(parts) if parts else None

                else:
                    
                    out = geom

                
                if fix_polygon and out is not None and (not out.is_empty) and out.geom_type in (
                "Polygon", "MultiPolygon"):
                    try:
                        fixed = out.buffer(0)
                        if fixed is not None and (not fixed.is_empty):
                            out = fixed
                    except Exception:
                        pass

                return out

            gdf_out = gdf_m.copy()
            gdf_out[geom_col] = gdf_out[geom_col].apply(_process_geom)

            
            gdf_out = gdf_out[~gdf_out[geom_col].isna()].copy()
            gdf_out = gdf_out[~gdf_out[geom_col].is_empty].copy()
            if gdf_out.empty:
                
                return gdf.copy()

            
            return gdf_out.to_crs(gdf.crs)

        except Exception as e:
            logger.error(e, exc_info=True)
            QMessageBox.critical(self, "홀 제거 오류", str(e))
            return None

    
    
    

    @staticmethod
    def get_widget_option(job_index, job_title):
        
        try:
            option = None  
            job_title = job_title[2:]

            if job_index == 0:
                option = {
                    "apply_basic_qss": True,

                    "disable_file_type_layer": True,
                    "disable_file_type_shp": True,
                    "disable_file_type_json": True,
                    "disable_file_type_txtcsv": True,
                    "disable_file_type_fold": True,

                    "show_uid_in_file": False,
                    "show_tuid_in_file": False,
                    "show_field_in_file": False,

                    "setting_by_text": False,
                    "setting_by_array": False,
                    "setting_by_expression": False,
                    "setting_by_section": {"enabled": False, "value_type": "int"},
                    "setting_by_numeric": {"enabled": False, "value_type": "int"},
                    "setting_by_combo": {"enabled": False, "items": []},

                    "output_by_file": True,
                    "output_by_field": False,
                    "output_by_table": False,
                }
            if job_index == 1:
                option = {
                    "apply_basic_qss": True,

                    "disable_file_type_layer": True,
                    "disable_file_type_shp": True,
                    "disable_file_type_json": True,
                    "disable_file_type_txtcsv": True,
                    "disable_file_type_fold": True,

                    "show_uid_in_file": False,
                    "show_tuid_in_file": False,
                    "show_field_in_file": False,

                    "setting_by_text": False,
                    "setting_by_array": False,
                    "setting_by_expression": False,
                    "setting_by_section": {"enabled": False, "value_type": "int"},
                    "setting_by_numeric": {"enabled": False, "value_type": "int"},
                    "setting_by_combo": {"enabled": False, "items": []},

                    "output_by_file": True,
                    "output_by_field": True,
                    "output_by_table": False,

                    "RESULT_FIELD": [
                        '면적 결과 필드 생성',
                        '필드 선택: ',
                        ''
                    ],
                }
            if job_index == 2:
                option = {
                    "apply_basic_qss": True,

                    "disable_file_type_layer": True,
                    "disable_file_type_shp": True,
                    "disable_file_type_json": True,
                    "disable_file_type_txtcsv": True,
                    "disable_file_type_fold": True,

                    "show_uid_in_file": False,
                    "show_tuid_in_file": False,
                    "show_field_in_file": False,

                    "setting_by_text": False,
                    "setting_by_array": False,
                    "setting_by_expression": False,
                    "setting_by_section": {"enabled": False, "value_type": "int"},
                    "setting_by_numeric": {"enabled": True, "value_type": "float"},
                    "setting_by_combo": {"enabled": False, "items": []},

                    "output_by_file": True,
                    "output_by_field": False,
                    "output_by_table": False,

                    "SETTING_NUMERIC": [
                        '버퍼 반경 설정 (m)',
                        '버퍼 거리 입력: ',
                        '입력한 거리만큼 객체 주변에 버퍼 영역을 생성합니다. (단위: m)'
                    ],
                }
            if job_index == 3:
                option = {
                    "apply_basic_qss": True,

                    "disable_file_type_layer": True,
                    "disable_file_type_shp": True,
                    "disable_file_type_json": True,
                    "disable_file_type_txtcsv": True,
                    "disable_file_type_fold": True,

                    "show_uid_in_file": False,
                    "show_tuid_in_file": False,
                    "show_field_in_file": False,

                    "setting_by_text": False,
                    "setting_by_array": False,
                    "setting_by_expression": False,
                    "setting_by_section": {"enabled": False, "value_type": "int"},
                    "setting_by_numeric": {"enabled": True, "value_type": "float"},
                    "setting_by_combo": {"enabled": False, "items": []},

                    "output_by_file": True,
                    "output_by_field": False,
                    "output_by_table": False,

                    "SETTING_NUMERIC": [
                        '지오메트리 단순화 강도 설정',
                        '허용오차 입력 (0.01 ~ 2.00): ',
                        '허용오차는 0보다 큰 값이어야 하며, 권장 범위는 0.01 ~ 2.00입니다.'
                    ],
                }
            if job_index == 4:
                option = {
                    "apply_basic_qss": True,

                    "disable_file_type_layer": True,
                    "disable_file_type_shp": True,
                    "disable_file_type_json": True,
                    "disable_file_type_txtcsv": True,
                    "disable_file_type_fold": True,

                    "show_uid_in_file": False,
                    "show_tuid_in_file": False,
                    "show_field_in_file": False,

                    "setting_by_text": False,
                    "setting_by_array": False,
                    "setting_by_expression": False,
                    "setting_by_section": {"enabled": False, "value_type": "int"},
                    "setting_by_numeric": {"enabled": True, "value_type": "float"},
                    "setting_by_combo": {"enabled": False, "items": []},

                    "output_by_file": True,
                    "output_by_field": False,
                    "output_by_table": False,

                    "SETTING_NUMERIC": [
                        '지오메트리 단순화 강도 설정',
                        '허용오차 입력 (1 ~ 100): ',
                        '허용오차는 0보다 큰 값이어야 하며, 권장 범위는 1 ~ 100입니다.'
                    ],
                }
            if job_index == 5:
                option = {
                    "apply_basic_qss": True,

                    "disable_file_type_layer": True,
                    "disable_file_type_shp": True,
                    "disable_file_type_json": True,
                    "disable_file_type_txtcsv": True,
                    "disable_file_type_fold": True,

                    "show_uid_in_file": False,
                    "show_tuid_in_file": False,
                    "show_field_in_file": False,

                    "setting_by_text": False,
                    "setting_by_array": False,
                    "setting_by_expression": False,
                    "setting_by_section": {"enabled": False, "value_type": "int"},
                    "setting_by_numeric": {"enabled": True, "value_type": "float"},
                    "setting_by_combo": {"enabled": False, "items": []},

                    "output_by_file": True,
                    "output_by_field": False,
                    "output_by_table": False,

                    "SETTING_NUMERIC": [
                        '정렬 강도 설정',
                        '정렬 강도 입력 (1 ~ 100): ',
                        '정렬 강도는 1 ~ 100 사이의 값이어야 하며, 값이 클수록 인접 객체 경계가 더 강하게 자동 정렬됩니다.'
                    ],
                }
            if job_index == 6:
                option = {
                    "apply_basic_qss": True,

                    "disable_file_type_layer": True,
                    "disable_file_type_shp": True,
                    "disable_file_type_json": True,
                    "disable_file_type_txtcsv": True,
                    "disable_file_type_fold": True,

                    "show_uid_in_file": False,
                    "show_tuid_in_file": False,
                    "show_field_in_file": False,

                    "setting_by_text": False,
                    "setting_by_array": False,
                    "setting_by_expression": False,
                    "setting_by_section": {"enabled": False, "value_type": "int"},
                    "setting_by_numeric": {"enabled": True, "value_type": "int"},
                    "setting_by_combo": {"enabled": False, "items": []},

                    "output_by_file": True,
                    "output_by_field": False,
                    "output_by_table": False,

                    "SETTING_NUMERIC": [
                        '홀 제거 강도 설정',
                        '홀 제거 강도 입력 (1 ~ 100): ',
                        '홀 제거 강도는 1 ~ 100 사이의 값이어야 하며, 값이 클수록 폴리곤 내부의 작은 홀(구멍)이 더 많이 자동으로 제거됩니다.'
                    ],
                }
            return option

        except Exception as e:
            logger.error("에러 발생: %s", e, exc_info=True)

    def run_job_by_index(self, gdf, file_preview_index):
        
        try:
            
            file_info = common.fileInfo_1

            
            setting_text = file_info.file_setting.get_text()
            setting_numeric = file_info.file_setting.get_numeric()
            setting_section_min, setting_section_max = file_info.file_setting.get_section()
            setting_combo = file_info.file_setting.get_combo()
            setting_array_string, setting_array_integer, setting_array_float = file_info.file_setting.get_array()

            
            source_file_type, source_file_path, source_file_name = file_info.file_record.get_record()

            
            file_preview = file_info.file_preview[file_preview_index]
            file_fieldes = file_preview.get_header()
            file_field_selection = file_preview.get_selection_field()
            file_tuid = file_preview.get_file_tuid()
            file_is_field_check = file_preview.get_field_check()
            result_field = file_info.result_field

            
            gdf.columns = gdf.columns.astype(str)

            
            result = None
            if self.job_index == 0:
                result = self.extract_centroid_points(gdf)

            elif self.job_index == 1:
                result = self.calculate_polygon_area_field(gdf, result_field)

            elif self.job_index == 2:
                result = self.create_buffer(gdf, setting_numeric)

            elif self.job_index == 3:
                result = self.simplify_geometry_strong_polygon(
                            gdf,
                            percent=setting_numeric,  
                            min_part_ratio=0.0001,  
                            min_hole_ratio=0.0005,  
                            preserve_topology=True  
                        )
            elif self.job_index == 4:
                result = self.simplify_geometry_strong_line(gdf, percent=setting_numeric)

            elif self.job_index == 5:
                result = self.snap_geometry_align_percent(
                            gdf,
                            percent=setting_numeric,   
                            iterations=4,              
                            area_change_limit=0.1      
                        )

            elif self.job_index == 6:
                result = self.remove_polygon_holes(gdf, hole_area_percent=setting_numeric)

            
            if result is None or result is False:
                return None

            return result

        except Exception as e:
            logger.error("에러 발생: %s", e, exc_info=True)
            return None




