"""FreeCAD Macro: Multi-Format Export.

SPDX-License-Identifier: MIT
Copyright (c) 2025 Sean P. Kane (GitHub: spkane)

Export selected bodies to multiple file formats simultaneously with a
user-friendly dialog for format selection and output configuration.

Requirements:
    - FreeCAD 0.21 or later
    - One or more objects selected in the 3D view

Usage:
    1. Select the object(s) to export
    2. Run the macro
    3. Select desired export formats (STL, STEP, 3MF selected by default)
    4. Choose output directory and base filename
    5. Click "Export"
"""

# FreeCAD Addon Manager metadata
__Name__ = "Multi Export"
__Comment__ = "Export selected bodies to multiple file formats (STL, STEP, 3MF, OBJ, IGES, BREP, PLY, AMF) simultaneously"
__Author__ = "Sean P. Kane"
__Version__ = "0.5.0-beta"
__Date__ = "2026-01-05"
__License__ = "MIT"
__Web__ = "https://github.com/spkane/freecad-robust-mcp-and-more"
__Wiki__ = "https://github.com/spkane/freecad-robust-mcp-and-more#readme"
__Icon__ = ""
__Help__ = "Select one or more objects, run the macro, choose export formats and output location, then click Export."
__Status__ = "Beta"
__Requires__ = "FreeCAD 0.21+"
__Communication__ = "https://github.com/spkane/freecad-robust-mcp-and-more/issues"
__Files__ = ""

import os

import FreeCAD as App
import FreeCADGui as Gui
import Mesh
import Part
from PySide import QtGui


class ExportFormat:
    """Represents an export format with its properties."""

    def __init__(
        self,
        name: str,
        extension: str,
        description: str,
        default_enabled: bool = False,
    ):
        """Initialize an export format.

        Args:
            name: Display name for the format
            extension: File extension (without dot)
            description: Brief description of the format
            default_enabled: Whether this format is enabled by default
        """
        self.name = name
        self.extension = extension
        self.description = description
        self.default_enabled = default_enabled


# Define available export formats
EXPORT_FORMATS = [
    ExportFormat(
        "STL",
        "stl",
        "Stereolithography - common for 3D printing",
        default_enabled=True,
    ),
    ExportFormat(
        "STEP",
        "step",
        "Standard for Exchange of Product Data - CAD interchange",
        default_enabled=True,
    ),
    ExportFormat(
        "3MF",
        "3mf",
        "3D Manufacturing Format - modern 3D printing format",
        default_enabled=True,
    ),
    ExportFormat(
        "OBJ",
        "obj",
        "Wavefront OBJ - 3D graphics and game engines",
        default_enabled=False,
    ),
    ExportFormat(
        "IGES",
        "iges",
        "Initial Graphics Exchange Specification - legacy CAD format",
        default_enabled=False,
    ),
    ExportFormat(
        "BREP",
        "brep",
        "OpenCASCADE native format - preserves exact geometry",
        default_enabled=False,
    ),
    ExportFormat(
        "PLY",
        "ply",
        "Polygon File Format - 3D scanning and printing",
        default_enabled=False,
    ),
    ExportFormat(
        "AMF",
        "amf",
        "Additive Manufacturing Format - XML-based 3D printing",
        default_enabled=False,
    ),
]


