import os
import re
import zipfile
try:
    import defusedxml.ElementTree as ET  # noqa: F401
except ImportError:
    import xml.etree.ElementTree as ET  # nosec B405
from qgis.core import (
    QgsVectorLayer, QgsVectorFileWriter, QgsCoordinateReferenceSystem,
    QgsCoordinateTransform, QgsField, QgsFields, QgsFeature,
    QgsGeometry, QgsProject
)
from ..shared.qt_compat import QVariant


class KmlToShpConverter:

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def convert(self, kml_path, output_path, target_crs_str='EPSG:4326',
                selected_fields=None, output_format='shp'):
        """
        Convert KML/KMZ to SHP or GPKG.

        Args:
            kml_path:       Source KML/KMZ file
            output_path:    Destination path (.shp or .gpkg)
            target_crs_str: Target CRS (default WGS84)
            selected_fields: List of field names to include (None = all)
            output_format:  'shp' (10-char ASCII names) or 'gpkg' (full Unicode names)
        """
        if not os.path.exists(kml_path):
            return False, "KML file not found"

        fmt = output_format.lower()
        use_gpkg = fmt == 'gpkg'

        # 1. Load all features from KML
        all_features, all_fields, layer_wkb = self._load_all_features(
            kml_path, use_gpkg=use_gpkg)
        if all_features is None:
            return False, "Invalid KML file"
        if not all_features:
            return False, "No features found in KML"

        # 2. Setup Target CRS
        crs = QgsCoordinateReferenceSystem(target_crs_str)
        if not crs.isValid():
            crs = QgsCoordinateReferenceSystem('EPSG:4326')

        # 3. Group features by geometry type
        groups = {}  # geom_label -> [(feat, parsed_attrs)]
        for feat, parsed_attrs in all_features:
            geom = feat.geometry()
            if geom is None or geom.isEmpty():
                continue
            gt = geom.type()  # 0=Point, 1=Line, 2=Polygon
            if gt == 0:
                label = 'point'
            elif gt == 1:
                label = 'line'
            elif gt == 2:
                label = 'polygon'
            else:
                label = 'other'
            groups.setdefault(label, []).append((feat, parsed_attrs))

        if not groups:
            return False, "No valid geometries found (all empty)"

        # 4. Build output fields
        new_fields = self._build_fields(
            all_fields, all_features, selected_fields, use_gpkg=use_gpkg)

        # 5. Write features
        total_written = 0
        results = []
        output_paths = []  # [(file_path, layer_name), ...] — exact paths created

        if use_gpkg:
            # GPKG: all geometry groups go into the SAME file, different layers
            _driver = 'GPKG'  # noqa: F841
            base_path = output_path if output_path.lower().endswith('.gpkg') \
                else os.path.splitext(output_path)[0] + '.gpkg'

            # Remove existing GPKG to start fresh
            if os.path.exists(base_path):
                os.remove(base_path)

            for idx, (label, feat_list) in enumerate(groups.items()):
                sample_geom = feat_list[0][0].geometry()
                wkb_type = sample_geom.wkbType()
                layer_name = label if len(groups) > 1 else \
                    os.path.splitext(os.path.basename(base_path))[0]
                count = self._write_layer(
                    base_path, layer_name, new_fields, wkb_type, crs,
                    feat_list, all_fields['original'],
                    driver='GPKG',
                    create_new=(idx == 0))
                total_written += count
                results.append(f"{label}: {count}")
                output_paths.append((base_path, layer_name))  # track GPKG layer

        else:
            # SHP: separate file per geometry type
            base_shp = output_path if output_path.lower().endswith('.shp') \
                else output_path + '.shp'

            for label, feat_list in groups.items():
                out_path = base_shp if len(groups) == 1 else \
                    os.path.splitext(base_shp)[0] + f'_{label}.shp'

                sample_geom = feat_list[0][0].geometry()
                wkb_type = sample_geom.wkbType()
                layer_name = os.path.splitext(os.path.basename(out_path))[0]
                count = self._write_layer(
                    out_path, layer_name, new_fields, wkb_type, crs,
                    feat_list, all_fields['original'],
                    driver='ESRI Shapefile',
                    create_new=True)
                total_written += count
                results.append(f"{label}: {count}")
                output_paths.append((out_path, layer_name))  # track SHP file

        ext = '.gpkg' if use_gpkg else '.shp'
        summary = f"OK — {total_written} features → {ext}"
        if len(groups) > 1:
            summary += f" ({', '.join(results)})"
        return True, summary, output_paths

    # ------------------------------------------------------------------
    # Feature loading
    # ------------------------------------------------------------------

    def _load_all_features(self, kml_path, use_gpkg=False):
        """Load all features from all sub-layers in a KML/KMZ.

        Two-track attribute loading:
        - GEOMETRY: via QGIS OGR (accurate CRS, coordinate handling)
        - ATTRIBUTES from <description> CDATA HTML: via xml.etree
          (OGR does NOT reliably expose CDATA content in its feature attributes)

        The two tracks are matched by Placemark <name>.

        Returns (feature_list, field_info, wkb_type) or (None, None, None).
        feature_list = [(QgsFeature, parsed_desc_dict), ...]
        """
        # --- Track A: XML attribute loading (fast, reliable CDATA support) ---
        xml_attrs, html_fields_ordered = self._xml_load_all_attrs(
            kml_path, use_gpkg=use_gpkg)

        # --- Track B: QGIS OGR geometry loading ---
        uris = self._get_layer_uris(kml_path)

        all_feats = []
        original_fields = None
        has_plain_desc = False
        wkb_type = None

        for uri in uris:
            layer = QgsVectorLayer(uri, "kml_temp", "ogr")
            if not layer.isValid():
                continue

            if original_fields is None:
                original_fields = layer.fields()
                wkb_type = layer.wkbType()

            for feat in layer.getFeatures():
                # Match feature to its XML attributes via the Name field
                feat_name = self._get_feat_name(feat)
                parsed = xml_attrs.get(feat_name, {}) if feat_name else {}

                # Fallback: try OGR description if XML gave nothing
                if not parsed:
                    desc = self._get_description(feat)
                    if desc:
                        html_matches = self._parse_html_table(desc)
                        if html_matches:
                            seen = set()
                            for key_raw, val_raw in html_matches:
                                if self._is_header_content(key_raw):
                                    continue
                                ck = self._make_field_name(
                                    key_raw, use_gpkg=use_gpkg,
                                    existing_names=seen)
                                if not ck:
                                    continue
                                cv = re.sub(r'<.*?>', '', val_raw).strip()
                                parsed[ck] = cv
                                seen.add(ck)
                                if ck not in html_fields_ordered:
                                    html_fields_ordered[ck] = cv[:50]
                        else:
                            has_plain_desc = True
                            parsed['Desc'] = re.sub(r'<.*?>', '', desc).strip()[:254]

                all_feats.append((feat, parsed))

            del layer

        if original_fields is None:
            return None, None, None

        has_html_table = bool(html_fields_ordered)
        field_info = {
            'original': original_fields,
            'html_fields': set(html_fields_ordered.keys()),
            'html_fields_ordered': html_fields_ordered,
            'has_plain': has_plain_desc and not has_html_table,
        }
        return all_feats, field_info, wkb_type

    @staticmethod
    def _get_feat_name(feat):
        """Safely get the Name attribute from an OGR/QGIS feature."""
        field_names = feat.fields().names()
        for fname in ['Name', 'name', 'NAME']:
            if fname in field_names:
                val = feat[fname]
                if val is not None and str(val).strip():
                    return str(val).strip()
        return None

    def _xml_load_all_attrs(self, kml_path, use_gpkg=False):
        """Load all attribute data from KML/KMZ using xml.etree.

        This is the RELIABLE way to read <description><![CDATA[...html...]]></description>
        because OGR/GDAL does NOT consistently expose CDATA content via its
        feature attribute API.

        Returns:
            (attrs_by_name, html_fields_ordered)
            attrs_by_name:      {placemark_name: {field_name: value}}
            html_fields_ordered:{field_name: sample_value}  (insertion-ordered schema)
        """
        attrs_by_name = {}       # {pm_name: {field: value}}
        html_fields_ordered = {}  # schema (field_name -> sample)
        used_schema = set()      # for schema deduplication

        try:
            if kml_path.lower().endswith('.kmz'):
                with zipfile.ZipFile(kml_path, 'r') as zf:
                    kml_names = [n for n in zf.namelist()
                                 if n.lower().endswith('.kml')]
                    if not kml_names:
                        return attrs_by_name, html_fields_ordered
                    kml_bytes = zf.read(kml_names[0])
                root = ET.fromstring(kml_bytes)  # nosec B314
            else:
                tree = ET.parse(kml_path)  # nosec B314
                root = tree.getroot()

            ns_prefix = root.tag.rsplit('}', 1)[0] + '}' if '}' in root.tag else ''
            name_tag = f'{ns_prefix}name'
            desc_tag = f'{ns_prefix}description'
            pm_tag = f'{ns_prefix}Placemark'

            for pm in root.iter(pm_tag):
                name_elem = pm.find(name_tag)
                desc_elem = pm.find(desc_tag)

                pm_name = None
                if name_elem is not None and name_elem.text:
                    pm_name = name_elem.text.strip()

                if desc_elem is None or not (desc_elem.text or '').strip():
                    continue

                desc_text = desc_elem.text.strip()
                matches = self._parse_html_table(desc_text)
                if not matches:
                    continue

                attrs = {}
                seen_in_feat = set()
                for key_raw, val_raw in matches:
                    if self._is_header_content(key_raw):
                        continue
                    # Per-feature key: dedup only within this feature
                    ck = self._make_field_name(
                        key_raw, use_gpkg=use_gpkg,
                        existing_names=seen_in_feat)
                    if not ck:
                        continue
                    cv = re.sub(r'<.*?>', '', val_raw).strip()
                    attrs[ck] = cv
                    seen_in_feat.add(ck)
                    # Schema: add field if not already known
                    if ck.lower() not in {k.lower() for k in used_schema}:
                        html_fields_ordered[ck] = cv[:50]
                        used_schema.add(ck)
                    elif ck not in html_fields_ordered and cv:
                        # Fill missing sample
                        html_fields_ordered[ck] = cv[:50]

                if attrs:
                    if pm_name:
                        attrs_by_name[pm_name] = attrs
                    # Also store by index as fallback (unnamed placemarks)
                    attrs_by_name[f'__idx_{len(attrs_by_name)}'] = attrs

        except Exception:  # nosec B110
            pass  # nosec B110

        return attrs_by_name, html_fields_ordered

    def _get_layer_uris(self, kml_path):
        """Get all valid layer URIs from KML/KMZ."""
        test = QgsVectorLayer(kml_path, "test", "ogr")
        if not test.isValid():
            return []

        sub_list = test.dataProvider().subLayers()
        del test

        if not sub_list:
            return self._try_geometry_split(kml_path)

        sample = sub_list[0]
        if '!!::!!' in sample:
            sep = '!!::!!'
        elif ':::' in sample:
            sep = ':::'
        else:
            sep = ':'

        uris = []
        for sl in sub_list:
            parts = sl.split(sep)
            if len(parts) < 2:
                continue
            layer_name = parts[1].strip()
            geom_type = parts[3].strip() if len(parts) > 3 else ''
            uri = f"{kml_path}|layername={layer_name}"
            if geom_type and geom_type.lower() not in ('unknown', 'none', ''):
                uri += f"|geometrytype={geom_type}"
            if uri not in uris:
                uris.append(uri)

        return uris if uris else self._try_geometry_split(kml_path)

    def _try_geometry_split(self, kml_path):
        """Try loading KML with explicit geometry type filters."""
        uris = []
        for gt in ('Point', 'LineString', 'Polygon',
                   'MultiPoint', 'MultiLineString', 'MultiPolygon'):
            uri = f"{kml_path}|geometrytype={gt}"
            test = QgsVectorLayer(uri, "test", "ogr")
            if test.isValid() and test.featureCount() > 0:
                uris.append(uri)
            del test
        return uris if uris else [kml_path]

    # ------------------------------------------------------------------
    # Field building
    # ------------------------------------------------------------------

    def _build_fields(self, field_info, all_features, selected_fields,
                      use_gpkg=False):
        """Build the output QgsFields based on scanned data."""
        new_fields = QgsFields()
        original_fields = field_info['original']

        remove_lower = [
            'description', 'timestamp', 'begin', 'end',
            'altitudemode', 'tessellate', 'extrude',
            'visibility', 'draworder', 'icon']

        for f in original_fields:
            if f.name().lower() not in remove_lower:
                new_fields.append(f)

        if field_info['has_plain']:
            if 'Desc' not in [f.name() for f in new_fields]:
                new_fields.append(QgsField('Desc', QVariant.String, len=254))

        # Add HTML-parsed fields in discovery order
        existing_names = [f.name().lower() for f in new_fields]
        max_len = 63 if use_gpkg else 254  # GPKG col names max 63; SHP field len
        for fname in field_info.get('html_fields_ordered', field_info['html_fields']):
            if fname.lower() not in existing_names:
                new_fields.append(QgsField(fname, QVariant.String, len=max_len))
                existing_names.append(fname.lower())

        # Filter by selected_fields
        if selected_fields is not None:
            sel_lower = [s.lower() for s in selected_fields]
            filtered = QgsFields()
            for i in range(new_fields.count()):
                f = new_fields.at(i)
                if f.name().lower() in sel_lower:
                    filtered.append(f)
            new_fields = filtered

        return new_fields

    # ------------------------------------------------------------------
    # Writing
    # ------------------------------------------------------------------

    def _write_layer(self, output_path, layer_name, fields, wkb_type, crs,
                     feat_list, original_fields,
                     driver='ESRI Shapefile', create_new=True):
        """Write a list of features to a Shapefile or GPKG layer.

        KML features are always in WGS84 (EPSG:4326).  When the requested
        output CRS differs, each geometry is reprojected using QGIS's project
        transform context (identical datum-grid pipeline to native QGIS
        Export Layer).
        """
        options = QgsVectorFileWriter.SaveVectorOptions()
        options.driverName = driver
        options.fileEncoding = 'UTF-8'
        options.layerName = layer_name

        # For GPKG: append layers after the first
        if not create_new and driver == 'GPKG':
            options.actionOnExistingFile = \
                QgsVectorFileWriter.ActionOnExistingFile.CreateOrOverwriteLayer

        writer = QgsVectorFileWriter.create(
            output_path, fields, wkb_type, crs,
            QgsProject.instance().transformContext(), options)

        if writer.hasError():
            del writer
            return 0

        # ── Build CRS transform: KML is always WGS84 → target CRS ──
        kml_crs = QgsCoordinateReferenceSystem('EPSG:4326')
        transform = None
        if crs.isValid() and crs != kml_crs:
            transform = QgsCoordinateTransform(
                kml_crs, crs,
                QgsProject.instance().transformContext())

        remove_lower = [
            'description', 'timestamp', 'begin', 'end',
            'altitudemode', 'tessellate', 'extrude',
            'visibility', 'draworder', 'icon']

        written = 0
        for feat, parsed_attrs in feat_list:
            geom = feat.geometry()
            if geom is None or geom.isEmpty():
                continue

            # ── Reproject geometry when target CRS ≠ WGS84 ──
            if transform is not None:
                geom = QgsGeometry(geom)   # deep copy — never modify original
                try:
                    geom.transform(transform)
                except (RuntimeError, AttributeError):
                    continue  # nosec B112 — skip features that fail to reproject

            new_feat = QgsFeature(fields)
            new_feat.setGeometry(geom)

            # Copy original OGR attributes
            for i in range(original_fields.count()):
                fname = original_fields.at(i).name()
                if fname.lower() not in remove_lower:
                    idx = fields.indexFromName(fname)
                    if idx != -1:
                        try:
                            new_feat.setAttribute(idx, feat.attributes()[i])
                        except (IndexError, KeyError):
                            pass  # nosec B110

            # Set HTML-parsed attributes
            for key, val in parsed_attrs.items():
                idx = fields.indexFromName(key)
                if idx != -1:
                    new_feat.setAttribute(idx, val)

            writer.addFeature(new_feat)
            written += 1

        del writer
        return written

    # Backwards compatibility alias
    def _write_shp(self, output_path, fields, wkb_type, crs,
                   feat_list, original_fields):
        layer_name = os.path.splitext(os.path.basename(output_path))[0]
        return self._write_layer(
            output_path, layer_name, fields, wkb_type, crs,
            feat_list, original_fields,
            driver='ESRI Shapefile', create_new=True)

    # ------------------------------------------------------------------
    # Utilities (public — used by dialog._scan_kml_fields)
    # ------------------------------------------------------------------

    @staticmethod
    def _get_description(feat):
        """Safely get description from a feature."""
        field_names = feat.fields().names()
        for name in ['description', 'Description']:
            if name in field_names:
                val = feat[name]
                if val and str(val).strip():
                    return str(val).strip()
        return ""

    @staticmethod
    def _parse_html_table(html):
        """Extract (key, value) pairs from HTML <td>key</td><td>val</td>."""
        return re.findall(
            r'<td[^>]*>(.*?)</td>\s*<td[^>]*>(.*?)</td>',
            html, re.IGNORECASE | re.DOTALL)

    @staticmethod
    def _is_header_content(raw_text):
        """Return True if the raw cell text looks like a section header."""
        clean = re.sub(r'<.*?>', '', raw_text).strip()
        if not clean:
            return True
        ascii_chars = sum(1 for c in clean if ord(c) < 128 and c not in ' \t\n\r')
        total_chars = sum(1 for c in clean if c not in ' \t\n\r')
        if total_chars > 0 and ascii_chars / total_chars < 0.3 and len(clean) < 20:
            ascii_key = re.sub(r'[^a-zA-Z0-9_]', '_', clean)
            ascii_key = re.sub(r'_+', '_', ascii_key).strip('_')
            if len(ascii_key) < 4 or ascii_key.count('_') / max(len(ascii_key), 1) > 0.6:
                return True
        return False

    @staticmethod
    def _make_field_name(raw_name, use_gpkg=False, existing_names=None):
        """Generate a field name appropriate for the target format.

        SHP:  10-char ASCII only (replaces Vietnamese with _), deduplicated
        GPKG: 63-char Unicode-friendly (strips HTML + control chars only)
        """
        # Strip HTML tags
        clean = re.sub(r'<.*?>', '', raw_name).strip()

        if use_gpkg:
            # GPKG: keep Unicode, remove only chars problematic for SQLite identifiers
            # Allow letters (any language), digits, spaces, underscores, hyphens
            clean = re.sub(r'[\x00-\x1f\x7f"\'\\]', '', clean)
            # Replace forward/back slash and other illegal chars with space
            clean = re.sub(r'[/\\|:*?<>]', ' ', clean)
            # Collapse multiple spaces
            clean = re.sub(r'\s+', ' ', clean).strip()
            candidate = clean[:63] if clean else ''
        else:
            # SHP: 10-char ASCII (replace non-ASCII with _)
            clean = re.sub(r'[^a-zA-Z0-9_]', '_', clean)
            clean = re.sub(r'_+', '_', clean).strip('_')
            candidate = clean[:10] if clean else ''

        if not candidate:
            return ''

        # Deduplicate
        if existing_names is not None:
            existing_lower = {n.lower() for n in existing_names}
            if candidate.lower() in existing_lower:
                if use_gpkg:
                    base = candidate[:60]
                    suffix = 2
                    while True:
                        c = f"{base} {suffix}"[:63]
                        if c.lower() not in existing_lower:
                            candidate = c
                            break
                        suffix += 1
                else:
                    base = clean[:8]
                    suffix = 2
                    while True:
                        c = f"{base[:8]}{suffix}"[:10]
                        if c.lower() not in existing_lower:
                            candidate = c
                            break
                        suffix += 1

        return candidate

    # Keep old name for backwards compatibility
    @staticmethod
    def _clean_field_name(name, existing_names=None):
        return KmlToShpConverter._make_field_name(
            name, use_gpkg=False, existing_names=existing_names)

    def discover_fields(self, kml_path, use_gpkg=False):
        """Scan KML/KMZ fields without blocking the UI.

        Two-phase strategy to avoid freezing on large files:

        Phase 1 — Fast XML scan (xml.etree, no QGIS):
            Parse <description> CDATA HTML tables across the whole file.
            Runs in ~100-300ms even for 10,000+ features because it
            only reads text nodes, no geometry processing.

        Phase 2 — Quick QGIS scan (max 20 features):
            OGR field schema, geometry types, sample values.
            layer.featureCount() is instant (metadata only).

        Returns dict: {
            'fields': {field_name: sample_value, ...},
            'total_features': int,
            'geom_types': set of labels,
            'sub_layer_count': int,
        }
        """
        if not os.path.exists(kml_path):
            return None

        # --- Phase 1: Fast XML scan for HTML table fields (no blocking) ---
        html_fields, _used = self._fast_xml_scan(kml_path, use_gpkg=use_gpkg)

        # --- Phase 2: Quick QGIS scan (first 20 features only) ---
        uris = self._get_layer_uris(kml_path)
        if not uris:
            return None

        all_fields = {}
        total = 0
        geom_types = set()

        remove_lower = [
            'description', 'timestamp', 'begin', 'end',
            'altitudemode', 'tessellate', 'extrude',
            'visibility', 'draworder', 'icon']

        for uri in uris:
            layer = QgsVectorLayer(uri, "scan", "ogr")
            if not layer.isValid():
                continue
            total += layer.featureCount()  # instant — reads metadata

            if '|geometrytype=' in uri:
                gt = uri.split('|geometrytype=')[-1].lower()
                if 'point' in gt:
                    geom_types.add('point')
                elif 'line' in gt:
                    geom_types.add('line')
                elif 'polygon' in gt:
                    geom_types.add('polygon')

            # OGR field schema (no feature iteration needed)
            for f in layer.fields():
                if f.name().lower() not in remove_lower:
                    if f.name() not in all_fields:
                        all_fields[f.name()] = ""

            # Sample values + geometry detection: first 20 features ONLY
            for i, feat in enumerate(layer.getFeatures()):
                if i >= 20:
                    break

                geom = feat.geometry()
                if geom and not geom.isEmpty():
                    gt = geom.type()
                    if gt == 0:
                        geom_types.add('point')
                    elif gt == 1:
                        geom_types.add('line')
                    elif gt == 2:
                        geom_types.add('polygon')

                for fname in list(all_fields.keys()):
                    if not all_fields[fname]:
                        try:
                            val = feat[fname]
                            if val is not None and str(val).strip():
                                all_fields[fname] = str(val)[:50]
                        except Exception:  # nosec B110
                            pass  # nosec B110

            del layer

        # Merge HTML fields from fast XML scan (preserve order)
        existing_lower = {k.lower() for k in all_fields}
        for fname, sample in html_fields.items():
            if fname.lower() not in existing_lower:
                all_fields[fname] = sample
                existing_lower.add(fname.lower())

        return {
            'fields': all_fields,
            'total_features': total,
            'geom_types': geom_types,
            'sub_layer_count': len(uris),
        }

    def _fast_xml_scan(self, kml_path, use_gpkg=False):
        """Fast scan using xml.etree to find <description> HTML table fields.

        No QGIS dependency. Runs in ~100-300ms even for 10,000+ features
        because it only processes text content, not geometry.

        Handles:
        - KML (plain XML)
        - KMZ (ZIP containing doc.kml)
        - CDATA sections (ElementTree returns the text content directly)
        - Any KML namespace

        Returns (ordered_fields_dict, used_names_set)
        """
        fields = {}
        used = set()

        try:
            # KMZ: unzip and read the embedded KML
            if kml_path.lower().endswith('.kmz'):
                with zipfile.ZipFile(kml_path, 'r') as zf:
                    kml_names = [n for n in zf.namelist()
                                 if n.lower().endswith('.kml')]
                    if not kml_names:
                        return fields, used
                    kml_bytes = zf.read(kml_names[0])
                root = ET.fromstring(kml_bytes)  # nosec B314
            else:
                tree = ET.parse(kml_path)  # nosec B314
                root = tree.getroot()

            # Build description tag name (handle any KML namespace)
            ns_prefix = ''
            if '}' in root.tag:
                ns_prefix = root.tag.rsplit('}', 1)[0] + '}'
            desc_tag = f'{ns_prefix}description'

            for desc_elem in root.iter(desc_tag):
                text = desc_elem.text or ''
                if not text.strip():
                    continue

                matches = self._parse_html_table(text)
                if not matches:
                    continue

                seen_in_feat = set()
                for key_raw, val_raw in matches:
                    if self._is_header_content(key_raw):
                        continue
                    ck = self._make_field_name(
                        key_raw,
                        use_gpkg=use_gpkg,
                        existing_names=used | seen_in_feat)
                    if not ck:
                        continue
                    cv = re.sub(r'<.*?>', '', val_raw).strip()
                    seen_in_feat.add(ck)
                    if ck not in fields:
                        fields[ck] = cv[:50]
                        used.add(ck)
                    elif not fields[ck] and cv:
                        fields[ck] = cv[:50]

                # Early stop: once schema is fully populated with samples,
                # no need to scan remaining features (same schema throughout)
                if fields and all(fields.values()):
                    break

        except (ET.ParseError, zipfile.BadZipFile, OSError, TypeError):
            pass  # nosec B110 — Fall back to empty — QGIS scan will handle OGR fields

        return fields, used
