# -*- coding: utf-8 -*-
"""
/***************************************************************************
 EasyGeocoder
                                 A QGIS plugin
 Geocode CSV addresses using OpenStreetMap / Nominatim (fixed service).
                              -------------------
        begin                : 2026-02-11
        copyright            : (C) 2026 by Jakub Grodzicki
        email                : gjkson16@gmail.com
 ***************************************************************************/
"""

import os
import csv
import json
import time
import http.client
import urllib.parse
import urllib.error
import urllib.request
from typing import Optional, List, Dict, Any, Callable, Tuple

from qgis.PyQt.QtCore import QSettings, QTranslator, QCoreApplication, QVariant, Qt
from qgis.PyQt.QtGui import QIcon, QPixmap, QPainter, QColor, QPen
from qgis.PyQt.QtWidgets import (
    QAction,
    QFileDialog,
    QMessageBox,
    QDialog,
    QDialogButtonBox,
    QGridLayout,
    QGroupBox,
    QLineEdit,
    QPushButton,
    QVBoxLayout,
    QLabel,
    QComboBox,
    QHBoxLayout,
    QFrame,
    QGraphicsDropShadowEffect,
)

from .resources_eg import *  # noqa: F401,F403

from qgis.core import (
    QgsVectorLayer,
    QgsProject,
    QgsApplication,
    QgsTask,
    QgsVectorFileWriter,
    QgsField,
    QgsFeature,
    QgsGeometry,
    QgsPointXY,
)

NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
PLUGIN_VERSION = "0.2.2"
USER_AGENT = f"EasyGeocoder/{PLUGIN_VERSION} (contact: gjkson16@gmail.com)"
REQUEST_TIMEOUT_SEC = 30
MAX_RETRIES = 2
RETRY_BASE_DELAY_SEC = 1.5
PLUGIN_SETTINGS_LANG_KEY = "easy_geocoder/language"
NOT_FOUND_FALLBACK_LAT = 52.40058891
NOT_FOUND_FALLBACK_LON = 7.07717619

LANG_OPTIONS: List[Tuple[str, str]] = [
    ("pl", "Polski"),
    ("de", "Deutsch"),
    ("en", "English"),
]

TEXTS: Dict[str, Dict[str, str]] = {
    "en": {
        "action_geocode": "EasyGeocoder: Geocode CSV (Nominatim)",
        "action_language": "EasyGeocoder: Select Language (PL/DE/EN)",
        "task_title": "EasyGeocoder: Geocode CSV",
        "already_running": "Geocoding is already running. Wait for it to finish or cancel it.",
        "select_input_csv": "Select input CSV (UTF-8)",
        "save_output_layer": "Save output layer (GPKG recommended)",
        "geocoding_started": "Geocoding started in background. You can continue working in QGIS.",
        "read_headers_failed": "Could not read CSV headers:\n{error}",
        "csv_no_headers": "CSV has no header row.",
        "csv_mapping_title": "CSV Column Mapping",
        "address_column": "Address column:",
        "city_column_optional": "City column (optional):",
        "postal_code_column_optional": "Postal code column (optional):",
        "country_column_optional": "Country column (optional):",
        "none_option": "(none)",
        "select_language_title": "Language",
        "select_language_label": "Select plugin language:",
        "language_changed": "Plugin language changed to: {language}",
        "geocoding_canceled": "Geocoding was canceled.",
        "geocoding_failed": "Geocoding failed:\n{error}",
        "save_or_load_failed": "Saving or loading output failed:\n{error}",
        "done_prefix": "Done.",
        "done_subtitle": "Geocoding completed and the output layer is ready in QGIS.",
        "completion_badge": "Nominatim",
        "completion_matched_label": "Matched",
        "completion_review_label": "Need review",
        "completion_all_matched_hint": "All addresses were matched successfully.",
        "completion_review_hint": "Some addresses need manual review before export.",
        "geocoded_records": "Geocoded records: {count}",
        "not_found_records": "Not found records: {count}",
        "dialog_title": "EasyGeocoder",
        "dialog_language_group": "Language",
        "dialog_language_label": "Interface language:",
        "dialog_files_group": "Files",
        "dialog_input_csv_label": "Input CSV:",
        "dialog_output_layer_label": "Output layer:",
        "dialog_browse": "Browse...",
        "dialog_start": "Start geocoding",
        "dialog_close": "Close",
        "dialog_required_fields": "Please complete required fields: {fields}",
        "dialog_csv_not_found": "Input CSV file does not exist:\n{path}",
    },
    "pl": {
        "action_geocode": "EasyGeocoder: Geokoduj CSV (Nominatim)",
        "action_language": "EasyGeocoder: Wybierz język (PL/DE/EN)",
        "task_title": "EasyGeocoder: Geokodowanie CSV",
        "already_running": "Geokodowanie już trwa. Poczekaj na zakończenie albo anuluj zadanie.",
        "select_input_csv": "Wybierz plik wejściowy CSV (UTF-8)",
        "save_output_layer": "Zapisz warstwę wynikową (zalecane GPKG)",
        "geocoding_started": "Geokodowanie uruchomione w tle. Możesz dalej pracować w QGIS.",
        "read_headers_failed": "Nie udało się odczytać nagłówków CSV:\n{error}",
        "csv_no_headers": "Plik CSV nie ma wiersza nagłówka.",
        "csv_mapping_title": "Mapowanie kolumn CSV",
        "address_column": "Kolumna adresu:",
        "city_column_optional": "Kolumna miasta (opcjonalnie):",
        "postal_code_column_optional": "Kolumna kodu pocztowego (opcjonalnie):",
        "country_column_optional": "Kolumna kraju (opcjonalnie):",
        "none_option": "(brak)",
        "select_language_title": "Język",
        "select_language_label": "Wybierz język wtyczki:",
        "language_changed": "Zmieniono język wtyczki na: {language}",
        "geocoding_canceled": "Geokodowanie zostało anulowane.",
        "geocoding_failed": "Geokodowanie nie powiodło się:\n{error}",
        "save_or_load_failed": "Nie udało się zapisać lub wczytać wyniku:\n{error}",
        "done_prefix": "Gotowe.",
        "done_subtitle": "Geokodowanie zakończone, a warstwa wynikowa jest już gotowa w QGIS.",
        "completion_badge": "Nominatim",
        "completion_matched_label": "Dopasowane",
        "completion_review_label": "Do sprawdzenia",
        "completion_all_matched_hint": "Wszystkie adresy zostały poprawnie dopasowane.",
        "completion_review_hint": "Część adresów wymaga ręcznego sprawdzenia przed eksportem.",
        "geocoded_records": "Zgeokodowane rekordy: {count}",
        "not_found_records": "Rekordy nie znalezione: {count}",
        "dialog_title": "EasyGeocoder",
        "dialog_language_group": "Język",
        "dialog_language_label": "Język interfejsu:",
        "dialog_files_group": "Pliki",
        "dialog_input_csv_label": "Wejściowy CSV:",
        "dialog_output_layer_label": "Warstwa wynikowa:",
        "dialog_browse": "Przeglądaj...",
        "dialog_start": "Uruchom geokodowanie",
        "dialog_close": "Zamknij",
        "dialog_required_fields": "Uzupełnij wymagane pola: {fields}",
        "dialog_csv_not_found": "Plik wejściowy CSV nie istnieje:\n{path}",
    },
    "de": {
        "action_geocode": "EasyGeocoder: CSV geokodieren (Nominatim)",
        "action_language": "EasyGeocoder: Sprache wählen (PL/DE/EN)",
        "task_title": "EasyGeocoder: CSV-Geokodierung",
        "already_running": "Die Geokodierung läuft bereits. Bitte warten oder den Task abbrechen.",
        "select_input_csv": "Eingabe-CSV auswählen (UTF-8)",
        "save_output_layer": "Ausgabe-Layer speichern (GPKG empfohlen)",
        "geocoding_started": "Geokodierung im Hintergrund gestartet. Sie können in QGIS weiterarbeiten.",
        "read_headers_failed": "CSV-Header konnten nicht gelesen werden:\n{error}",
        "csv_no_headers": "Die CSV-Datei hat keine Kopfzeile.",
        "csv_mapping_title": "CSV-Spaltenzuordnung",
        "address_column": "Adressspalte:",
        "city_column_optional": "Stadtspalte (optional):",
        "postal_code_column_optional": "PLZ-Spalte (optional):",
        "country_column_optional": "Landspalte (optional):",
        "none_option": "(keine)",
        "select_language_title": "Sprache",
        "select_language_label": "Plugin-Sprache auswählen:",
        "language_changed": "Plugin-Sprache geändert auf: {language}",
        "geocoding_canceled": "Geokodierung wurde abgebrochen.",
        "geocoding_failed": "Geokodierung fehlgeschlagen:\n{error}",
        "save_or_load_failed": "Speichern oder Laden der Ausgabe fehlgeschlagen:\n{error}",
        "done_prefix": "Fertig.",
        "done_subtitle": "Die Geokodierung ist abgeschlossen und der Ergebnis-Layer ist in QGIS bereit.",
        "completion_badge": "Nominatim",
        "completion_matched_label": "Treffer",
        "completion_review_label": "Prüfen",
        "completion_all_matched_hint": "Alle Adressen wurden erfolgreich gefunden.",
        "completion_review_hint": "Einige Adressen sollten vor dem Export manuell geprüft werden.",
        "geocoded_records": "Geokodierte Datensätze: {count}",
        "not_found_records": "Not-Found-Datensätze: {count}",
        "dialog_title": "EasyGeocoder",
        "dialog_language_group": "Sprache",
        "dialog_language_label": "Sprache der Oberfläche:",
        "dialog_files_group": "Dateien",
        "dialog_input_csv_label": "Eingabe-CSV:",
        "dialog_output_layer_label": "Ausgabe-Layer:",
        "dialog_browse": "Durchsuchen...",
        "dialog_start": "Geokodierung starten",
        "dialog_close": "Schließen",
        "dialog_required_fields": "Bitte füllen Sie die Pflichtfelder aus: {fields}",
        "dialog_csv_not_found": "Eingabe-CSV-Datei existiert nicht:\n{path}",
    },
}