class MultiExportDialog(QtGui.QDialog):
    """Dialog for configuring multi-format export options."""

    def __init__(self, selected_objects: list, parent=None):
        """Initialize the export dialog.

        Args:
            selected_objects: List of FreeCAD objects to export
            parent: Parent widget
        """
        super(MultiExportDialog, self).__init__(parent)
        self.selected_objects = selected_objects
        self.format_checkboxes = {}

        self.setWindowTitle("Multi-Format Export")
        self.setModal(True)
        self.setMinimumWidth(500)
        self.setup_ui()
        self.populate_defaults()

    def setup_ui(self):
        """Initialize the user interface."""
        layout = QtGui.QVBoxLayout()

        # Objects to export section
        objects_group = QtGui.QGroupBox("Objects to Export")
        objects_layout = QtGui.QVBoxLayout()

        self.objects_list = QtGui.QListWidget()
        self.objects_list.setMaximumHeight(100)
        self.objects_list.setSelectionMode(QtGui.QAbstractItemView.NoSelection)
        for obj in self.selected_objects:
            item = QtGui.QListWidgetItem(f"{obj.Label} ({_get_object_type(obj)})")
            self.objects_list.addItem(item)
        objects_layout.addWidget(self.objects_list)

        objects_group.setLayout(objects_layout)
        layout.addWidget(objects_group)

        # Export formats section
        formats_group = QtGui.QGroupBox("Export Formats")
        formats_layout = QtGui.QGridLayout()

        for i, fmt in enumerate(EXPORT_FORMATS):
            checkbox = QtGui.QCheckBox(f"{fmt.name} (.{fmt.extension})")
            checkbox.setChecked(fmt.default_enabled)
            checkbox.setToolTip(fmt.description)
            self.format_checkboxes[fmt.extension] = checkbox

            row = i // 2
            col = i % 2
            formats_layout.addWidget(checkbox, row, col)

        # Quick selection buttons
        button_row = (len(EXPORT_FORMATS) + 1) // 2
        select_all_btn = QtGui.QPushButton("Select All")
        select_all_btn.clicked.connect(self._select_all_formats)
        select_none_btn = QtGui.QPushButton("Select None")
        select_none_btn.clicked.connect(self._select_no_formats)
        select_defaults_btn = QtGui.QPushButton("Reset Defaults")
        select_defaults_btn.clicked.connect(self._reset_default_formats)

        btn_layout = QtGui.QHBoxLayout()
        btn_layout.addWidget(select_all_btn)
        btn_layout.addWidget(select_none_btn)
        btn_layout.addWidget(select_defaults_btn)
        btn_layout.addStretch()

        formats_layout.addLayout(btn_layout, button_row, 0, 1, 2)

        formats_group.setLayout(formats_layout)
        layout.addWidget(formats_group)

        # Output configuration section
        output_group = QtGui.QGroupBox("Output Configuration")
        output_layout = QtGui.QFormLayout()

        # Directory selection
        dir_layout = QtGui.QHBoxLayout()
        self.directory_edit = QtGui.QLineEdit()
        self.directory_edit.setReadOnly(True)
        browse_btn = QtGui.QPushButton("Browse...")
        browse_btn.clicked.connect(self._browse_directory)
        dir_layout.addWidget(self.directory_edit)
        dir_layout.addWidget(browse_btn)
        output_layout.addRow("Directory:", dir_layout)

        # Base filename
        self.filename_edit = QtGui.QLineEdit()
        self.filename_edit.setToolTip(
            "Base filename without extension. Each format will be appended."
        )
        output_layout.addRow("Base Filename:", self.filename_edit)

        # Preview of output files
        self.preview_label = QtGui.QLabel("")
        self.preview_label.setWordWrap(True)
        self.preview_label.setStyleSheet("color: gray; font-size: 11px;")
        output_layout.addRow("Will create:", self.preview_label)

        # Connect signals for preview updates
        self.filename_edit.textChanged.connect(self._update_preview)
        self.directory_edit.textChanged.connect(self._update_preview)
        for checkbox in self.format_checkboxes.values():
            checkbox.stateChanged.connect(self._update_preview)

        output_group.setLayout(output_layout)
        layout.addWidget(output_group)

        # Mesh options section (for STL, OBJ, PLY, 3MF, AMF)
        mesh_group = QtGui.QGroupBox("Mesh Options (STL, OBJ, PLY, 3MF, AMF)")
        mesh_layout = QtGui.QFormLayout()

        self.tolerance_spin = QtGui.QDoubleSpinBox()
        self.tolerance_spin.setRange(0.001, 10.0)
        self.tolerance_spin.setValue(0.1)
        self.tolerance_spin.setDecimals(3)
        self.tolerance_spin.setSuffix(" mm")
        self.tolerance_spin.setToolTip(
            "Lower values create finer meshes but larger files"
        )
        mesh_layout.addRow("Surface Tolerance:", self.tolerance_spin)

        self.deflection_spin = QtGui.QDoubleSpinBox()
        self.deflection_spin.setRange(0.001, 10.0)
        self.deflection_spin.setValue(0.1)
        self.deflection_spin.setDecimals(3)
        self.deflection_spin.setSuffix(" mm")
        self.deflection_spin.setToolTip("Angular deflection for curved surfaces")
        mesh_layout.addRow("Angular Deflection:", self.deflection_spin)

        mesh_group.setLayout(mesh_layout)
        layout.addWidget(mesh_group)

        # Status label
        self.status_label = QtGui.QLabel("")
        self.status_label.setWordWrap(True)
        layout.addWidget(self.status_label)

        # Progress bar (hidden initially)
        self.progress_bar = QtGui.QProgressBar()
        self.progress_bar.setVisible(False)
        layout.addWidget(self.progress_bar)

        # Buttons
        button_box = QtGui.QDialogButtonBox()
        self.export_btn = button_box.addButton(
            "Export", QtGui.QDialogButtonBox.AcceptRole
        )
        cancel_btn = button_box.addButton(QtGui.QDialogButtonBox.Cancel)

        button_box.accepted.connect(self.accept)
        button_box.rejected.connect(self.reject)

        layout.addWidget(button_box)
        self.setLayout(layout)

    def populate_defaults(self):
        """Populate default values based on the current document."""
        doc = App.ActiveDocument

        # Default directory: same as the FreeCAD document
        if doc and doc.FileName:
            default_dir = os.path.dirname(doc.FileName)
        else:
            default_dir = os.path.expanduser("~")
        self.directory_edit.setText(default_dir)

        # Default filename: based on selected objects or document name
        if len(self.selected_objects) == 1:
            # Single object: use its label
            default_name = _sanitize_filename(self.selected_objects[0].Label)
        elif doc and doc.FileName:
            # Multiple objects: use document name
            doc_name = os.path.splitext(os.path.basename(doc.FileName))[0]
            default_name = _sanitize_filename(doc_name)
        elif doc:
            default_name = _sanitize_filename(doc.Label)
        else:
            default_name = "export"

        self.filename_edit.setText(default_name)
        self._update_preview()

    def _select_all_formats(self):
        """Select all export formats."""
        for checkbox in self.format_checkboxes.values():
            checkbox.setChecked(True)

    def _select_no_formats(self):
        """Deselect all export formats."""
        for checkbox in self.format_checkboxes.values():
            checkbox.setChecked(False)

    def _reset_default_formats(self):
        """Reset to default format selection."""
        for fmt in EXPORT_FORMATS:
            self.format_checkboxes[fmt.extension].setChecked(fmt.default_enabled)

    def _browse_directory(self):
        """Open directory browser dialog."""
        current_dir = self.directory_edit.text() or os.path.expanduser("~")
        directory = QtGui.QFileDialog.getExistingDirectory(
            self,
            "Select Export Directory",
            current_dir,
            QtGui.QFileDialog.ShowDirsOnly,
        )
        if directory:
            self.directory_edit.setText(directory)

    def _update_preview(self):
        """Update the preview of files to be created."""
        selected_formats = self.get_selected_formats()
        base_name = self.filename_edit.text().strip()

        if not selected_formats:
            self.preview_label.setText("No formats selected")
            return

        if not base_name:
            self.preview_label.setText("Enter a filename")
            return

        files = [f"{base_name}.{ext}" for ext in selected_formats]
        if len(files) > 4:
            preview_text = ", ".join(files[:4]) + f", ... (+{len(files) - 4} more)"
        else:
            preview_text = ", ".join(files)

        self.preview_label.setText(preview_text)

    def get_selected_formats(self) -> list[str]:
        """Get list of selected format extensions."""
        return [
            ext
            for ext, checkbox in self.format_checkboxes.items()
            if checkbox.isChecked()
        ]

    def get_export_parameters(self) -> dict:
        """Get all export parameters from the dialog."""
        return {
            "directory": self.directory_edit.text(),
            "base_filename": self.filename_edit.text().strip(),
            "formats": self.get_selected_formats(),
            "mesh_tolerance": self.tolerance_spin.value(),
            "mesh_deflection": self.deflection_spin.value(),
        }

    def set_status(self, message: str, is_error: bool = False):
        """Update status message."""
        if is_error:
            self.status_label.setStyleSheet("color: red;")
        else:
            self.status_label.setStyleSheet("color: green;")
        self.status_label.setText(message)
        QtGui.QApplication.processEvents()

    def set_progress(self, value: int, maximum: int = 100):
        """Update progress bar."""
        if not self.progress_bar.isVisible():
            self.progress_bar.setVisible(True)
        self.progress_bar.setMaximum(maximum)
        self.progress_bar.setValue(value)
        QtGui.QApplication.processEvents()


