"""
Export Handler for FilterEngineTask

Handles all export operations including standard, batch, streaming, and ZIP modes.
Extracted from FilterEngineTask as part of the C1 God Object decomposition (Phase 3).

This handler manages:
- Export parameter validation
- Standard single/multi-layer export (GPKG, SHP, GeoJSON, etc.)
- Batch export to folder or ZIP
- Streaming export for large datasets
- Style export (QML, SLD, LYRX)

Location: core/tasks/export_handler.py (Hexagonal Architecture - Application Layer)

Thread Safety:
    All methods are designed to be called from worker threads. They do not
    access QGIS UI elements directly. Progress and description updates are
    communicated via callback functions.
"""

import logging
import os
from typing import Any, Callable, Dict, List, Optional

from ...infrastructure.logging import setup_logger
from ...infrastructure.streaming import StreamingExporter, StreamingConfig
from ...config.config import ENV_VARS

# Setup logger
logger = setup_logger(
    'FilterMate.Tasks.Export',
    os.path.join(ENV_VARS.get("PATH_ABSOLUTE_PROJECT", "."), 'logs', 'filtermate_tasks.log'),
    level=logging.INFO
)


class ExportHandler:
    """Handles all export operations for FilterEngineTask.

    Encapsulates export logic previously embedded in FilterEngineTask,
    receiving all dependencies explicitly via method parameters.

    Example:
        >>> handler = ExportHandler()
        >>> result = handler.execute_exporting(
        ...     task_parameters=params,
        ...     project=QgsProject.instance(),
        ...     set_progress=task.setProgress,
        ...     set_description=task.setDescription,
        ...     is_canceled=task.isCanceled,
        ... )
    """

    def __init__(self):
        # Warnings collected during the most recent execute_exporting() call.
        # Caller (FilterEngineTask) drains this into self.warning_messages so
        # FinishedHandler surfaces them via iface.messageBar() — without this,
        # SHP DBF pre-flight warnings (B3) and per-layer batch failures only
        # reached the log file.
        self._last_warnings: List[str] = []

    def _collect_warnings(self, result: Any) -> None:
        """Extract user-relevant warnings from an export result object.

        Handles both ``ExportResult`` (with .warnings: List[str]) and
        ``BatchExportResult`` (with .failed_layers: List[str] and
        .skipped_layers: List[str]).
        """
        warnings = getattr(result, 'warnings', None)
        if warnings:
            self._last_warnings.extend(str(w) for w in warnings)
        failed = getattr(result, 'failed_layers', None)
        if failed:
            self._last_warnings.extend(f"Failed: {f}" for f in failed)
        skipped = getattr(result, 'skipped_layers', None)
        if skipped:
            self._last_warnings.extend(f"Skipped: {s}" for s in skipped)

    def validate_export_parameters(
        self,
        task_parameters: Dict[str, Any],
    ) -> Optional[Dict[str, Any]]:
        """Validate and extract export parameters from task configuration.

        Args:
            task_parameters: Task configuration dict

        Returns:
            dict: Export configuration or None if validation fails
        """
        from ..export import validate_export_parameters

        result = validate_export_parameters(task_parameters, ENV_VARS)
        if result.valid:
            return {
                'layers': result.layers,
                'projection': result.projection,
                'styles': result.styles,
                'datatype': result.datatype,
                'output_folder': result.output_folder,
                'zip_path': result.zip_path,
                'batch_output_folder': result.batch_output_folder,
                'batch_zip': result.batch_zip,
                'preserve_groups': result.preserve_groups,
            }
        else:
            logger.error(result.error_message)
            return None

    def get_layer_by_name(self, project: Any, layer_name: str) -> Optional[Any]:
        """Get layer object from project by name.

        Args:
            project: QgsProject instance
            layer_name: Name of the layer to find

        Returns:
            QgsVectorLayer or None if not found
        """
        layers_found = project.mapLayersByName(layer_name)
        if layers_found:
            return layers_found[0]
        logger.warning(f"Layer '{layer_name}' not found in project")
        return None

    def save_layer_style(
        self,
        layer: Any,
        output_path: str,
        style_format: str,
        datatype: str,
    ) -> None:
        """Save layer style in specified format.

        Args:
            layer: QgsVectorLayer
            output_path: Output file path
            style_format: Style format (QML, SLD, etc.)
            datatype: Export format
        """
        from ..export import save_layer_style
        save_layer_style(layer, output_path, style_format, datatype)

    def save_layer_style_lyrx(self, layer: Any, output_path: str) -> None:
        """Save layer style in LYRX format.

        Args:
            layer: QgsVectorLayer
            output_path: Output file path
        """
        from ..export.style_exporter import StyleExporter, StyleFormat
        exporter = StyleExporter()
        exporter.save_style(layer, output_path, StyleFormat.LYRX)

    def calculate_total_features(self, layers: List, project: Any) -> int:
        """Calculate total feature count across all layers.

        Args:
            layers: List of layer info dicts or layer names
            project: QgsProject instance

        Returns:
            int: Total feature count
        """
        total = 0
        for layer_info in layers:
            layer_name = layer_info['layer_name'] if isinstance(layer_info, dict) else layer_info
            layer = self.get_layer_by_name(project, layer_name)
            if layer:
                total += layer.featureCount()
        return total

    def execute_exporting(
        self,
        task_parameters: Dict[str, Any],
        project: Any,
        set_progress: Callable[[float], None],
        set_description: Callable[[str], None],
        is_canceled: Callable[[], bool],
    ) -> tuple:
        """Export selected layers to various formats.

        Supports multiple export modes:
        - Standard: Single file with all layers (GPKG, GeoJSON)
        - Batch folder: One file per layer in output folder
        - Batch ZIP: One ZIP archive per layer
        - Streaming: Memory-efficient export for large datasets

        Args:
            task_parameters: Task configuration dict
            project: QgsProject instance
            set_progress: Callback to update progress (0-100)
            set_description: Callback to update task description
            is_canceled: Callback to check if task was canceled

        Returns:
            tuple: (success: bool, message: str, error_details: Optional[str])
        """
        # Reset per-run warning collector. Caller drains _last_warnings into
        # self.warning_messages so FinishedHandler displays them.
        self._last_warnings = []

        # Validate parameters
        export_config = self.validate_export_parameters(task_parameters)
        if not export_config:
            return False, 'Export configuration validation failed', None

        layers = export_config['layers']
        projection = export_config['projection']
        datatype = export_config['datatype']
        output_folder = export_config['output_folder']
        style_format = export_config['styles']
        zip_path = export_config['zip_path']
        batch_output_folder = export_config.get('batch_output_folder', False)
        batch_zip = export_config.get('batch_zip', False)
        save_styles = task_parameters["task"]['EXPORTING'].get("HAS_STYLES_TO_EXPORT", False)

        # Initialize exporters
        from ..export import BatchExporter, LayerExporter, sanitize_filename
        batch_exporter = BatchExporter(project=project)
        layer_exporter = LayerExporter(project=project)

        # Inject cancel check
        batch_exporter.is_canceled = is_canceled

        def progress_callback(percent):
            set_progress(percent)

        def description_callback(desc):
            set_description(desc)

        # BATCH MODE: One file per layer in folder
        if batch_output_folder:
            logger.info("Batch output folder mode enabled - delegating to BatchExporter")
            result = batch_exporter.export_to_folder(
                layers, output_folder, datatype,
                projection=projection,
                style_format=style_format,
                save_styles=save_styles,
                progress_callback=progress_callback,
                description_callback=description_callback
            )
            self._collect_warnings(result)
            if result.success:
                message = f'Batch export: {result.exported_count} layer(s) exported to <a href="file:///{output_folder}">{output_folder}</a>'
            else:
                message = f'Batch export completed with errors:\n{result.get_summary()}'
            return result.success, message, getattr(result, 'error_details', None)

        # BATCH MODE: One ZIP per layer
        if batch_zip:
            logger.info("Batch ZIP mode enabled - delegating to BatchExporter")
            result = batch_exporter.export_to_zip(
                layers, output_folder, datatype,
                projection=projection,
                style_format=style_format,
                save_styles=save_styles,
                progress_callback=progress_callback,
                description_callback=description_callback
            )
            self._collect_warnings(result)
            if result.success:
                message = f'Batch ZIP export: {result.exported_count} ZIP file(s) created in <a href="file:///{output_folder}">{output_folder}</a>'
            else:
                message = f'Batch ZIP export completed with errors:\n{result.get_summary()}'
            return result.success, message, getattr(result, 'error_details', None)

        # GPKG STANDARD MODE
        if datatype == 'GPKG':
            return self._export_gpkg(
                layers, output_folder, save_styles, zip_path,
                project, layer_exporter, batch_exporter, sanitize_filename,
                preserve_groups=export_config.get('preserve_groups', False),
                export_crs=projection,
            )

        # Detect file-path vs directory-path output. The dialog returns a full
        # file path for single-layer export when a datatype is chosen, but a
        # directory path otherwise.
        from ..export.layer_exporter import get_extension_for_format
        ext = get_extension_for_format(datatype)
        output_is_file = (
            len(layers) == 1
            and os.path.splitext(output_folder)[1] != ''
            and not os.path.isdir(output_folder)
        )

        # STREAMING MODE: For large datasets (non-GPKG, directory output only).
        # Streaming writes one file per layer into a directory, so skip it when
        # the user selected a single target file.
        streaming_config = task_parameters.get('config', {}).get('APP', {}).get('OPTIONS', {}).get('STREAMING_EXPORT', {})
        streaming_enabled = streaming_config.get('enabled', {}).get('value', True)
        feature_threshold = streaming_config.get('feature_threshold', {}).get('value', 10000)
        chunk_size = streaming_config.get('chunk_size', {}).get('value', 5000)

        if streaming_enabled and not output_is_file:
            total_features = self.calculate_total_features(layers, project)
            if total_features >= feature_threshold:
                logger.info(f"Using STREAMING export mode ({total_features} features >= {feature_threshold} threshold)")
                success, message = self._export_with_streaming(
                    layers, output_folder, projection, datatype,
                    style_format, save_styles, chunk_size,
                    project, set_progress, set_description, is_canceled
                )
                if success and zip_path:
                    if BatchExporter.create_zip_archive(zip_path, output_folder):
                        message += f' and Zip file has been exported to <a href="file:///{zip_path}">{zip_path}</a>'
                return success, message, None

        # STANDARD MODE: Single or multiple layers

        if output_is_file:
            parent_dir = os.path.dirname(output_folder)
            if parent_dir and not os.path.exists(parent_dir):
                try:
                    os.makedirs(parent_dir, exist_ok=True)
                except OSError as e:
                    logger.error(f"Failed to create output directory: {e}")
                    return False, f'Failed to create output directory: {parent_dir}', None
        elif not os.path.exists(output_folder):
            return False, f'Output path does not exist: {output_folder}', None

        export_success = False
        message = ''
        single_output = None

        if len(layers) == 1:
            layer_name = layers[0]['layer_name'] if isinstance(layers[0], dict) else layers[0]
            logger.info(f"Single layer export - delegating to LayerExporter: {layer_name}")
            if output_is_file:
                # User picked full file path via save-file dialog — use as-is,
                # ensuring the extension matches the selected datatype.
                single_output = output_folder
                if os.path.splitext(single_output)[1].lower() != ext.lower():
                    single_output = os.path.splitext(single_output)[0] + ext
            else:
                single_output = os.path.join(
                    output_folder, f"{sanitize_filename(layer_name)}{ext}"
                )
            result = layer_exporter.export_single_layer(
                layer_name, single_output, projection, datatype, style_format, save_styles
            )
            self._collect_warnings(result)
            export_success = result.success
            if not result.success:
                message = result.error_message or 'Export failed'

        elif os.path.isdir(output_folder):
            logger.info(f"Multiple layers export - delegating to LayerExporter: {len(layers)} layers")
            from ..export import ExportConfig
            result = layer_exporter.export_multiple_to_directory(
                ExportConfig(
                    layers=layers,
                    output_path=output_folder,
                    datatype=datatype,
                    projection=projection,
                    style_format=style_format,
                    save_styles=save_styles
                )
            )
            self._collect_warnings(result)
            export_success = result.success
            if not result.success:
                message = result.error_message or 'Export failed'
        else:
            return False, f'Invalid export configuration: {len(layers)} layers but output is not a directory', None

        if not export_success:
            return False, message, None

        # NOTE: a previous post-write ``if is_canceled(): return False, 'Export
        # cancelled by user'`` lived here, but it was misleading — by this
        # point ``writeAsVectorFormatV3`` has already completed and the file
        # is on disk. The standard non-streaming LayerExporter doesn't accept
        # a cancel callback, so cancel can't actually abort the write. Returning
        # "Export cancelled" while the file exists confused users and skipped
        # downstream zip / KML-merge steps. Cancel handling for in-progress
        # writes lives in the streaming and batch paths (which DO check
        # cancel between batches/layers); after a successful synchronous
        # write, the right behavior is to report success.

        # Resolve the reported output location.
        reported_output = single_output if output_is_file else output_folder

        # Create zip archive if requested.
        # File-output mode zips ONLY the exported file + its sidecars (same
        # basename) — zipping the parent dir would leak unrelated user files
        # into the archive.
        zip_created = False
        if zip_path:
            if output_is_file:
                zip_created = BatchExporter.create_zip_archive_for_file(
                    zip_path, single_output
                )
            else:
                zip_created = BatchExporter.create_zip_archive(
                    zip_path, output_folder
                )
            if not zip_created:
                return False, 'Failed to create ZIP archive', None

        # Store deferred KML folder merge for main thread execution (in finished()).
        # Group merging only makes sense when multiple KML files live in a folder;
        # skip it for single-file output (no siblings to merge).
        # Accept both OGR drivers that produce KML output ('KML' and 'LIBKML').
        preserve_groups = export_config.get('preserve_groups', False)
        if (
            preserve_groups
            and datatype.upper() in ('KML', 'LIBKML')
            and not output_is_file
        ):
            layer_ids = []
            for l in layers:
                if isinstance(l, dict):
                    layer_ids.append(l.get('layer_id', ''))
                else:
                    layer_ids.append(str(l))
            self._pending_kml_merge = {
                'output_folder': output_folder,
                'layer_ids': layer_ids,
                'project_title': project.title() or 'FilterMate Export',
            }
            logger.info("KML folder merge deferred to main thread (finished callback)")

        message = f'Layer(s) has been exported to <a href="file:///{reported_output}">{reported_output}</a>'
        if zip_created:
            message += f' and Zip file has been exported to <a href="file:///{zip_path}">{zip_path}</a>'

        logger.info("Export completed successfully")
        return True, message, None

    def _export_gpkg(
        self,
        layers: List,
        output_folder: str,
        save_styles: bool,
        zip_path: Optional[str],
        project: Any,
        layer_exporter: Any,
        batch_exporter_class: Any,
        sanitize_filename_fn: Callable,
        preserve_groups: bool = False,
        export_crs: Any = None,
    ) -> tuple:
        """Handle GPKG export mode.

        Args:
            layers: List of layer info dicts or layer names
            output_folder: Output directory or .gpkg file path
            save_styles: Whether to save styles
            zip_path: Optional ZIP archive path
            project: QgsProject instance
            layer_exporter: LayerExporter instance
            batch_exporter_class: BatchExporter class (for create_zip_archive)
            sanitize_filename_fn: Function to sanitize filenames
            preserve_groups: Whether to preserve layer group structure in GPKG
            export_crs: Optional QgsCoordinateReferenceSystem for reprojection

        Returns:
            tuple: (success: bool, message: str, error_details: Optional[str])
        """
        # Distinguish file-output from directory-output. Prefer the existing
        # filesystem state when available (handles paths like ``foo.gpkg.tmp``
        # — non-.gpkg suffix that the user clearly meant as a file path),
        # then fall back to suffix detection for paths that don't exist yet.
        if os.path.isdir(output_folder):
            output_is_file = False
        elif os.path.isfile(output_folder):
            output_is_file = True
        else:
            output_is_file = output_folder.lower().endswith('.gpkg')

        if output_is_file:
            gpkg_output_path = output_folder
            gpkg_dir = os.path.dirname(gpkg_output_path)
            if gpkg_dir and not os.path.exists(gpkg_dir):
                try:
                    os.makedirs(gpkg_dir)
                    logger.info(f"Created output directory: {gpkg_dir}")
                except OSError as e:
                    logger.error(f"Failed to create output directory: {e}")
                    return False, f'Failed to create output directory: {gpkg_dir}', None
        else:
            if not os.path.exists(output_folder):
                try:
                    os.makedirs(output_folder)
                except OSError as e:
                    logger.debug(f"Ignored in makedirs for export output: {e}")
                    return False, f'Failed to create output directory: {output_folder}', None

            project_title = project.title() if project.title() else None
            project_basename = project.baseName() if project.baseName() else None
            default_name = project_title or project_basename or 'export'
            default_name = sanitize_filename_fn(default_name)
            gpkg_output_path = os.path.join(output_folder, f"{default_name}.gpkg")

        logger.info(f"GPKG export - delegating to LayerExporter: {gpkg_output_path}")
        result = layer_exporter.export_to_gpkg(
            layers, gpkg_output_path, save_styles, target_crs=export_crs
        )

        if not result.success:
            return False, result.error_message or 'GPKG export failed', None

        # Store deferred layer tree write for main thread execution (in finished())
        if preserve_groups:
            layer_ids = []
            for l in layers:
                if isinstance(l, dict):
                    layer_ids.append(l.get('layer_id', ''))
                else:
                    layer_ids.append(str(l))
            self._pending_layer_tree_write = {
                'gpkg_path': gpkg_output_path,
                'layer_ids': layer_ids,
                'project_title': project.title() or 'FilterMate Export',
                'export_crs_authid': export_crs.authid() if export_crs and export_crs.isValid() else None,
                'save_styles': save_styles,
            }
            logger.info("Layer tree write deferred to main thread (finished callback)")

        message = f'Layer(s) exported to <a href="file:///{gpkg_output_path}">{gpkg_output_path}</a>'

        if preserve_groups:
            message += (
                '<br/><i>To open with group structure: '
                'Project &gt; Open from &gt; GeoPackage</i>'
            )

        if zip_path:
            gpkg_dir = os.path.dirname(gpkg_output_path)
            if batch_exporter_class.create_zip_archive(zip_path, gpkg_dir):
                message += f' and Zip file has been exported to <a href="file:///{zip_path}">{zip_path}</a>'

        return True, message, None

    def _export_with_streaming(
        self,
        layers: List,
        output_folder: str,
        projection: Any,
        datatype: str,
        style_format: Optional[str],
        save_styles: bool,
        chunk_size: int,
        project: Any,
        set_progress: Callable,
        set_description: Callable,
        is_canceled: Callable,
    ) -> tuple:
        """Export layers using streaming for large datasets.

        Args:
            layers: List of layer info dicts or layer names
            output_folder: Output directory path
            projection: Target CRS
            datatype: Output format (GPKG, SHP, etc.)
            style_format: Style format (QML, SLD, etc.)
            save_styles: Whether to save styles
            chunk_size: Number of features per batch
            project: QgsProject instance
            set_progress: Progress callback
            set_description: Description callback
            is_canceled: Cancel check callback

        Returns:
            tuple: (success: bool, message: str)
        """
        try:
            config = StreamingConfig(batch_size=chunk_size)
            exporter = StreamingExporter(config)

            # Resolve the OGR driver name using the authoritative map — covers
            # XLSX, MapInfo File, FlatGeobuf, SpatiaLite, etc. StreamingExporter
            # accepts the driver name directly (unknown format strings are
            # passed through case-preserved).
            from ..export.layer_exporter import LayerExporter
            export_format = LayerExporter.DRIVER_MAP.get(datatype.upper(), datatype)

            if not os.path.exists(output_folder):
                try:
                    os.makedirs(output_folder)
                    logger.info(f"Created output folder: {output_folder}")
                except OSError as e:
                    error_msg = f"Cannot create output folder '{output_folder}': {e}"
                    logger.error(error_msg)
                    return False, error_msg

            def progress_callback(progress):
                set_progress(int(progress.percent_complete))
                set_description(
                    f"Streaming export: {progress.features_processed}/{progress.total_features} features"
                )

            exported_count = 0
            failed_layers = []
            from ..export.layer_exporter import get_extension_for_format
            from ..export.batch_exporter import (
                _disambiguate_basename, sanitize_filename,
            )
            ext = get_extension_for_format(datatype)
            used_basenames = set()

            for layer_info in layers:
                layer_name = layer_info['layer_name'] if isinstance(layer_info, dict) else layer_info
                layer = self.get_layer_by_name(project, layer_name)

                if not layer:
                    logger.warning(f"Layer not found: {layer_name}")
                    failed_layers.append(f"{layer_name} (not found)")
                    continue

                # Determine output path (disambiguate collisions)
                safe = _disambiguate_basename(
                    sanitize_filename(layer_name), used_basenames
                )
                output_path = os.path.join(output_folder, f"{safe}{ext}")

                logger.info(f"Streaming export: {layer_name} -> {output_path}")

                result = exporter.export_layer_streaming(
                    source_layer=layer,
                    output_path=output_path,
                    format=export_format,
                    progress_callback=progress_callback,
                    cancel_check=is_canceled,
                    target_crs=projection,
                )

                if not result.get('success', False):
                    error_msg = result.get('error', 'Unknown error')
                    logger.error(f"Streaming export failed for {layer_name}: {error_msg}")
                    failed_layers.append(f"{layer_name} ({error_msg})")
                    continue

                exported_count += 1

                # Style preservation depends on the target format (H2 fix):
                # - GPKG: embed in layer_styles table (matches qgis:package
                #   behavior for non-streaming GPKG; previously a .qml sidecar
                #   was written next to the .gpkg, which loses self-containment
                #   and is inconsistent with the standard GPKG path).
                # - Other formats: write a sidecar file (QML/SLD/LYRX) — the
                #   format itself can't carry embedded styles.
                if save_styles:
                    if datatype.upper() == 'GPKG':
                        try:
                            from ..export.gpkg_layer_tree_writer import (
                                write_layer_styles_to_gpkg,
                            )
                            # Single-layer streaming: each output_path is a
                            # standalone .gpkg, so the layer's table_name in
                            # that file is the (sanitized) layer name.
                            write_layer_styles_to_gpkg(
                                output_path, [(layer, safe)]
                            )
                        except Exception as e:
                            logger.warning(
                                f"Style embedding failed for {output_path}: {e}"
                            )
                    elif style_format:
                        self.save_layer_style(layer, output_path, style_format, datatype)

                if is_canceled():
                    logger.info("Export cancelled by user")
                    return False, "Export cancelled by user"

            if failed_layers:
                if exported_count > 0:
                    message = (
                        f"Partial export: {exported_count}/{len(layers)} layers exported. "
                        f"Failed: {', '.join(failed_layers[:3])}"
                    )
                    if len(failed_layers) > 3:
                        message += f" and {len(failed_layers) - 3} more"
                    logger.warning(message)
                    return True, message
                else:
                    message = (
                        f"Export failed for all {len(layers)} layers. "
                        f"Errors: {', '.join(failed_layers[:3])}"
                    )
                    if len(failed_layers) > 3:
                        message += f" and {len(failed_layers) - 3} more"
                    logger.error(message)
                    return False, message

            message = (
                f'Streaming export: {len(layers)} layer(s) exported to '
                f'<a href="file:///{output_folder}">{output_folder}</a>'
            )
            return True, message

        except Exception as e:  # catch-all safety net: streaming export must return error tuple
            error_msg = f"Streaming export error: {e}"
            logger.error(error_msg)
            return False, error_msg
