from functools import partial
from typing import TYPE_CHECKING

from qgis._core import QgsFeatureRenderer, QgsVectorDataProvider, QgsVectorLayerEditBuffer
if TYPE_CHECKING:
    from omrat import OMRAT

from qgis.core import (
    QgsVectorLayer, QgsFeature, QgsGeometry, QgsLineString, QgsPoint, QgsProject,
    QgsField, QgsCoordinateReferenceSystem, QgsCoordinateTransform, QgsFields,
    QgsPalLayerSettings, QgsVectorLayerSimpleLabeling, QgsSingleSymbolRenderer,
    QgsLineSymbol, QgsPointXY, QgsWkbTypes,
)
from qgis.gui import QgsRubberBand
from qgis.PyQt.QtCore import QMetaType, Qt
from qgis.PyQt.QtGui import QColor
from qgis.PyQt.QtWidgets import QTableWidgetItem, QPushButton


from omrat_utils import PointTool


def is_valid_point_pair(start: QgsPointXY, end: QgsPointXY) -> bool:
    return not (
        (-1 <= start.x() <= 1 and -1 <= start.y() <= 1) or
        (-1 <= end.x() <= 1 and -1 <= end.y() <= 1)
    )


def calculate_tangent_line(
    mid: QgsPointXY, start: QgsPointXY, end: QgsPointXY, offset: float
) -> tuple[QgsPointXY, QgsPointXY] | None:
    dx = end.x() - start.x()
    dy = end.y() - start.y()
    length = (dx**2 + dy**2)**0.5
    if length < 1e-9:
        return None
    unit_dx = dx / length
    unit_dy = dy / length
    perp_dx = -unit_dy
    perp_dy = unit_dx

    start_tangent = QgsPointXY(mid.x() - perp_dx * offset, mid.y() - perp_dy * offset)
    end_tangent = QgsPointXY(mid.x() + perp_dx * offset, mid.y() + perp_dy * offset)
    return start_tangent, end_tangent