class EasyGeocoderRunDialog(QDialog):
    """Main plugin dialog for language and file selection."""

    LANGUAGE_LABELS = {
        "pl": "Polski",
        "de": "Deutsch",
        "en": "English",
    }

    def __init__(self, parent, current_language: str):
        super().__init__(parent)
        self.language = current_language if current_language in TEXTS else "en"
        self.headers: List[str] = []
        self.window_icon_resource_path = ":/plugins/easy_geocoder/logo/EG.png"
        self.logo_resource_path = ":/plugins/easy_geocoder/logo/EasyGeocoder.png"
        self.setWindowIcon(QIcon(self.window_icon_resource_path))
        self.setWindowTitle(self._t("dialog_title"))
        self.setMinimumWidth(760)

        main_layout = QVBoxLayout(self)

        self.logo_label = QLabel(self)
        self.logo_label.setObjectName("easygeocoder_logo")
        self.logo_label.setStyleSheet("padding: 2px 0 6px 0;")
        self.logo_label.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
        self._refresh_logo()
        main_layout.addWidget(self.logo_label)

        self.language_group = QGroupBox("", self)
        language_layout = QGridLayout(self.language_group)
        self.language_label = QLabel("", self)
        language_layout.addWidget(self.language_label, 0, 0)

        self.language_combo = QComboBox(self)
        for code, _ in LANG_OPTIONS:
            self.language_combo.addItem(
                self._build_flag_icon(code),
                self.LANGUAGE_LABELS.get(code, code.upper()),
                code,
            )

        current_index = self.language_combo.findData(current_language)
        if current_index >= 0:
            self.language_combo.setCurrentIndex(current_index)
        self.language_combo.currentIndexChanged.connect(self._on_language_changed)

        language_layout.addWidget(self.language_combo, 0, 1)
        language_layout.setColumnStretch(1, 1)
        main_layout.addWidget(self.language_group)

        self.files_group = QGroupBox("", self)
        files_layout = QGridLayout(self.files_group)

        self.csv_path_edit = QLineEdit(self)
        self.output_path_edit = QLineEdit(self)

        self.csv_button = QPushButton("", self)
        self.out_button = QPushButton("", self)

        self.csv_button.clicked.connect(self._choose_input_csv)
        self.out_button.clicked.connect(self._choose_output_layer)
        self.csv_path_edit.textChanged.connect(self._on_csv_path_changed)

        self.input_csv_label = QLabel("", self)
        self.output_layer_label = QLabel("", self)

        files_layout.addWidget(self.input_csv_label, 0, 0)
        files_layout.addWidget(self.csv_path_edit, 0, 1)
        files_layout.addWidget(self.csv_button, 0, 2)

        files_layout.addWidget(self.output_layer_label, 1, 0)
        files_layout.addWidget(self.output_path_edit, 1, 1)
        files_layout.addWidget(self.out_button, 1, 2)
        files_layout.setColumnStretch(1, 1)

        main_layout.addWidget(self.files_group)

        self.mapping_group = QGroupBox("", self)
        mapping_layout = QGridLayout(self.mapping_group)

        self.address_label = QLabel("", self)
        self.city_label = QLabel("", self)
        self.postal_code_label = QLabel("", self)
        self.country_label = QLabel("", self)

        self.address_combo = QComboBox(self)
        self.city_combo = QComboBox(self)
        self.postal_code_combo = QComboBox(self)
        self.country_combo = QComboBox(self)

        mapping_layout.addWidget(self.address_label, 0, 0)
        mapping_layout.addWidget(self.address_combo, 0, 1)
        mapping_layout.addWidget(self.city_label, 1, 0)
        mapping_layout.addWidget(self.city_combo, 1, 1)
        mapping_layout.addWidget(self.postal_code_label, 2, 0)
        mapping_layout.addWidget(self.postal_code_combo, 2, 1)
        mapping_layout.addWidget(self.country_label, 3, 0)
        mapping_layout.addWidget(self.country_combo, 3, 1)
        mapping_layout.setColumnStretch(1, 1)

        main_layout.addWidget(self.mapping_group)

        self.button_box = QDialogButtonBox(self)
        self.start_button = self.button_box.addButton("", QDialogButtonBox.AcceptRole)
        self.close_button = self.button_box.addButton("", QDialogButtonBox.RejectRole)
        self.button_box.accepted.connect(self._on_accept)
        self.button_box.rejected.connect(self.reject)
        main_layout.addWidget(self.button_box)

        self._apply_translations()

    def _refresh_logo(self):
        logo_pixmap = QPixmap(self.logo_resource_path)
        if logo_pixmap.isNull():
            self.logo_label.clear()
            return

        self.logo_label.setPixmap(logo_pixmap.scaledToHeight(64))

    def _t(self, key: str, **kwargs) -> str:
        lang_pack = TEXTS.get(self.language, TEXTS["en"])
        fallback_pack = TEXTS["en"]
        template = lang_pack.get(key, fallback_pack.get(key, key))
        return template.format(**kwargs)

    def _on_language_changed(self, index: int):
        selected_language = self.language_combo.itemData(index)
        if selected_language and selected_language != self.language:
            self.language = selected_language
            self._apply_translations()

    def _apply_translations(self):
        self.setWindowTitle(self._t("dialog_title"))
        self.language_group.setTitle(self._t("dialog_language_group"))
        self.language_label.setText(self._t("dialog_language_label"))
        self.files_group.setTitle(self._t("dialog_files_group"))
        self.input_csv_label.setText(self._t("dialog_input_csv_label"))
        self.output_layer_label.setText(self._t("dialog_output_layer_label"))
        self.mapping_group.setTitle(self._t("csv_mapping_title"))
        self.address_label.setText(self._t("address_column"))
        self.city_label.setText(self._t("city_column_optional"))
        self.postal_code_label.setText(self._t("postal_code_column_optional"))
        self.country_label.setText(self._t("country_column_optional"))
        self.csv_button.setText(self._t("dialog_browse"))
        self.out_button.setText(self._t("dialog_browse"))
        self.start_button.setText(self._t("dialog_start"))
        self.close_button.setText(self._t("dialog_close"))
        self._populate_header_combos(self.headers)

    def _on_csv_path_changed(self, text_value: str):
        self._update_headers_from_csv(text_value.strip(), show_errors=False)

    def _update_headers_from_csv(self, csv_path: str, show_errors: bool):
        if not csv_path or not os.path.exists(csv_path):
            self.headers = []
            self._populate_header_combos(self.headers)
            return

        try:
            delimiter = _read_csv_delimiter(csv_path)
            headers = _read_csv_headers(csv_path, delimiter)
        except Exception as exc:  # pylint: disable=broad-exception-caught
            self.headers = []
            self._populate_header_combos(self.headers)
            if show_errors:
                QMessageBox.warning(self, "EasyGeocoder", self._t("read_headers_failed", error=exc))
            return

        self.headers = headers
        self._populate_header_combos(self.headers)

    def _populate_header_combos(self, headers: List[str]):
        previous_addr = self.address_combo.currentData()
        previous_city = self.city_combo.currentData()
        previous_postal_code = self.postal_code_combo.currentData()
        previous_country = self.country_combo.currentData()

        self.address_combo.clear()
        self.city_combo.clear()
        self.postal_code_combo.clear()
        self.country_combo.clear()

        none_option = self._t("none_option")

        for header in headers:
            self.address_combo.addItem(header, header)

        self.city_combo.addItem(none_option, None)
        self.postal_code_combo.addItem(none_option, None)
        self.country_combo.addItem(none_option, None)
        for header in headers:
            self.city_combo.addItem(header, header)
            self.postal_code_combo.addItem(header, header)
            self.country_combo.addItem(header, header)

        addr_default = _first_matching_header(headers, ["adresse", "address", "street", "addr"])
        city_default = _first_matching_header(headers, ["stadt", "city", "ort", "town"])
        postal_code_default = _first_matching_header(
            headers, ["postal", "postal code", "postcode", "zip", "zip code", "plz", "kod", "kod pocztowy"]
        )
        country_default = _first_matching_header(headers, ["land", "country", "nation"])

        addr_value = previous_addr if previous_addr in headers else (addr_default or (headers[0] if headers else None))
        city_value = previous_city if previous_city in headers else city_default
        postal_code_value = previous_postal_code if previous_postal_code in headers else postal_code_default
        country_value = previous_country if previous_country in headers else country_default

        self._set_combo_data(self.address_combo, addr_value)
        self._set_combo_data(self.city_combo, city_value)
        self._set_combo_data(self.postal_code_combo, postal_code_value)
        self._set_combo_data(self.country_combo, country_value)

    @staticmethod
    def _set_combo_data(combo: QComboBox, value):
        idx = combo.findData(value)
        if idx >= 0:
            combo.setCurrentIndex(idx)
        elif combo.count() > 0:
            combo.setCurrentIndex(0)

    @staticmethod
    def _build_flag_icon(language_code: str) -> QIcon:
        width, height = 24, 16
        pixmap = QPixmap(width, height)
        pixmap.fill(QColor("white"))

        painter = QPainter(pixmap)
        if language_code == "pl":
            painter.fillRect(0, 0, width, height // 2, QColor("white"))
            painter.fillRect(0, height // 2, width, height - (height // 2), QColor("#dc143c"))
        elif language_code == "de":
            painter.fillRect(0, 0, width, height // 3, QColor("black"))
            painter.fillRect(0, height // 3, width, height // 3, QColor("#dd0000"))
            painter.fillRect(0, 2 * (height // 3), width, height - 2 * (height // 3), QColor("#ffce00"))
        elif language_code == "en":
            # Simplified US flag: stripes + canton + star dots.
            for stripe_index in range(13):
                y0 = int((stripe_index * height) / 13)
                y1 = int(((stripe_index + 1) * height) / 13)
                stripe_color = QColor("#b22234") if stripe_index % 2 == 0 else QColor("white")
                painter.fillRect(0, y0, width, max(1, y1 - y0), stripe_color)

            canton_width = int(width * 0.45)
            canton_height = int((7 * height) / 13)
            painter.fillRect(0, 0, canton_width, canton_height, QColor("#3c3b6e"))

            painter.setPen(QPen(QColor("white"), 1))
            for row in range(4):
                for col in range(5):
                    x = 1 + int(col * max(1, (canton_width - 3)) / 4)
                    y = 1 + int(row * max(1, (canton_height - 3)) / 3)
                    painter.drawPoint(x, y)

        painter.setPen(QColor("#555555"))
        painter.drawRect(0, 0, width - 1, height - 1)
        painter.end()
        return QIcon(pixmap)

    def _choose_input_csv(self):
        csv_path, _ = QFileDialog.getOpenFileName(
            self,
            self._t("select_input_csv"),
            "",
            "CSV (*.csv)",
        )
        if not csv_path:
            return

        self.csv_path_edit.setText(csv_path)
        self._update_headers_from_csv(csv_path, show_errors=True)
        base_path, _ = os.path.splitext(csv_path)

        if not self.output_path_edit.text().strip():
            self.output_path_edit.setText(base_path + "_geocoded.gpkg")

    def _choose_output_layer(self):
        out_path, _ = QFileDialog.getSaveFileName(
            self,
            self._t("save_output_layer"),
            self.output_path_edit.text().strip(),
            "GeoPackage (*.gpkg);;Shapefile (*.shp)",
        )
        if out_path:
            self.output_path_edit.setText(out_path)

    def _on_accept(self):
        csv_path = self.csv_path_edit.text().strip()
        out_path = self.output_path_edit.text().strip()

        missing_fields: List[str] = []
        if not csv_path:
            missing_fields.append(self._t("dialog_input_csv_label"))
        if not out_path:
            missing_fields.append(self._t("dialog_output_layer_label"))

        if missing_fields:
            QMessageBox.warning(
                self,
                "EasyGeocoder",
                self._t("dialog_required_fields", fields=", ".join(missing_fields)),
            )
            return

        if not os.path.exists(csv_path):
            QMessageBox.warning(
                self,
                "EasyGeocoder",
                self._t("dialog_csv_not_found", path=csv_path),
            )
            return

        self._update_headers_from_csv(csv_path, show_errors=True)
        if not self.headers:
            QMessageBox.warning(self, "EasyGeocoder", self._t("csv_no_headers"))
            return

        if not self.address_combo.currentData():
            QMessageBox.warning(
                self,
                "EasyGeocoder",
                self._t("dialog_required_fields", fields=self._t("address_column")),
            )
            return

        self.accept()

    def values(self) -> Dict[str, Optional[str]]:
        return {
            "language": self.language_combo.currentData(),
            "csv_path": self.csv_path_edit.text().strip(),
            "out_path": self.output_path_edit.text().strip(),
            "addr_field": self.address_combo.currentData(),
            "city_field": self.city_combo.currentData(),
            "postal_code_field": self.postal_code_combo.currentData(),
            "country_field": self.country_combo.currentData(),
        }



class GeocodeCsvTask(QgsTask):
    """Background geocoding task."""

    def __init__(
        self,
        task_title: str,
        csv_path: str,
        addr_field: str,
        city_field: Optional[str],
        postal_code_field: Optional[str],
        country_field: Optional[str],
        throttle_sec: float,
        on_done: Callable[["GeocodeCsvTask", bool], None],
    ):
        super().__init__(task_title, QgsTask.CanCancel)
        self.csv_path = csv_path
        self.addr_field = addr_field
        self.city_field = city_field
        self.postal_code_field = postal_code_field
        self.country_field = country_field
        self.throttle_sec = throttle_sec
        self._on_done = on_done
        self.geocoded_rows: List[Dict[str, Any]] = []
        self.output_rows: List[Dict[str, Any]] = []
        self.not_found: List[Dict[str, Any]] = []
        self.csv_headers: List[str] = []
        self.error_message = ""
        self.total_rows = 0

    def _extract_source_values(self, row: Dict[str, Any]) -> Dict[str, str]:
        source_values: Dict[str, str] = {}
        for header in self.csv_headers:
            value = row.get(header, "")
            source_values[header] = "" if value is None else str(value)
        return source_values

    def run(self) -> bool:
        try:
            delimiter = _read_csv_delimiter(self.csv_path)
            self.total_rows = _count_csv_rows(self.csv_path, delimiter)
            if self.total_rows == 0:
                self.setProgress(100.0)
                return True

            cache: Dict[str, Dict[str, Any]] = {}
            with open(self.csv_path, "r", encoding="utf-8-sig", newline="") as csv_file:
                reader = csv.DictReader(csv_file, delimiter=delimiter)
                self.csv_headers = [field for field in (reader.fieldnames or []) if field]
                for index, row in enumerate(reader, start=1):
                    if self.isCanceled():
                        return False

                    query = _build_query(
                        row,
                        self.addr_field,
                        self.city_field,
                        self.postal_code_field,
                        self.country_field,
                    )
                    if not query:
                        row["_reason"] = "empty query"
                        self.not_found.append(row)
                        self.output_rows.append(
                            {
                                "query": query,
                                "lat": NOT_FOUND_FALLBACK_LAT,
                                "lon": NOT_FOUND_FALLBACK_LON,
                                "status": _status_label_from_reason(row["_reason"]),
                                "source_values": self._extract_source_values(row),
                            }
                        )
                        self.setProgress((index * 100.0) / self.total_rows)
                        continue

                    if query in cache:
                        geocode_outcome = cache[query]
                    else:
                        if self.throttle_sec > 0:
                            time.sleep(self.throttle_sec)
                        if self.isCanceled():
                            return False
                        address = (row.get(self.addr_field) or "").strip()
                        city = (row.get(self.city_field) or "").strip() if self.city_field else ""
                        postal_code = (row.get(self.postal_code_field) or "").strip() if self.postal_code_field else ""
                        country = (row.get(self.country_field) or "").strip() if self.country_field else ""
                        geocode_outcome = _nominatim_geocode(
                            query,
                            address=address,
                            city=city,
                            postal_code=postal_code,
                            country=country,
                        )
                        cache[query] = geocode_outcome

                    if geocode_outcome["status"] != "ok":
                        row["_reason"] = geocode_outcome["status"]
                        self.not_found.append(row)
                        self.output_rows.append(
                            {
                                "query": query,
                                "lat": NOT_FOUND_FALLBACK_LAT,
                                "lon": NOT_FOUND_FALLBACK_LON,
                                "status": _status_label_from_reason(row["_reason"]),
                                "source_values": self._extract_source_values(row),
                            }
                        )
                        self.setProgress((index * 100.0) / self.total_rows)
                        continue

                    result = geocode_outcome["result"]
                    try:
                        lat = float(result["lat"])
                        lon = float(result["lon"])
                    except (KeyError, TypeError, ValueError):
                        row["_reason"] = "invalid coordinates"
                        self.not_found.append(row)
                        self.output_rows.append(
                            {
                                "query": query,
                                "lat": NOT_FOUND_FALLBACK_LAT,
                                "lon": NOT_FOUND_FALLBACK_LON,
                                "status": _status_label_from_reason(row["_reason"]),
                                "source_values": self._extract_source_values(row),
                            }
                        )
                        self.setProgress((index * 100.0) / self.total_rows)
                        continue

                    output_row = {
                        "query": query,
                        "lat": lat,
                        "lon": lon,
                        "status": "OK",
                        "source_values": self._extract_source_values(row),
                    }
                    self.geocoded_rows.append(output_row)
                    self.output_rows.append(output_row)
                    self.setProgress((index * 100.0) / self.total_rows)

            return True
        except Exception as exc:  # pylint: disable=broad-exception-caught
            self.error_message = str(exc)
            return False

    def finished(self, success: bool):
        self._on_done(self, success)


class EasyGeocoder:
    """QGIS Plugin Implementation."""

    def __init__(self, iface):
        self.iface = iface
        self.plugin_dir = os.path.dirname(__file__)

        locale = QSettings().value("locale/userLocale", "en", type=str)[0:2]
        locale_path = os.path.join(self.plugin_dir, "i18n", f"EasyGeocoder_{locale}.qm")
        if os.path.exists(locale_path):
            self.translator = QTranslator()
            self.translator.load(locale_path)
            QCoreApplication.installTranslator(self.translator)

        self.actions = []
        self.menu = self.tr("EasyGeocoder")
        self.first_start = None
        self.current_task: Optional[GeocodeCsvTask] = None
        self.is_unloading = False
        self.language = self._load_language_setting()
        self.geocode_action: Optional[QAction] = None
        self.plugin_submenu = None
        self.plugin_submenu_action: Optional[QAction] = None

    def tr(self, message):
        return QCoreApplication.translate("EasyGeocoder", message)

    def _load_language_setting(self) -> str:
        saved_language = QSettings().value(PLUGIN_SETTINGS_LANG_KEY, "", type=str)
        supported_codes = {code for code, _ in LANG_OPTIONS}
        if saved_language in supported_codes:
            return saved_language

        qgis_locale = QSettings().value("locale/userLocale", "en", type=str)[:2].lower()
        if qgis_locale in supported_codes:
            return qgis_locale

        return "en"

    def _t(self, key: str, **kwargs) -> str:
        lang_pack = TEXTS.get(self.language, TEXTS["en"])
        fallback_pack = TEXTS["en"]
        template = lang_pack.get(key, fallback_pack.get(key, key))
        return template.format(**kwargs)

    def _refresh_action_texts(self):
        if self.geocode_action:
            self.geocode_action.setText(self._t("action_geocode"))

    def add_action(
        self,
        icon_path,
        text,
        callback,
        enabled_flag=True,
        add_to_menu=True,
        add_to_toolbar=True,
        status_tip=None,
        whats_this=None,
        parent=None,
    ):
        icon = QIcon(icon_path)
        action = QAction(icon, text, parent)
        action.triggered.connect(callback)
        action.setEnabled(enabled_flag)

        if status_tip is not None:
            action.setStatusTip(status_tip)
        if whats_this is not None:
            action.setWhatsThis(whats_this)

        if add_to_toolbar:
            self.iface.addToolBarIcon(action)
        if add_to_menu:
            if self.plugin_submenu is not None:
                self.plugin_submenu.addAction(action)
            else:
                self.iface.addPluginToMenu(self.menu, action)

        self.actions.append(action)
        return action

    def initGui(self):
        icon_path = ":/plugins/easy_geocoder/logo/EG.png"
        self.plugin_submenu = self.iface.pluginMenu().addMenu(
            QIcon(icon_path),
            self.menu,
        )
        self.plugin_submenu_action = self.plugin_submenu.menuAction()

        self.geocode_action = self.add_action(
            icon_path,
            text=self._t("action_geocode"),
            callback=self.run,
            parent=self.iface.mainWindow(),
        )
        self.first_start = True

    def unload(self):
        self.is_unloading = True
        if self.current_task and self.current_task.status() in (
            QgsTask.Queued,
            QgsTask.OnHold,
            QgsTask.Running,
        ):
            self.current_task.cancel()

        for action in self.actions:
            if self.plugin_submenu is not None:
                self.plugin_submenu.removeAction(action)
            self.iface.removeToolBarIcon(action)

        if self.plugin_submenu_action is not None:
            self.iface.pluginMenu().removeAction(self.plugin_submenu_action)
            self.plugin_submenu_action = None
            self.plugin_submenu = None

    def run(self):
        if self.current_task and self.current_task.status() in (
            QgsTask.Queued,
            QgsTask.OnHold,
            QgsTask.Running,
        ):
            QMessageBox.warning(
                self.iface.mainWindow(),
                "EasyGeocoder",
                self._t("already_running"),
            )
            return

        dialog = EasyGeocoderRunDialog(
            self.iface.mainWindow(),
            self.language,
        )
        if dialog.exec_() != QDialog.Accepted:
            return

        selected = dialog.values()
        selected_language = selected["language"]
        if selected_language and selected_language != self.language:
            self.language = selected_language
            QSettings().setValue(PLUGIN_SETTINGS_LANG_KEY, self.language)
            self._refresh_action_texts()

        csv_path = selected["csv_path"]
        out_path = selected["out_path"]

        task = GeocodeCsvTask(
            task_title=self._t("task_title"),
            csv_path=csv_path,
            addr_field=selected["addr_field"],
            city_field=selected["city_field"],
            postal_code_field=selected["postal_code_field"],
            country_field=selected["country_field"],
            throttle_sec=1.1,
            on_done=lambda finished_task, success: self._on_task_finished(
                finished_task,
                success,
                out_path,
            ),
        )
        self.current_task = task
        self._set_actions_enabled(False)
        QgsApplication.taskManager().addTask(task)
        self.iface.messageBar().pushInfo(
            "EasyGeocoder",
            self._t("geocoding_started"),
        )

    def _set_actions_enabled(self, enabled: bool):
        for action in self.actions:
            action.setEnabled(enabled)

    @staticmethod
    def _build_completion_icon_pixmap() -> QPixmap:
        size = 88
        pixmap = QPixmap(size, size)
        pixmap.fill(Qt.transparent)

        painter = QPainter(pixmap)
        painter.setRenderHint(QPainter.Antialiasing)
        painter.setPen(Qt.NoPen)
        painter.setBrush(QColor("#d9f7e8"))
        painter.drawEllipse(4, 4, size - 8, size - 8)
        painter.setBrush(QColor("#16a36c"))
        painter.drawEllipse(12, 12, size - 24, size - 24)

        check_pen = QPen(QColor("white"), 6)
        check_pen.setCapStyle(Qt.RoundCap)
        check_pen.setJoinStyle(Qt.RoundJoin)
        painter.setPen(check_pen)
        painter.drawLine(29, 47, 39, 58)
        painter.drawLine(39, 58, 60, 34)
        painter.end()
        return pixmap

    @staticmethod
    def _build_completion_stat_card(
        title: str,
        value: int,
        detail: str,
        accent_color: str,
        background_color: str,
        border_color: str,
    ) -> QFrame:
        card = QFrame()
        card.setStyleSheet(
            f"""
            QFrame {{
                background-color: {background_color};
                border: 1px solid {border_color};
                border-radius: 18px;
            }}
            QLabel {{
                background: transparent;
                border: none;
            }}
            """
        )

        layout = QVBoxLayout(card)
        layout.setContentsMargins(18, 16, 18, 16)
        layout.setSpacing(4)

        title_label = QLabel(title)
        title_label.setStyleSheet(f"color: {accent_color}; font-size: 11px; font-weight: 700;")

        value_label = QLabel(str(value))
        value_label.setStyleSheet("color: #16324b; font-size: 30px; font-weight: 700;")

        detail_label = QLabel(detail)
        detail_label.setWordWrap(True)
        detail_label.setStyleSheet("color: #56718a; font-size: 12px;")

        layout.addWidget(title_label)
        layout.addWidget(value_label)
        layout.addWidget(detail_label)
        layout.addStretch()
        return card

    def _show_completion_dialog(self, geocoded_count: int, not_found_count: int):
        review_background = "#fff4e7" if not_found_count else "#eff5ff"
        review_border = "#f3cf9d" if not_found_count else "#c7daf7"
        review_accent = "#b96a16" if not_found_count else "#315b9a"
        hint_text = self._t("completion_review_hint") if not_found_count else self._t("completion_all_matched_hint")

        dialog = QDialog(self.iface.mainWindow())
        dialog.setWindowTitle("EasyGeocoder")
        dialog.setModal(True)
        dialog.setMinimumWidth(640)
        dialog.setStyleSheet(
            """
            QDialog {
                background-color: #edf3f7;
            }
            QFrame#completionShell {
                background-color: #f8fbfd;
                border: 1px solid #d3e0ea;
                border-radius: 24px;
            }
            QFrame#completionHero {
                background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
                    stop:0 #ffffff, stop:1 #eef6fb);
                border: 1px solid #dbe8f1;
                border-radius: 20px;
            }
            QLabel#completionBadge {
                background-color: #e8f0ff;
                color: #34587b;
                border: 1px solid #cbdaf1;
                border-radius: 12px;
                padding: 6px 10px;
                font-size: 11px;
                font-weight: 700;
            }
            QLabel#completionTitle {
                color: #123426;
                font-size: 26px;
                font-weight: 700;
            }
            QLabel#completionSubtitle {
                color: #48637d;
                font-size: 13px;
            }
            QLabel#completionHint {
                color: #2f5874;
                font-size: 12px;
                font-weight: 600;
            }
            QPushButton {
                min-width: 126px;
                padding: 10px 20px;
                border-radius: 12px;
                border: 1px solid #12815e;
                background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
                    stop:0 #18a36d, stop:1 #0f875c);
                color: white;
                font-size: 13px;
                font-weight: 700;
            }
            QPushButton:hover {
                background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
                    stop:0 #1ab878, stop:1 #0f9362);
            }
            QPushButton:pressed {
                background: #0d724e;
            }
            """
        )

        outer_layout = QVBoxLayout(dialog)
        outer_layout.setContentsMargins(20, 18, 20, 18)

        shell = QFrame(dialog)
        shell.setObjectName("completionShell")
        shadow = QGraphicsDropShadowEffect(dialog)
        shadow.setBlurRadius(34)
        shadow.setOffset(0, 12)
        shadow.setColor(QColor(18, 51, 77, 40))
        shell.setGraphicsEffect(shadow)
        outer_layout.addWidget(shell)

        shell_layout = QVBoxLayout(shell)
        shell_layout.setContentsMargins(18, 18, 18, 18)
        shell_layout.setSpacing(16)

        hero = QFrame(shell)
        hero.setObjectName("completionHero")
        hero_layout = QHBoxLayout(hero)
        hero_layout.setContentsMargins(18, 18, 18, 18)
        hero_layout.setSpacing(16)

        icon_label = QLabel(hero)
        icon_label.setPixmap(self._build_completion_icon_pixmap())
        icon_label.setAlignment(Qt.AlignTop | Qt.AlignHCenter)
        hero_layout.addWidget(icon_label, 0, Qt.AlignTop)

        text_layout = QVBoxLayout()
        text_layout.setSpacing(6)

        badge_label = QLabel(self._t("completion_badge"), hero)
        badge_label.setObjectName("completionBadge")
        badge_label.setAlignment(Qt.AlignCenter)
        badge_label.setMaximumWidth(96)
        text_layout.addWidget(badge_label, 0, Qt.AlignLeft)

        title_label = QLabel(self._t("done_prefix"), hero)
        title_label.setObjectName("completionTitle")
        text_layout.addWidget(title_label)

        subtitle_label = QLabel(self._t("done_subtitle"), hero)
        subtitle_label.setObjectName("completionSubtitle")
        subtitle_label.setWordWrap(True)
        text_layout.addWidget(subtitle_label)

        hint_label = QLabel(hint_text, hero)
        hint_label.setObjectName("completionHint")
        hint_label.setWordWrap(True)
        text_layout.addWidget(hint_label)

        hero_layout.addLayout(text_layout, 1)
        shell_layout.addWidget(hero)

        stats_layout = QHBoxLayout()
        stats_layout.setSpacing(14)
        stats_layout.addWidget(
            self._build_completion_stat_card(
                title=self._t("completion_matched_label"),
                value=geocoded_count,
                detail=self._t("geocoded_records", count=geocoded_count),
                accent_color="#158a63",
                background_color="#edfbf3",
                border_color="#bee5cf",
            )
        )
        stats_layout.addWidget(
            self._build_completion_stat_card(
                title=self._t("completion_review_label"),
                value=not_found_count,
                detail=self._t("not_found_records", count=not_found_count),
                accent_color=review_accent,
                background_color=review_background,
                border_color=review_border,
            )
        )
        shell_layout.addLayout(stats_layout)

        button_layout = QHBoxLayout()
        button_layout.addStretch()
        ok_button = QPushButton("OK", shell)
        ok_button.clicked.connect(dialog.accept)
        button_layout.addWidget(ok_button)
        shell_layout.addLayout(button_layout)

        dialog.exec_()

    def _on_task_finished(
        self,
        task: GeocodeCsvTask,
        success: bool,
        out_path: str,
    ):
        if self.is_unloading:
            self.current_task = None
            return

        self.current_task = None
        self._set_actions_enabled(True)

        if task.isCanceled():
            QMessageBox.warning(
                self.iface.mainWindow(),
                "EasyGeocoder",
                self._t("geocoding_canceled"),
            )
            return

        if not success:
            error_message = task.error_message or "Unknown error during geocoding."
            QMessageBox.critical(
                self.iface.mainWindow(),
                "EasyGeocoder",
                self._t("geocoding_failed", error=error_message),
            )
            return

        try:
            output_layer = build_layer_from_rows(task.output_rows, task.csv_headers)
            save_layer(output_layer, out_path)
            self.iface.addVectorLayer(out_path, "Geocoded (Nominatim)", "ogr")
            not_found_count = len(task.not_found)
            self._show_completion_dialog(
                geocoded_count=len(task.geocoded_rows),
                not_found_count=not_found_count,
            )
        except Exception as exc:  # pylint: disable=broad-exception-caught
            QMessageBox.critical(
                self.iface.mainWindow(),
                "EasyGeocoder",
                self._t("save_or_load_failed", error=exc),
            )


# -------------------------
# Helpers
# -------------------------

def _detect_delimiter(first_line: str) -> str:
    # Simple fallback for common CSV formats.
    if ";" in first_line and first_line.count(";") >= first_line.count(","):
        return ";"
    return ","


def _read_csv_delimiter(csv_path: str) -> str:
    with open(csv_path, "r", encoding="utf-8-sig", newline="") as csv_file:
        first_line = csv_file.readline()
    return _detect_delimiter(first_line)


def _count_csv_rows(csv_path: str, delimiter: str) -> int:
    with open(csv_path, "r", encoding="utf-8-sig", newline="") as csv_file:
        reader = csv.DictReader(csv_file, delimiter=delimiter)
        return sum(1 for _ in reader)


def _read_csv_headers(csv_path: str, delimiter: str) -> List[str]:
    with open(csv_path, "r", encoding="utf-8-sig", newline="") as csv_file:
        reader = csv.DictReader(csv_file, delimiter=delimiter)
        if not reader.fieldnames:
            return []
        return [name.strip() for name in reader.fieldnames if name and name.strip()]


def _first_matching_header(headers: List[str], aliases: List[str]) -> Optional[str]:
    normalized_headers = [(header, header.strip().lower()) for header in headers]

    for alias in aliases:
        alias_norm = alias.strip().lower()
        for header, header_norm in normalized_headers:
            if header_norm == alias_norm:
                return header

    for alias in aliases:
        alias_norm = alias.strip().lower()
        for header, header_norm in normalized_headers:
            if alias_norm in header_norm:
                return header

    return None


def _build_query(
    row: Dict[str, Any],
    addr_field: str,
    city_field: Optional[str],
    postal_code_field: Optional[str],
    country_field: Optional[str],
) -> str:
    address = (row.get(addr_field) or "").strip()
    city = (row.get(city_field) or "").strip() if city_field else ""
    postal_code = (row.get(postal_code_field) or "").strip() if postal_code_field else ""
    country = (row.get(country_field) or "").strip() if country_field else ""
    locality = " ".join([part for part in [postal_code, city] if part])
    return ", ".join([part for part in [address, locality, country] if part])


def _status_label_from_reason(reason: str) -> str:
    reason_text = (reason or "").strip()
    if not reason_text:
        return "NOT FOUND"
    return reason_text.replace("_", " ").upper()


def _validate_nominatim_url(url: str) -> str:
    parsed_url = urllib.parse.urlsplit(url)
    if parsed_url.scheme != "https" or parsed_url.netloc != "nominatim.openstreetmap.org" or not parsed_url.path.startswith("/"):
        raise ValueError(
            "Refusing to open unexpected URL; only https://nominatim.openstreetmap.org/... is allowed"
        )
    return url


def _nominatim_request(params: Dict[str, Any]) -> Dict[str, Any]:
    url = NOMINATIM_URL + "?" + urllib.parse.urlencode(params)
    validated_url = _validate_nominatim_url(url)
    parsed_url = urllib.parse.urlsplit(validated_url)
    request_path = parsed_url.path or "/"
    if parsed_url.query:
        request_path = f"{request_path}?{parsed_url.query}"

    for attempt in range(MAX_RETRIES + 1):
        connection = None
        try:
            connection = http.client.HTTPSConnection(parsed_url.netloc, timeout=REQUEST_TIMEOUT_SEC)
            connection.request("GET", request_path, headers={"User-Agent": USER_AGENT})
            response = connection.getresponse()
            status_code = response.status
            headers = {key: value for key, value in response.getheaders()}
            data = response.read().decode("utf-8")

            if status_code == 429:
                if attempt < MAX_RETRIES:
                    time.sleep(_retry_delay(attempt, headers.get("Retry-After")))
                    continue
                return {"status": "http_429", "result": None}

            if 500 <= status_code < 600:
                if attempt < MAX_RETRIES:
                    time.sleep(_retry_delay(attempt))
                    continue
                return {"status": f"http_{status_code}", "result": None}

            if status_code >= 400:
                return {"status": f"http_{status_code}", "result": None}

            parsed = json.loads(data)
            if parsed:
                return {"status": "ok", "result": parsed[0]}
            return {"status": "not_found", "result": None}
        except urllib.error.URLError as exc:
            status = "timeout" if "timed out" in str(exc.reason).lower() else "network_error"
            if attempt < MAX_RETRIES:
                time.sleep(_retry_delay(attempt))
                continue
            return {"status": status, "result": None}
        except (http.client.HTTPException, OSError):
            if attempt < MAX_RETRIES:
                time.sleep(_retry_delay(attempt))
                continue
            return {"status": "network_error", "result": None}
        except TimeoutError:
            if attempt < MAX_RETRIES:
                time.sleep(_retry_delay(attempt))
                continue
            return {"status": "timeout", "result": None}
        except json.JSONDecodeError:
            return {"status": "invalid_json", "result": None}
        except Exception:  # pylint: disable=broad-exception-caught
            if attempt < MAX_RETRIES:
                time.sleep(_retry_delay(attempt))
                continue
            return {"status": "network_error", "result": None}
        finally:
            if connection is not None:
                connection.close()

    return {"status": "network_error", "result": None}


def _nominatim_geocode(
    query: str,
    address: str = "",
    city: str = "",
    postal_code: str = "",
    country: str = "",
) -> Dict[str, Any]:
    base_params = {"format": "json", "limit": 1}

    structured_params = dict(base_params)
    if address:
        structured_params["street"] = address
    if city:
        structured_params["city"] = city
    if postal_code:
        structured_params["postalcode"] = postal_code
    if country:
        structured_params["country"] = country

    if any([address, city, postal_code, country]):
        # First try strict field-based search for better precision.
        structured_outcome = _nominatim_request(structured_params)
        if structured_outcome["status"] == "ok":
            return structured_outcome
        if structured_outcome["status"] != "not_found":
            return structured_outcome

    # Fallback to free-text query to recover matches when structured search is too strict.
    fallback_params = dict(base_params)
    fallback_params["q"] = query
    return _nominatim_request(fallback_params)


def _retry_delay(attempt: int, retry_after_header: Optional[str] = None) -> float:
    if retry_after_header:
        try:
            retry_after = float(retry_after_header)
            if retry_after > 0:
                return retry_after
        except ValueError:
            pass
    return RETRY_BASE_DELAY_SEC * (2 ** attempt)


def _build_source_field_map(source_columns: List[str]) -> Dict[str, str]:
    reserved_names = {"query", "coordinates", "status"}
    used_lower = {name.lower() for name in reserved_names}
    source_field_map: Dict[str, str] = {}

    for column_name in source_columns:
        if not column_name:
            continue

        candidate = str(column_name).strip()
        if not candidate:
            continue

        base_name = candidate
        suffix = 1
        while candidate.lower() in used_lower:
            candidate = f"{base_name}_{suffix}"
            suffix += 1

        used_lower.add(candidate.lower())
        source_field_map[column_name] = candidate

    return source_field_map


def _is_integer_text(value: str) -> bool:
    text = (value or "").strip()
    if not text:
        return False
    if text[0] in "+-":
        return text[1:].isdigit()
    return text.isdigit()


def _infer_source_field_types(
    geocoded_rows: List[Dict[str, Any]],
    source_columns: List[str],
) -> Dict[str, int]:
    source_types: Dict[str, int] = {}
    for source_column in source_columns:
        non_empty_values: List[str] = []
        for row in geocoded_rows:
            source_values = row.get("source_values") or {}
            raw_value = source_values.get(source_column, "")
            text_value = "" if raw_value is None else str(raw_value).strip()
            if text_value:
                non_empty_values.append(text_value)

        if non_empty_values and all(_is_integer_text(value) for value in non_empty_values):
            source_types[source_column] = QVariant.LongLong
        else:
            source_types[source_column] = QVariant.String

    return source_types


def build_layer_from_rows(
    geocoded_rows: List[Dict[str, Any]],
    source_columns: Optional[List[str]] = None,
) -> QgsVectorLayer:
    vl = QgsVectorLayer("Point?crs=EPSG:4326", "Geocoded (Nominatim)", "memory")
    pr = vl.dataProvider()
    source_column_list = source_columns or []
    source_field_map = _build_source_field_map(source_column_list)
    source_field_types = _infer_source_field_types(geocoded_rows, source_column_list)
    source_items = list(source_field_map.items())
    integer_source_items = [
        (source_column, field_name)
        for source_column, field_name in source_items
        if source_field_types.get(source_column) == QVariant.LongLong
    ]
    non_integer_source_items = [
        (source_column, field_name)
        for source_column, field_name in source_items
        if source_field_types.get(source_column) != QVariant.LongLong
    ]
    ordered_source_items = integer_source_items + non_integer_source_items

    fields = []
    for source_column, field_name in integer_source_items:
        fields.append(QgsField(field_name, source_field_types.get(source_column, QVariant.String)))
    fields.append(QgsField("query", QVariant.String))
    for source_column, field_name in non_integer_source_items:
        fields.append(QgsField(field_name, source_field_types.get(source_column, QVariant.String)))
    # Keep these two columns always at the very end.
    fields.append(QgsField("coordinates", QVariant.String))
    fields.append(QgsField("status", QVariant.String))

    pr.addAttributes(fields)
    vl.updateFields()

    for row in geocoded_rows:
        feat = QgsFeature(vl.fields())
        feat.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(row["lon"], row["lat"])))
        feat["query"] = row["query"]
        feat["coordinates"] = f"{row['lat']}, {row['lon']}"
        feat["status"] = row["status"]
        source_values = row.get("source_values") or {}
        for source_column, field_name in ordered_source_items:
            value = source_values.get(source_column, "")
            if value is None or str(value).strip() == "":
                feat[field_name] = None if source_field_types.get(source_column) == QVariant.LongLong else ""
            elif source_field_types.get(source_column) == QVariant.LongLong:
                feat[field_name] = int(str(value).strip())
            else:
                feat[field_name] = str(value)
        pr.addFeature(feat)

    vl.updateExtents()
    return vl


def save_layer(vl: QgsVectorLayer, out_path: str):
    options = QgsVectorFileWriter.SaveVectorOptions()
    options.driverName = "GPKG" if out_path.lower().endswith(".gpkg") else "ESRI Shapefile"
    options.layerName = "geocoded" if options.driverName == "GPKG" else ""

    result = QgsVectorFileWriter.writeAsVectorFormatV3(
        vl,
        out_path,
        QgsProject.instance().transformContext(),
        options,
    )

    # QGIS can return tuples with different lengths across versions.
    # We only use the first two values: (error_code, error_message).
    if isinstance(result, tuple) and len(result) >= 2:
        res, err = result[0], result[1]
    else:
        res, err = result, ""

    if res != QgsVectorFileWriter.NoError:
        raise RuntimeError(str(err))