class MultiExporter:
    """Handles exporting objects to multiple formats."""

    def __init__(self, objects: list, params: dict):
        """Initialize the exporter.

        Args:
            objects: List of FreeCAD objects to export
            params: Export parameters from the dialog
        """
        self.objects = objects
        self.params = params
        self.exported_files = []
        self.errors = []

    def export_all(self, progress_callback=None) -> tuple[list[str], list[str]]:
        """Export objects to all selected formats.

        Args:
            progress_callback: Optional callback for progress updates

        Returns:
            Tuple of (list of exported files, list of errors)
        """
        formats = self.params["formats"]
        total_exports = len(formats)

        if total_exports == 0:
            return [], ["No formats selected"]

        for i, fmt in enumerate(formats):
            if progress_callback:
                progress = int((i / total_exports) * 100)
                progress_callback(progress, f"Exporting {fmt.upper()}...")

            try:
                filepath = self._export_format(fmt)
                self.exported_files.append(filepath)
                App.Console.PrintMessage(f"Exported: {filepath}\n")
            except Exception as e:
                error_msg = f"Failed to export {fmt.upper()}: {e!s}"
                self.errors.append(error_msg)
                App.Console.PrintError(f"{error_msg}\n")

        if progress_callback:
            progress_callback(100, "Export complete!")

        return self.exported_files, self.errors

    def _export_format(self, extension: str) -> str:
        """Export to a specific format.

        Args:
            extension: File extension (format identifier)

        Returns:
            Path to the exported file
        """
        directory = self.params["directory"]
        base_name = self.params["base_filename"]
        filepath = os.path.join(directory, f"{base_name}.{extension}")

        # Get the shape(s) to export
        shapes = []
        for obj in self.objects:
            if hasattr(obj, "Shape"):
                shapes.append(obj.Shape)
            elif hasattr(obj, "Mesh"):
                shapes.append(obj.Mesh)

        if not shapes:
            raise ValueError("No exportable shapes found")

        # Combine shapes if multiple
        if len(shapes) == 1:
            combined_shape = shapes[0]
        else:
            # For Part shapes, make a compound
            combined_shape = Part.makeCompound(shapes)

        # Export based on format
        if extension == "stl":
            self._export_mesh(combined_shape, filepath, "stl")
        elif extension == "step":
            self._export_step(combined_shape, filepath)
        elif extension == "3mf":
            self._export_mesh(combined_shape, filepath, "3mf")
        elif extension == "obj":
            self._export_mesh(combined_shape, filepath, "obj")
        elif extension == "iges":
            self._export_iges(combined_shape, filepath)
        elif extension == "brep":
            self._export_brep(combined_shape, filepath)
        elif extension == "ply":
            self._export_mesh(combined_shape, filepath, "ply")
        elif extension == "amf":
            self._export_mesh(combined_shape, filepath, "amf")
        else:
            raise ValueError(f"Unsupported format: {extension}")

        return filepath

    def _export_mesh(self, shape, filepath: str, format_type: str):
        """Export shape as a mesh format (STL, OBJ, PLY, 3MF, AMF).

        Args:
            shape: Part.Shape to export
            filepath: Output file path
            format_type: Mesh format type
        """
        tolerance = self.params["mesh_tolerance"]
        deflection = self.params["mesh_deflection"]

        # Create mesh from shape
        mesh = Mesh.Mesh()

        if hasattr(shape, "tessellate"):
            # Part.Shape - tessellate it
            vertices, facets = shape.tessellate(tolerance)
            mesh_data = []
            for facet in facets:
                triangle = [
                    vertices[facet[0]],
                    vertices[facet[1]],
                    vertices[facet[2]],
                ]
                mesh_data.append(triangle)
            mesh.addFacets(mesh_data)
        elif hasattr(shape, "Facets"):
            # Already a Mesh
            mesh = shape
        else:
            raise ValueError("Cannot create mesh from object")

        # Export the mesh
        mesh.write(filepath)

    def _export_step(self, shape, filepath: str):
        """Export shape as STEP format.

        Args:
            shape: Part.Shape to export
            filepath: Output file path
        """
        shape.exportStep(filepath)

    def _export_iges(self, shape, filepath: str):
        """Export shape as IGES format.

        Args:
            shape: Part.Shape to export
            filepath: Output file path
        """
        shape.exportIges(filepath)

    def _export_brep(self, shape, filepath: str):
        """Export shape as BREP format.

        Args:
            shape: Part.Shape to export
            filepath: Output file path
        """
        shape.exportBrep(filepath)