class HandleQGISIface:
    def __init__(self, omrat: "OMRAT"):
        """Initialize the HandleQGISIface class."""
        self.omrat = omrat
        self.tangent_layer: QgsVectorLayer | None = None
        self.vector_layers: list[QgsVectorLayer] = []
        self.current_start_point: QgsPointXY | None = None
        self.segment_id = 0
        self.cur_route_id = 1
        self.item_changed_connected = False
        self.buffer_edits: list[QgsVectorLayerEditBuffer] = []
        self.leg_dirs: dict[str, list[str]] = {}
        self._rubber_band: QgsRubberBand | None = None
        self.omrat.main_widget.twRouteList.cellClicked.connect(self.on_route_table_cell_clicked)
        # Spinboxes for current route / next-leg IDs (declared in the .ui).
        mw = self.omrat.main_widget
        if hasattr(mw, 'sbRouteId'):
            mw.sbRouteId.setValue(self.cur_route_id)
            mw.sbRouteId.valueChanged.connect(self._on_route_id_changed)
        if hasattr(mw, 'sbNextLegId'):
            mw.sbNextLegId.setValue(self.segment_id + 1)
            mw.sbNextLegId.valueChanged.connect(self._on_next_leg_id_changed)

    def add_new_route(self):
        """Starts the editing for a new route."""
        self.current_start_point = None
        self.point_layer = None
        self.mapTool = PointTool(self.omrat.iface.mapCanvas())
        canvas = self.omrat.iface.mapCanvas()
        if canvas is not None:
            canvas.setMapTool(self.mapTool)
        self.mapTool.canvasClicked.connect(self.onMapClick)
        self.mapTool.canvasMoved.connect(self._on_canvas_moved)
        self.omrat.main_widget.pbStopRoute.setEnabled(True)

    def onMapClick(self, point: QgsPoint):
        q_point: QgsPoint = self.point4326_from_wkt(point.asWkt())
        if self.current_start_point is None:
            self.create_point(q_point)
        else:
            self.create_line(q_point)

    # ------------------------------------------------------------------
    # Rubber-band preview helpers
    # ------------------------------------------------------------------

    def _init_rubber_band(self) -> None:
        """Create (or recreate) the dashed rubber-band line on the canvas."""
        self._clear_rubber_band()
        canvas = self.omrat.iface.mapCanvas() if self.omrat.iface else None
        if canvas is None:
            return
        rb = QgsRubberBand(canvas, QgsWkbTypes.GeometryType.LineGeometry)
        rb.setColor(QColor(80, 80, 80, 180))
        rb.setWidth(1)
        rb.setLineStyle(getattr(Qt, 'DashLine', None) or Qt.PenStyle.DashLine)
        self._rubber_band = rb

    def _on_canvas_moved(self, point: QgsPointXY) -> None:
        """Update the rubber-band end-point as the cursor moves."""
        if self._rubber_band is None or self.current_start_point is None:
            return
        canvas = self.omrat.iface.mapCanvas()
        start = self._to_canvas_crs(self.current_start_point, canvas)
        self._rubber_band.reset(QgsWkbTypes.GeometryType.LineGeometry)
        self._rubber_band.addPoint(start, False)
        self._rubber_band.addPoint(point, True)

    def _to_canvas_crs(self, pt_4326: QgsPointXY, canvas) -> QgsPointXY:
        """Transform a point from EPSG:4326 to the canvas CRS."""
        canvas_crs = canvas.mapSettings().destinationCrs()
        src_crs = QgsCoordinateReferenceSystem("EPSG:4326")
        if canvas_crs.authid() == src_crs.authid():
            return pt_4326
        try:
            tr = QgsCoordinateTransform(src_crs, canvas_crs, QgsProject.instance())
            return tr.transform(pt_4326)
        except Exception:  # nosec B110 B112
            return pt_4326

    def _clear_rubber_band(self) -> None:
        """Remove the rubber-band line from the canvas."""
        if self._rubber_band is not None:
            try:
                self._rubber_band.reset(QgsWkbTypes.GeometryType.LineGeometry)
                canvas = self.omrat.iface.mapCanvas() if self.omrat.iface else None
                if canvas is not None:
                    canvas.scene().removeItem(self._rubber_band)
            except Exception:  # nosec B110 B112
                pass
            self._rubber_band = None

    # ------------------------------------------------------------------
    # Spinbox sync helpers
    # ------------------------------------------------------------------

    def sync_drawing_spinboxes(self) -> None:
        """Push current route/leg IDs into the UI spinboxes (blocks signals to avoid loops)."""
        mw = self.omrat.main_widget
        for name, value in (
            ('sbRouteId', self.cur_route_id),
            ('sbNextLegId', self.segment_id + 1),
        ):
            sb = getattr(mw, name, None)
            if sb is None:
                continue
            sb.blockSignals(True)
            try:
                sb.setValue(value)
            finally:
                sb.blockSignals(False)

    def _on_route_id_changed(self, value: int) -> None:
        self.cur_route_id = value

    def _on_next_leg_id_changed(self, value: int) -> None:
        self.segment_id = value - 1

    def create_point(self, point: QgsPoint):
        self.point_layer = QgsVectorLayer("Point?crs=EPSG:4326", "StartPoint", "memory")
        prov: QgsVectorDataProvider | None = self.point_layer.dataProvider()
        QgsProject.instance().addMapLayer(self.point_layer)  # Add the layer to the project

        if not self.point_layer.isEditable():
            self.point_layer.startEditing()  # Start editing

        feat = QgsFeature()
        feat.setGeometry(point)
        if isinstance(prov, QgsVectorDataProvider):
            prov.addFeature(feat)
        self.current_start_point = QgsPointXY(point.x(), point.y())
        # Create the rubber-band preview line for the next leg.
        self._init_rubber_band()

    def create_fields(self) -> QgsFields:
        fields = QgsFields()
        fields.append(QgsField("segmentId", QMetaType.Type.Int))
        fields.append(QgsField("routeId", QMetaType.Type.Int))
        fields.append(QgsField("startPoint", QMetaType.Type.QString))
        fields.append(QgsField("endPoint", QMetaType.Type.QString))
        fields.append(QgsField("label", QMetaType.Type.QString))  # Field for labeling
        return fields

    def create_line(self, point: QgsPoint):
        """Create a line layer, style it, label it, and create offset lines."""
        # Increment segment ID so the layer name carries the leg id from
        # the moment it appears in the QGIS layer panel (rather than
        # only after a save -> reload round-trip).
        self.segment_id += 1
        layer_name = f"LEG_{self.cur_route_id}_{self.segment_id}"
        vl = QgsVectorLayer("LineString?crs=EPSG:4326", layer_name, "memory")
        if not vl.isValid():
            print("Error: Line layer is not valid")
            return

        # Add the layer to the project
        QgsProject.instance().addMapLayer(vl)

        # Start editing the layer
        if not vl.isEditable():
            vl.startEditing()

        # Add fields to the layer
        pr: QgsVectorDataProvider | None = vl.dataProvider()
        fields = self.create_fields()
        if pr is not None:
            pr.addAttributes(fields.toList())
        vl.updateFields()

        # Create the feature
        fet = QgsFeature(fields)
        if isinstance(self.current_start_point, QgsPointXY):
            start_point = self.current_start_point
            end_point = point
            fet.setGeometry(QgsLineString([QgsPoint(start_point.x(), start_point.y()), end_point]))
            fet.setAttributes([
                self.segment_id,
                self.cur_route_id,
                self.current_start_point.asWkt(),
                point.asWkt(),
                f"LEG_{self.cur_route_id}_{self.segment_id}"  # Label value
            ])
            # Style the layer
            self.style_layer(vl)
            fet.setId(self.segment_id)

            # Add the feature to the layer
            if pr is not None:
                pr.addFeature(fet)

            # Label the layer
            self.label_layer(vl)

            # Refresh the layer to ensure changes are applied
            vl.triggerRepaint()

            # Create offset lines
            self.create_offset_lines(start_point, QgsPointXY(end_point.x(), end_point.y()), 2500, self.segment_id)

            # Stop editing and remove the point layer
            if not self.omrat.testing and self.point_layer is not None:
                if self.point_layer.isEditable():
                    self.point_layer.commitChanges()  # Save changes
                QgsProject.instance().removeMapLayer(self.point_layer)

            # Update segment data and save the route
            self.update_segment_data(point)

            # Auto-split both legs if the new one crosses an existing leg.
            if self._auto_split_on_intersection(str(self.segment_id), point, vl):
                return

            self.vector_layers.append(vl)

            # Ensure the layer is in editing mode before connecting the signal
            if not vl.isEditable():
                vl.startEditing()

            edit_buffer: QgsVectorLayerEditBuffer | None = vl.editBuffer()
            if edit_buffer is None:
                print("Error: editBuffer is None")
                return

            edit_buffer.geometryChanged.connect(partial(self.on_geometry_changed_wrapper, self.segment_id))
            self.buffer_edits.append(edit_buffer)
            self.current_start_point = QgsPointXY(point.x(), point.y())
            self.point_layer = None
            self.save_route(QgsPoint(start_point.x(), start_point.y()), end_point)

            vl.setCustomProperty("segment_id", self.segment_id)
            self.sync_drawing_spinboxes()

    def unload(self):
        """Remove temporary layers and disconnect signals."""
        # Remove the point layer
        print('unloading_qgis')
        try:
            if hasattr(self, 'point_layer') and self.point_layer is not None:
                QgsProject.instance().removeMapLayer(self.point_layer)
        except RuntimeError:
            pass
        self.point_layer = None
        # Remove vector layers and disconnect geometryChanged signals
        for obj in self.buffer_edits:
            try:
                obj.geometryChanged.disconnect()
            except BaseException:
                pass
        self.buffer_edits = []
        for layer in self.vector_layers:
            try:
                edit_buffer = layer.editBuffer()
                if edit_buffer is not None:
                    edit_buffer.geometryChanged.disconnect()
                    print(f"Disconnected geometryChanged signal for layer {layer.name()}")
            except TypeError:
                print(f"No connection for geometryChanged signal in layer {layer.name()}")
            QgsProject.instance().removeMapLayer(layer.id())  # Remove the layer from QGIS

        # Disconnect itemChanged signal from twRouteList
        try:
            self.omrat.main_widget.twRouteList.itemChanged.disconnect()
        except TypeError:
            pass

        # Disconnect custom signals
        if hasattr(self, "mapTool"):
            if hasattr(self.mapTool, 'canvasClicked'):
                try:
                    self.mapTool.canvasClicked.disconnect()
                except TypeError:
                    pass
        try:
            self.omrat.main_widget.twRouteList.disconnect()
        except TypeError:
            pass
        # Break circular references
        self._clear_rubber_band()
        self.tangent_layer = None
        self.mapTool = None
        self.vector_layers = []
        self.current_start_point = None
        self.leg_dirs = {}

    def clear(self) -> None:
        """Remove all route/segment layers and reset state.

        Unlike unload(), this keeps the plugin operational by preserving
        permanent signal connections (cellClicked, canvasClicked, etc.).
        """
        # Remove the point layer
        try:
            if hasattr(self, 'point_layer') and self.point_layer is not None:
                QgsProject.instance().removeMapLayer(self.point_layer)
        except RuntimeError:
            pass
        self.point_layer = None

        # Disconnect geometry-changed signals from edit buffers
        for obj in self.buffer_edits:
            try:
                obj.geometryChanged.disconnect()
            except Exception:  # nosec B110 B112
                pass
        self.buffer_edits = []

        # Remove vector layers from QGIS project
        for layer in self.vector_layers:
            try:
                edit_buffer = layer.editBuffer()
                if edit_buffer is not None:
                    try:
                        edit_buffer.geometryChanged.disconnect()
                    except TypeError:
                        pass
                QgsProject.instance().removeMapLayer(layer.id())
            except Exception:  # nosec B110 B112
                pass
        self.vector_layers = []

        # Remove tangent layer
        if self.tangent_layer is not None:
            try:
                QgsProject.instance().removeMapLayer(self.tangent_layer.id())
            except Exception:  # nosec B110 B112
                pass
            self.tangent_layer = None

        # Disconnect itemChanged if connected (will be reconnected on next load)
        if self.item_changed_connected:
            try:
                self.omrat.main_widget.twRouteList.itemChanged.disconnect(self.on_width_changed)
            except TypeError:
                pass
            self.item_changed_connected = False

        # Reset state
        self.current_start_point = None
        self.segment_id = 0
        self.cur_route_id = 1
        self.leg_dirs = {}
        self._clear_rubber_band()
        self.sync_drawing_spinboxes()

    def _find_layer_for_seg_id(self, seg_id: int) -> "QgsVectorLayer | None":
        """Return the vector layer whose first feature has segmentId == seg_id."""
        for layer in self.vector_layers:
            try:
                for feat in layer.getFeatures():
                    if feat["segmentId"] == seg_id:
                        return layer
            except Exception:  # nosec B110 B112
                pass
        return None

    def remove_leg(self) -> None:
        """Remove selected leg(s) from the route table, QGIS layers, and all data dicts."""
        table = self.omrat.main_widget.twRouteList
        selected_rows: set[int] = {item.row() for item in table.selectedItems()}
        if not selected_rows:
            return

        for row in sorted(selected_rows, reverse=True):
            seg_id_item = table.item(row, 0)
            if seg_id_item is None:
                continue
            try:
                seg_id = int(seg_id_item.text())
            except ValueError:
                continue
            seg_key = str(seg_id)

            layer_to_remove = self._find_layer_for_seg_id(seg_id)

            if layer_to_remove is not None:
                edit_buffer = layer_to_remove.editBuffer()
                if edit_buffer is not None:
                    try:
                        edit_buffer.geometryChanged.disconnect()
                    except TypeError:
                        pass
                    if edit_buffer in self.buffer_edits:
                        self.buffer_edits.remove(edit_buffer)
                self.vector_layers.remove(layer_to_remove)
                try:
                    QgsProject.instance().removeMapLayer(layer_to_remove.id())
                except Exception:  # nosec B110 B112
                    pass

            # Remove tangent lines for this segment.
            if self.tangent_layer is not None:
                self.remove_existing_tangent(seg_id)

            # Remove from all data dicts.
            self.omrat.segment_data.pop(seg_key, None)
            self.omrat.traffic_data.pop(seg_key, None)
            self.leg_dirs.pop(seg_key, None)

            table.removeRow(row)

        # Rebuild junction registry to remove references to deleted legs.
        if hasattr(self.omrat, 'junctions') and self.omrat.junctions is not None:
            self.omrat.junctions.rebuild_from_segments(self.omrat.segment_data, prefer_user=True)

        # Refresh traffic UI so the removed leg no longer appears in the selector.
        self.omrat.run_traffic_module()

        canvas = self.omrat.iface.mapCanvas() if self.omrat.iface else None
        if canvas is not None:
            canvas.refresh()

    def _auto_split_on_intersection(
        self,
        new_leg_id: str,
        endpoint: QgsPoint,
        orphan_layer: "QgsVectorLayer | None" = None,
    ) -> bool:
        """Split any existing leg that the newly added leg crosses.

        When a true X-intersection is found the two legs are replaced by
        four sub-legs meeting at the crossing point, the canvas and route
        table are rebuilt, and the junction registry is refreshed.

        ``orphan_layer`` is the transient vector layer created for the new
        leg before we knew a split was needed; it is removed from the QGIS
        project before ``reload_legs_from_segment_data`` recreates everything.

        Returns ``True`` if at least one split was applied so the caller can
        return early (skipping the normal post-draw bookkeeping that
        ``reload_legs_from_segment_data`` already handles).
        """
        from geometries.route_validation import (
            _segments_intersect,
            LegIntersection,
            split_leg_at_points,
            _make_id_provider,
        )
        sd = self.omrat.segment_data
        new_leg = sd.get(new_leg_id)
        if not isinstance(new_leg, dict):
            return False

        new_start = self._parse_wkt_xy(new_leg.get('Start_Point'))
        new_end = self._parse_wkt_xy(new_leg.get('End_Point'))
        if new_start is None or new_end is None:
            return False

        intersections: list[LegIntersection] = []
        for other_id, other_leg in list(sd.items()):
            if other_id == new_leg_id or not isinstance(other_leg, dict):
                continue
            other_start = self._parse_wkt_xy(other_leg.get('Start_Point'))
            other_end = self._parse_wkt_xy(other_leg.get('End_Point'))
            if other_start is None or other_end is None:
                continue
            # Shared endpoint == junction, not a crossing.
            if new_start in (other_start, other_end) or new_end in (other_start, other_end):
                continue
            hit = _segments_intersect(new_start, new_end, other_start, other_end)
            if hit is None:
                continue
            t1, t2, pt = hit
            intersections.append(LegIntersection(
                leg1_id=new_leg_id, leg2_id=other_id, point=pt, t1=t1, t2=t2,
            ))

        if not intersections:
            return False

        td = getattr(self.omrat, 'traffic_data', None)

        # Sort intersections by their position along the new leg so that
        # sub-legs are created in left-to-right (start-to-end) order.
        intersections.sort(key=lambda ix: ix.t1)

        # One shared id-provider so all new IDs are allocated without gaps.
        id_prov = _make_id_provider(sd)

        # Split the new leg at ALL crossing points in one atomic pass,
        # producing clean _a / _b / _c sub-legs instead of cascading _a_b_a.
        split_leg_at_points(
            sd, new_leg_id,
            [ix.point for ix in intersections],
            id_prov, td,
        )

        # Split each crossed leg once at its own intersection point.
        # Use split_leg_at_points so suffix-stripping and collision-avoidance
        # apply (same logic as for the new leg above).
        for ix in intersections:
            split_leg_at_points(sd, ix.leg2_id, [ix.point], id_prov, td)

        # Remove the transient layer that was already added to the QGIS
        # project — reload_legs_from_segment_data recreates all legs from
        # the now-mutated segment_data.
        if orphan_layer is not None:
            try:
                QgsProject.instance().removeMapLayer(orphan_layer.id())
            except Exception:  # nosec B110 B112
                pass

        self.reload_legs_from_segment_data()

        # Set start point for the next interactive leg draw.
        self.current_start_point = QgsPointXY(endpoint.x(), endpoint.y())
        self.point_layer = None

        # Sync the traffic segment combo box from the rebuilt route table.
        traffic = getattr(self.omrat, 'traffic', None)
        if traffic is not None and hasattr(traffic, 'fill_cbTrafficSelectSeg'):
            try:
                traffic.fill_cbTrafficSelectSeg()
            except Exception:  # nosec B110 B112
                pass

        # Rebuild junction registry so new crossing node gets a matrix.
        handler = getattr(self.omrat, 'junctions', None)
        if handler is not None:
            handler.rebuild_from_segments(sd, prefer_user=True)

        return True

    def reload_legs_from_segment_data(self) -> None:
        """Tear down all leg vector layers and rebuild them from ``segment_data``.

        Used by the route-validation pass after merges or splits have
        mutated ``segment_data``: simply rewriting the dict isn't enough
        because (a) the line layers on the canvas still draw the old
        geometry and (b) ``GatherData.get_segment_tbl`` reads endpoints
        back from ``twRouteList`` on save and would silently overwrite
        the merged values.

        Reuses ``OMRAT.load_lines`` to recreate the leg vector layers
        from the in-memory dict; rebuilds the route table rows
        directly so the actual ``Width`` and ``Leg_name`` values are
        preserved (``save_route`` would hardcode ``5000`` and
        ``LEG_{route}_{id}``).
        """
        widget = self.omrat.main_widget
        if widget is None:
            return

        # Disconnect geometry-changed signals on the existing layers.
        for obj in list(self.buffer_edits):
            try:
                obj.geometryChanged.disconnect()
            except Exception:  # nosec B110 B112
                pass
        self.buffer_edits = []
        # Remove the existing leg layers from the QGIS project.
        for layer in list(self.vector_layers):
            try:
                eb = layer.editBuffer()
                if eb is not None:
                    try:
                        eb.geometryChanged.disconnect()
                    except TypeError:
                        pass
                QgsProject.instance().removeMapLayer(layer.id())
            except Exception:  # nosec B110 B112
                pass
        self.vector_layers = []
        # Tear down the tangent layer too -- ``ensure_tangent_layer``
        # rebuilds it on demand inside ``create_offset_lines``.
        if self.tangent_layer is not None:
            try:
                QgsProject.instance().removeMapLayer(self.tangent_layer.id())
            except Exception:  # nosec B110 B112
                pass
            self.tangent_layer = None

        # Disconnect itemChanged so the bulk repopulation below does
        # not spam the width-change handler; reconnect after the
        # rebuild.
        prev_item_changed = self.item_changed_connected
        if prev_item_changed:
            try:
                widget.twRouteList.itemChanged.disconnect(self.on_width_changed)
            except TypeError:
                pass
            self.item_changed_connected = False
        widget.twRouteList.setRowCount(0)

        segment_data = getattr(self.omrat, 'segment_data', {}) or {}

        # Rebuild the leg vector layers via the same path used on file
        # load -- one source of truth for the layer construction.
        self.omrat.load_lines({'segment_data': segment_data})

        # Rebuild table rows directly, preserving width and leg name.
        max_id = 0
        for seg_id, seg in segment_data.items():
            sp = self._parse_wkt_xy(seg.get('Start_Point'))
            ep = self._parse_wkt_xy(seg.get('End_Point'))
            if sp is None or ep is None:
                continue
            try:
                fid = int(str(seg_id))
                if fid > max_id:
                    max_id = fid
            except ValueError:
                continue
            try:
                route_id = int(seg.get('Route_Id', 1))
            except (TypeError, ValueError):
                route_id = 1
            try:
                width = float(seg.get('Width', 5000))
            except (TypeError, ValueError):
                width = 5000.0
            leg_name = str(seg.get('Leg_name', f'LEG_{route_id}_{fid}'))

            row_id = widget.twRouteList.rowCount()
            widget.twRouteList.setRowCount(row_id + 1)
            widget.twRouteList.setItem(row_id, 0, QTableWidgetItem(str(fid)))
            widget.twRouteList.setItem(row_id, 1, QTableWidgetItem(str(route_id)))
            widget.twRouteList.setItem(row_id, 2, QTableWidgetItem(leg_name))
            widget.twRouteList.setItem(
                row_id, 3, QTableWidgetItem(self.format_wkt(QgsPoint(sp[0], sp[1]))),
            )
            widget.twRouteList.setItem(
                row_id, 4, QTableWidgetItem(self.format_wkt(QgsPoint(ep[0], ep[1]))),
            )
            widget.twRouteList.setItem(row_id, 5, QTableWidgetItem(f"{int(width)}"))
            btn = QPushButton("Update AIS")
            btn.clicked.connect(
                lambda _checked=False, k=str(seg_id): self.omrat.ais.update_legs(k)
            )
            widget.twRouteList.setCellWidget(row_id, 6, btn)

            # Refresh the offset / tangent line for this leg.
            try:
                self.create_offset_lines(
                    QgsPointXY(sp[0], sp[1]),
                    QgsPointXY(ep[0], ep[1]),
                    width / 2,
                    fid,
                )
            except Exception:  # nosec B110 B112
                pass

        # Bump the leg-id counter past the highest current id so future
        # interactive draws don't collide.
        self.segment_id = max(max_id, self.segment_id)
        self.sync_drawing_spinboxes()

        # Reconnect the width-changed handler.
        if prev_item_changed and not self.item_changed_connected:
            widget.twRouteList.itemChanged.connect(self.on_width_changed)
            self.item_changed_connected = True

        canvas = self.omrat.iface.mapCanvas() if self.omrat.iface else None
        if canvas is not None:
            canvas.refresh()

    def on_geometry_changed(self, fid: int, geom: QgsGeometry):
        """Handle geometry changes for a feature."""
        # Get the segment ID from the feature's attributes
        polyline: list[QgsGeometry] = geom.asPolyline()
        assert isinstance(polyline, list)  # nosec B101
        start_point_: QgsGeometry = polyline[0]
        end_point_: QgsGeometry = polyline[-1]
        if isinstance(start_point_, QgsPointXY) and isinstance(end_point_, QgsPointXY):
            start_point = QgsPoint(start_point_.x(), start_point_.y())
            end_point = QgsPoint(end_point_.x(), end_point_.y())
            start_pointXY = start_point_
            end_pointXY = end_point_
        elif isinstance(start_point_, QgsPoint) and isinstance(end_point_, QgsPoint):
            start_point = start_point_
            end_point = end_point_
            start_pointXY = QgsPointXY(start_point_.x(), start_point_.y())
            end_pointXY = QgsPointXY(end_point_.x(), end_point_.y())
        else:
            raise TypeError("Unknown data point")

        assert isinstance(start_point, QgsPoint)  # nosec B101
        assert isinstance(end_point, QgsPoint)  # nosec B101
        # Update the start and end points in the table
        assert (self.omrat.main_widget is not None)  # nosec B101

        # Capture the OLD endpoints of this leg before we overwrite them
        # so the shared-vertex propagation (below) can match siblings.
        old_seg = self.omrat.segment_data.get(str(fid)) or {}
        old_start = self._parse_wkt_xy(old_seg.get('Start_Point'))
        old_end = self._parse_wkt_xy(old_seg.get('End_Point'))

        for row in range(self.omrat.main_widget.twRouteList.rowCount()):
            if int(self.omrat.main_widget.twRouteList.item(row, 0).text()) == fid:
                self.omrat.main_widget.twRouteList.item(row, 3).setText(self.format_wkt(start_point))
                self.omrat.main_widget.twRouteList.item(row, 4).setText(self.format_wkt(end_point))

                # Get the width from the table
                width = float(self.omrat.main_widget.twRouteList.item(row, 5).text())

                # Update the tangent line for this segment
                self.create_offset_lines(start_pointXY, end_pointXY, width / 2, fid)

                # Keep backing segment_data in sync so save/export uses edited geometry.
                seg_key = str(fid)
                if seg_key in self.omrat.segment_data:
                    self.omrat.segment_data[seg_key]['Start_Point'] = self.format_wkt(start_point)
                    self.omrat.segment_data[seg_key]['End_Point'] = self.format_wkt(end_point)

                    # Recompute heading-based direction labels and line length in meters.
                    # Use the matching QgsPointXY overload of azimuth — passing a
                    # QgsPointXY to QgsPoint.azimuth raises a type-mismatch error
                    # in QGIS 4 / Qt 6.
                    degrees: float = (start_pointXY.azimuth(end_pointXY) + 360) % 360
                    if degrees > 315 or degrees <= 45:
                        dirs = ['North going', 'South going']
                    elif degrees > 45 and degrees <= 135:
                        dirs = ['East going', 'West going']
                    elif degrees > 135 and degrees <= 225:
                        dirs = ['South going', 'North going']
                    else:
                        dirs = ['West going', 'East going']
                    self.omrat.segment_data[seg_key]['Dirs'] = dirs
                    self.leg_dirs[seg_key] = dirs

                    longitude = (start_pointXY.x() + end_pointXY.x()) / 2
                    utm_zone = int((longitude + 180) / 6) + 1
                    is_northern = start_point.y() >= 0
                    utm_crs = QgsCoordinateReferenceSystem(
                        f"EPSG:{32600 + utm_zone if is_northern else 32700 + utm_zone}"
                    )
                    transform_to_utm = QgsCoordinateTransform(
                        QgsCoordinateReferenceSystem("EPSG:4326"),
                        utm_crs,
                        QgsProject.instance(),
                    )
                    start_utm = transform_to_utm.transform(start_pointXY)
                    end_utm = transform_to_utm.transform(end_pointXY)
                    self.omrat.segment_data[seg_key]['line_length'] = start_utm.distance(end_utm)

                # Propagate the move to any other leg that shared the
                # endpoint that just moved (so curved routes stay
                # connected when the user drags a junction vertex).
                self._propagate_shared_vertex_move(
                    moved_fid=fid,
                    old_start=old_start,
                    old_end=old_end,
                    new_start=(start_pointXY.x(), start_pointXY.y()),
                    new_end=(end_pointXY.x(), end_pointXY.y()),
                )

                # Stop processing once the correct row is updated
                return

    @staticmethod
    def label_layer(layer: QgsVectorLayer) -> None:
        """Label the layer with the 'label' field."""
        settings = QgsPalLayerSettings()
        settings.fieldName = "label"  # Use the 'label' field for labeling
        settings.placement = QgsPalLayerSettings.Placement.Line
        settings.enabled = True

        labeling = QgsVectorLayerSimpleLabeling(settings)
        layer.setLabeling(labeling)
        layer.setLabelsEnabled(True)

        # Trigger a refresh of the layer's labeling
        layer.triggerRepaint()

    @staticmethod
    def style_layer(layer: QgsVectorLayer) -> None:
        """Style the layer with a thicker line."""
        # Get the layer's renderer and symbol
        renderer: QgsFeatureRenderer | None = layer.renderer()
        if (renderer is None):
            renderer = QgsSingleSymbolRenderer(QgsLineSymbol())
            layer.setRenderer(renderer)

        symbol = renderer.symbol()
        assert (isinstance(symbol, QgsLineSymbol))  # nosec B101
        symbol.setWidth(1.5)  # Set line thickness
        symbol.setColor(QColor("blue"))  # Optional: Set line color

        # Trigger a refresh of the layer's symbology
        layer.triggerRepaint()

    def save_route(self, point1: QgsPoint, point2: QgsPoint):
        """Save route information to the twRouteList table."""
        assert (self.omrat.main_widget is not None)  # nosec B101
        row_id = self.omrat.main_widget.twRouteList.rowCount()
        self.omrat.main_widget.twRouteList.setRowCount(row_id + 1)

        # Create table items
        item1 = QTableWidgetItem(f'{self.segment_id}')
        item2 = QTableWidgetItem(f'{self.cur_route_id}')
        item3 = QTableWidgetItem(f'{point1.asWkt(precision=5).split("(")[1].split(")")[0]}')
        item4 = QTableWidgetItem(f'{point2.asWkt(precision=5).split("(")[1].split(")")[0]}')
        item5 = QTableWidgetItem('5000')  # Default width
        item6 = QTableWidgetItem(f'LEG_{self.cur_route_id}_{self.segment_id}')  # Leg name

        # Add items to the table
        self.omrat.main_widget.twRouteList.setItem(row_id, 0, item1)
        self.omrat.main_widget.twRouteList.setItem(row_id, 1, item2)
        self.omrat.main_widget.twRouteList.setItem(row_id, 2, item6)
        self.omrat.main_widget.twRouteList.setItem(row_id, 3, item3)
        self.omrat.main_widget.twRouteList.setItem(row_id, 4, item4)
        self.omrat.main_widget.twRouteList.setItem(row_id, 5, item5)
        btn_update_ais = QPushButton("Update AIS")
        btn_update_ais.clicked.connect(lambda: self.omrat.ais.update_legs(str(self.segment_id)))
        self.omrat.main_widget.twRouteList.setCellWidget(row_id, 6, btn_update_ais)

        # Connect the itemChanged signal to a handler
        if not self.item_changed_connected:
            self.omrat.main_widget.twRouteList.itemChanged.connect(self.on_width_changed)
            self.item_changed_connected = True

    def on_route_table_cell_clicked(self, row: int, column: int):
        """Called when any cell in the route table is clicked."""
        segment_id_item = self.omrat.main_widget.twRouteList.item(row, 0)
        if segment_id_item is not None:
            try:
                segment_id = segment_id_item.text()
                self.omrat.distributions.run_update_plot(segment_id)
            except ValueError:
                pass  # Handle or log invalid segment_id if needed

    def update_segment_data(self, point: QgsPoint) -> None:
        main_widget = self.omrat.main_widget
        assert (self.current_start_point is not None)  # nosec B101
        pointXY = QgsPointXY(point.x(), point.y())
        degrees: float = (self.current_start_point.azimuth(pointXY) + 360) % 360
        if degrees > 315 or degrees <= 45:
            main_widget.laDir1.setText('North going')
            main_widget.laDir2.setText('South going')
            dirs: list[str] = ['North going', 'South going']
        elif degrees > 45 and degrees <= 135:
            main_widget.laDir1.setText('East going')
            main_widget.laDir2.setText('West going')
            dirs: list[str] = ['East going', 'West going']
        elif degrees > 135 and degrees <= 225:
            main_widget.laDir1.setText('South going')
            main_widget.laDir2.setText('North going')
            dirs: list[str] = ['South going', 'North going']
        elif degrees > 225 and degrees <= 315:
            main_widget.laDir1.setText('West going')
            main_widget.laDir2.setText('East going')
            dirs: list[str] = ['West going', 'East going']
        else:
            return
        longitude = (pointXY.x() + self.current_start_point.x()) / 2
        utm_zone = int((longitude + 180) / 6) + 1
        is_northern = self.current_start_point.y() >= 0
        utm_epsg = 32600 + utm_zone if is_northern else 32700 + utm_zone
        utm_crs = QgsCoordinateReferenceSystem(f"EPSG:{utm_epsg}")

        transform_to_utm = QgsCoordinateTransform(
            QgsCoordinateReferenceSystem("EPSG:4326"), utm_crs, QgsProject.instance()
        )
        start_utm = transform_to_utm.transform(self.current_start_point)
        dist = start_utm.distance(transform_to_utm.transform(pointXY))
        seg_key = f'{self.segment_id}'
        start_wkt = QgsPoint(self.current_start_point.x(), self.current_start_point.y()).asWkt()
        if seg_key in self.omrat.segment_data:
            self.omrat.segment_data[seg_key]['Start_Point'] = start_wkt
            self.omrat.segment_data[seg_key]['End_Point'] = point.asWkt()
            self.omrat.segment_data[seg_key]['Dirs'] = dirs
            self.omrat.segment_data[seg_key]['line_length'] = dist
        else:
            self.omrat.segment_data[seg_key] = {
                'Start_Point': start_wkt,
                'End_Point': point.asWkt(),
                'Dirs': dirs, 'Width': 5000, 'line_length': dist,
                'Route_Id': self.cur_route_id,
                'Segment_Id': self.segment_id,
                'Leg_name': f'LEG_{self.cur_route_id}_{self.segment_id}',
            }
            # Initialise an empty traffic block so save() and the UI
            # don't crash with KeyError before AIS data are loaded.
            traffic = getattr(self.omrat, 'traffic', None)
            if traffic is not None and seg_key not in self.omrat.traffic_data:
                traffic.create_empty_dict(seg_key, dirs)
            # Stamp the write-once import baseline for the audit report.
            imported = getattr(self.omrat, 'segments_imported', None)
            if isinstance(imported, dict) and f'{self.segment_id}' not in imported:
                imported[f'{self.segment_id}'] = {
                    'Start_Point': QgsPoint(
                        self.current_start_point.x(),
                        self.current_start_point.y(),
                    ).asWkt(),
                    'End_Point': point.asWkt(),
                }
        self.leg_dirs[f'{self.segment_id}'] = dirs
        leg_name = f'LEG_{self.cur_route_id}_{self.segment_id}'
        main_widget.cbTrafficSelectSeg.addItem(leg_name, f'{self.segment_id}')
        self.omrat.traffic.c_seg = f'{self.segment_id}'
        self.current_start_point = None

    def ensure_tangent_layer(self):
        if self.tangent_layer is None:
            self.tangent_layer = QgsVectorLayer("LineString?crs=EPSG:4326", "Tangent Line", "memory")
            if not self.tangent_layer.isValid():
                raise RuntimeError("Tangent line layer is not valid")
            QgsProject.instance().addMapLayer(self.tangent_layer)
            self.vector_layers.append(self.tangent_layer)
        self.tangent_layer.startEditing()

    def ensure_tangent_fields(self):
        if self.tangent_layer.fields().lookupField("type") < 0:
            pr = self.tangent_layer.dataProvider()
            pr.addAttributes([QgsField("type", QMetaType.Type.QString)])
            self.tangent_layer.updateFields()

    def calculate_midpoint_utm(
        self, start: QgsPointXY, end: QgsPointXY
    ) -> tuple[QgsPointXY, QgsCoordinateTransform, QgsCoordinateTransform]:
        longitude = (start.x() + end.x()) / 2
        utm_zone = int((longitude + 180) / 6) + 1
        is_northern = start.y() >= 0
        utm_epsg = 32600 + utm_zone if is_northern else 32700 + utm_zone
        utm_crs = QgsCoordinateReferenceSystem(f"EPSG:{utm_epsg}")

        transform_to_utm = QgsCoordinateTransform(
            QgsCoordinateReferenceSystem("EPSG:4326"), utm_crs, QgsProject.instance()
        )
        transform_to_canvas = QgsCoordinateTransform(
            utm_crs, QgsCoordinateReferenceSystem("EPSG:4326"), QgsProject.instance()
        )

        start_utm = transform_to_utm.transform(start)
        end_utm = transform_to_utm.transform(end)
        mid_utm = QgsPointXY((start_utm.x() + end_utm.x()) / 2, (start_utm.y() + end_utm.y()) / 2)

        return mid_utm, transform_to_utm, transform_to_canvas

    def remove_existing_tangent(self, segment_id: int):
        ids = [f.id() for f in self.tangent_layer.getFeatures() if f["type"] == f"Tangent Line {segment_id}"]
        if not ids:
            return
        # Use the data provider directly — consistent with add_tangent_feature
        # which also uses dataProvider().addFeatures(), so no edit mode needed.
        self.tangent_layer.dataProvider().deleteFeatures(ids)
        self.tangent_layer.triggerRepaint()

    def add_tangent_feature(self, start: QgsPointXY, end: QgsPointXY, segment_id: int):
        self.remove_existing_tangent(segment_id)
        fet = QgsFeature()
        fet.setGeometry(QgsLineString([QgsPoint(start.x(), start.y()), QgsPoint(end.x(), end.y())]))
        fet.setAttributes([f"Tangent Line {segment_id}"])
        self.tangent_layer.dataProvider().addFeatures([fet])

    def create_offset_lines(
        self, start_point: QgsPointXY, end_point: QgsPointXY, offset_distance: float, segment_id: int
    ):
        if not is_valid_point_pair(start_point, end_point):
            return

        self.ensure_tangent_layer()
        self.ensure_tangent_fields()

        mid_utm, to_utm, to_canvas = self.calculate_midpoint_utm(start_point, end_point)
        start_utm = to_utm.transform(start_point)
        end_utm = to_utm.transform(end_point)

        result = calculate_tangent_line(mid_utm, start_utm, end_utm, offset_distance)
        if result is None:
            return
        tangent_start_utm, tangent_end_utm = result
        tangent_start = to_canvas.transform(tangent_start_utm)
        tangent_end = to_canvas.transform(tangent_end_utm)

        self.add_tangent_feature(tangent_start, tangent_end, segment_id)
        self.tangent_layer.commitChanges()
        self.tangent_layer.triggerRepaint()

    def on_width_changed(self, item: QTableWidgetItem):
        """Handle edits to the route table: width (col 5) and Leg_name (col 2)."""
        column = item.column()
        row = item.row()
        seg_id_item = self.omrat.main_widget.twRouteList.item(row, 0)
        if seg_id_item is None:
            return
        try:
            segment_id = int(seg_id_item.text())
        except ValueError:
            return

        if column == 5:  # Width
            start_point_wkt = self.omrat.main_widget.twRouteList.item(row, 3).text()
            end_point_wkt = self.omrat.main_widget.twRouteList.item(row, 4).text()
            width = float(self.omrat.main_widget.twRouteList.item(row, 5).text())
            seg_key = str(segment_id)
            if seg_key in self.omrat.segment_data:
                self.omrat.segment_data[seg_key]['Width'] = int(width)
            start_point_geom = QgsGeometry.fromWkt(f"Point ({start_point_wkt})")
            end_point_geom = QgsGeometry.fromWkt(f"Point ({end_point_wkt})")
            if not start_point_geom.isEmpty() and not end_point_geom.isEmpty():
                start_point: QgsPointXY = start_point_geom.asPoint()
                end_point: QgsPointXY = end_point_geom.asPoint()
                self.create_offset_lines(start_point, end_point, width / 2, segment_id)

        elif column == 2:  # Leg_name
            new_name = item.text()
            seg_key = str(segment_id)
            if seg_key in self.omrat.segment_data:
                self.omrat.segment_data[seg_key]['Leg_name'] = new_name
            layer = self._find_layer_for_seg_id(segment_id)
            if layer is not None:
                layer.setName(new_name)
                label_idx = layer.fields().lookupField("label")
                if label_idx >= 0:
                    pr = layer.dataProvider()
                    for feat in layer.getFeatures():
                        if feat["segmentId"] == segment_id:
                            pr.changeAttributeValues({feat.id(): {label_idx: new_name}})
                            break
                layer.triggerRepaint()

    def point4326_from_wkt(self, wkt: str) -> QgsPoint:
        """Converts a WKT string to a QgsGeometry in EPSG:4326."""
        q_point_base: QgsGeometry = QgsGeometry.fromWkt(wkt)
        assert isinstance(q_point_base, QgsGeometry)  # nosec B101
        pointXY = q_point_base.asPoint()
        q_point = QgsPoint(pointXY.x(), pointXY.y())
        crs = self.omrat.iface.mapCanvas().mapSettings().destinationCrs().authid()
        tr = QgsCoordinateTransform(QgsCoordinateReferenceSystem(crs),
                                    QgsCoordinateReferenceSystem("EPSG:4326"),
                                    QgsProject.instance())
        q_point.transform(tr)
        return q_point

    def format_wkt(self, point: QgsPoint):
        """Formats a point as a WKT string with six decimal places."""
        return f'{point.x():.6f} {point.y():.6f}'

    @staticmethod
    def _parse_wkt_xy(text: str | None) -> tuple[float, float] | None:
        """Parse the OMRAT segment-table point format ``"lon lat"``.

        Tolerant of leading/trailing whitespace, of comma-separated forms,
        and of the full WKT shapes ``"Point (lon lat)"`` /
        ``"POINT(lon lat)"`` that ``update_segment_data`` writes for
        freshly-drawn legs via ``QgsPoint.asWkt()``.  Returns ``None`` for
        missing / malformed input.
        """
        if not isinstance(text, str):
            return None
        s = text.strip()
        if '(' in s and ')' in s:
            s = s.split('(', 1)[1].split(')', 1)[0]
        parts = s.replace(',', ' ').split()
        if len(parts) < 2:
            return None
        try:
            return float(parts[0]), float(parts[1])
        except ValueError:
            return None

    def _propagate_shared_vertex_move(
        self,
        *,
        moved_fid: int,
        old_start: tuple[float, float] | None,
        old_end: tuple[float, float] | None,
        new_start: tuple[float, float],
        new_end: tuple[float, float],
    ) -> None:
        """Move sibling legs that shared the endpoint just dragged.

        For every other leg whose start or end point matched ``old_start``
        or ``old_end`` (within :data:`_SHARED_VERTEX_TOL` degrees),
        update the stored endpoint to the matching ``new_*`` and rewrite
        the matching row of ``twRouteList``.

        A re-entrancy flag suppresses the propagation triggered by the
        QGIS ``geometryChanged`` signals fired by our own writes — those
        signals would otherwise cause a chain of moves that drift away
        from the user's original drag.
        """
        if getattr(self, '_propagating_vertex_move', False):
            return

        # Only propagate when the endpoint actually moved.
        moved: list[tuple[tuple[float, float], tuple[float, float]]] = []
        if old_start is not None and not self._xy_close(old_start, new_start):
            moved.append((old_start, new_start))
        if old_end is not None and not self._xy_close(old_end, new_end):
            moved.append((old_end, new_end))
        if not moved:
            return

        self._propagating_vertex_move = True
        try:
            for old_xy, new_xy in moved:
                self._move_matching_endpoints(
                    skip_fid=moved_fid, old_xy=old_xy, new_xy=new_xy,
                )
        finally:
            self._propagating_vertex_move = False

    @staticmethod
    def _xy_close(
        a: tuple[float, float],
        b: tuple[float, float],
        tol: float = 1e-7,
    ) -> bool:
        return abs(a[0] - b[0]) <= tol and abs(a[1] - b[1]) <= tol

    def _move_matching_endpoints(
        self,
        *,
        skip_fid: int,
        old_xy: tuple[float, float],
        new_xy: tuple[float, float],
    ) -> None:
        """For every leg != ``skip_fid`` whose start or end equals
        ``old_xy``, update that endpoint to ``new_xy``.

        Updates ``segment_data`` and the ``twRouteList`` row in step,
        and rewrites the offset / leg layer features so the canvas
        catches up without waiting for the next edit-buffer commit.
        """
        widget = self.omrat.main_widget
        if widget is None:
            return
        tol = 1e-7
        for row in range(widget.twRouteList.rowCount()):
            try:
                fid = int(widget.twRouteList.item(row, 0).text())
            except (AttributeError, ValueError):
                continue
            if fid == skip_fid:
                continue

            seg_key = str(fid)
            seg = self.omrat.segment_data.get(seg_key)
            if seg is None:
                continue

            sp = self._parse_wkt_xy(seg.get('Start_Point'))
            ep = self._parse_wkt_xy(seg.get('End_Point'))
            if sp is None or ep is None:
                continue

            updated = False
            if abs(sp[0] - old_xy[0]) <= tol and abs(sp[1] - old_xy[1]) <= tol:
                sp = new_xy
                updated = True
            if abs(ep[0] - old_xy[0]) <= tol and abs(ep[1] - old_xy[1]) <= tol:
                ep = new_xy
                updated = True
            if not updated:
                continue

            new_start_pt = QgsPoint(sp[0], sp[1])
            new_end_pt = QgsPoint(ep[0], ep[1])
            new_start_xy = QgsPointXY(sp[0], sp[1])
            new_end_xy = QgsPointXY(ep[0], ep[1])

            seg['Start_Point'] = self.format_wkt(new_start_pt)
            seg['End_Point'] = self.format_wkt(new_end_pt)
            try:
                widget.twRouteList.item(row, 3).setText(seg['Start_Point'])
                widget.twRouteList.item(row, 4).setText(seg['End_Point'])
            except Exception:  # nosec B110 B112
                pass

            try:
                width = float(widget.twRouteList.item(row, 5).text())
            except (AttributeError, TypeError, ValueError):
                width = 0.0
            try:
                self.create_offset_lines(
                    new_start_xy, new_end_xy, width / 2 if width else 0.0, fid,
                )
            except Exception:  # nosec B110 B112
                pass

    def on_geometry_changed_wrapper(self, segment_id: int, fid: int, geom: QgsGeometry):
        """Wrapper for the geometryChanged signal to pass the segment ID."""
        self.on_geometry_changed(segment_id, geom)