def _get_object_type(obj) -> str:
    """Get a human-readable type description for an object."""
    if hasattr(obj, "TypeId"):
        type_id = obj.TypeId
        if "Part::" in type_id:
            return type_id.replace("Part::", "")
        if "PartDesign::" in type_id:
            return type_id.replace("PartDesign::", "")
        if "Mesh::" in type_id:
            return "Mesh"
        return type_id
    if hasattr(obj, "Shape"):
        return "Shape"
    return ""


def _sanitize_filename(name: str) -> str:
    """Sanitize a string for use as a filename.

    Args:
        name: The original name

    Returns:
        Sanitized filename (without extension)
    """
    # Replace problematic characters
    invalid_chars = '<>:"/\\|?*'
    result = name
    for char in invalid_chars:
        result = result.replace(char, "_")

    # Remove leading/trailing whitespace and dots
    result = result.strip(" .")

    # Ensure non-empty
    return result if result else "export"


def _get_exportable_objects(selection: list) -> list:
    """Filter selection to only include exportable objects.

    Args:
        selection: List of selected FreeCAD objects

    Returns:
        List of objects that can be exported
    """
    exportable = []
    for obj in selection:
        # Check for Part shapes
        if hasattr(obj, "Shape") and hasattr(obj.Shape, "Volume"):
            if obj.Shape.Volume > 0.001:
                exportable.append(obj)
        # Check for Mesh objects
        elif hasattr(obj, "Mesh"):
            exportable.append(obj)
    return exportable


def main():
    """Main macro entry point."""
    # Check for active document
    if not App.ActiveDocument:
        QtGui.QMessageBox.warning(
            None, "No Document", "Please open or create a document first."
        )
        return

    # Get current selection
    selection = Gui.Selection.getSelection()

    if not selection:
        QtGui.QMessageBox.warning(
            None,
            "No Selection",
            "Please select one or more objects to export.\n\n"
            "You can select multiple objects by holding Ctrl/Cmd while clicking.",
        )
        return

    # Filter to exportable objects
    exportable = _get_exportable_objects(selection)

    if not exportable:
        QtGui.QMessageBox.warning(
            None,
            "No Exportable Objects",
            "None of the selected objects can be exported.\n\n"
            "Please select objects with solid shapes (Part or PartDesign bodies).",
        )
        return

    # Show dialog
    dialog = MultiExportDialog(exportable)

    if dialog.exec_() != QtGui.QDialog.Accepted:
        return

    params = dialog.get_export_parameters()

    # Validate parameters
    if not params["formats"]:
        QtGui.QMessageBox.warning(
            None,
            "No Formats Selected",
            "Please select at least one export format.",
        )
        return

    if not params["base_filename"]:
        QtGui.QMessageBox.warning(
            None,
            "No Filename",
            "Please enter a base filename for the exports.",
        )
        return

    if not os.path.isdir(params["directory"]):
        QtGui.QMessageBox.warning(
            None,
            "Invalid Directory",
            f"The directory does not exist:\n{params['directory']}",
        )
        return

    # Perform export
    try:
        exporter = MultiExporter(exportable, params)

        def progress_update(value, message=""):
            dialog.set_status(message)
            dialog.set_progress(value)

        exported_files, errors = exporter.export_all(progress_update)

        # Show results
        if exported_files:
            success_msg = f"Successfully exported {len(exported_files)} file(s):\n"
            for f in exported_files[:5]:
                success_msg += f"\n  • {os.path.basename(f)}"
            if len(exported_files) > 5:
                success_msg += f"\n  ... and {len(exported_files) - 5} more"

            if errors:
                success_msg += f"\n\nWarnings ({len(errors)}):\n"
                for e in errors[:3]:
                    success_msg += f"\n  • {e}"

            dialog.set_status(success_msg)
            App.Console.PrintMessage(
                f"Multi-export complete: {len(exported_files)} files\n"
            )
        else:
            error_msg = "Export failed:\n" + "\n".join(errors)
            dialog.set_status(error_msg, is_error=True)
            App.Console.PrintError(f"Multi-export failed: {errors}\n")

    except Exception as e:
        dialog.set_status(f"Export error: {e!s}", is_error=True)
        App.Console.PrintError(f"Multi-export error: {e!s}\n")
        import traceback

        traceback.print_exc()


if __name__ == "__main__":
    main()
