"""
Project serialization to/from ZIP files for pyPhotoAlbum
"""

import os
import json
import zipfile
import shutil
import tempfile
import threading
from typing import Optional, Tuple, Callable
from pathlib import Path
from pyPhotoAlbum.project import Project
from pyPhotoAlbum.version_manager import (
    CURRENT_DATA_VERSION,
    check_version_compatibility,
    VersionCompatibility,
    DataMigration,
)

# Legacy constant for backward compatibility
SERIALIZATION_VERSION = CURRENT_DATA_VERSION


def _import_external_images(project: Project):
    """
    Find and import any images that have external (absolute or non-assets) paths.
    This ensures all images are in the assets folder before saving.

    Args:
        project: The Project instance to check
    """
    from pyPhotoAlbum.models import ImageData

    imported_count = 0

    for page in project.pages:
        for element in page.layout.elements:
            if isinstance(element, ImageData) and element.image_path:
                # Check if this is an external path (absolute or not in assets/)
                is_external = False

                if os.path.isabs(element.image_path):
                    # Absolute path - definitely external
                    is_external = True
                    external_path = element.image_path
                elif not element.image_path.startswith("assets/"):
                    # Relative path but not in assets folder
                    # Check if it exists relative to project folder
                    full_path = os.path.join(project.folder_path, element.image_path)
                    if os.path.exists(full_path) and not full_path.startswith(project.asset_manager.assets_folder):
                        is_external = True
                        external_path = full_path
                    else:
                        # Path doesn't exist - skip it (will be caught as missing asset)
                        continue
                else:
                    # Already in assets/ folder
                    continue

                # Import the external image
                if is_external and os.path.exists(external_path):
                    try:
                        new_asset_path = project.asset_manager.import_asset(external_path)
                        element.image_path = new_asset_path
                        imported_count += 1
                        print(f"Auto-imported external image: {external_path} → {new_asset_path}")
                    except Exception as e:
                        print(f"Warning: Failed to import external image {external_path}: {e}")

    if imported_count > 0:
        print(f"Auto-imported {imported_count} external image(s) to assets folder")


def _normalize_asset_paths(project: Project, project_folder: str):
    """
    Normalize asset paths in a loaded project to be relative to the project folder.
    This fixes legacy projects that may have absolute paths or paths relative to old locations.

    Args:
        project: The Project instance to normalize
        project_folder: The current project folder path
    """
    from pyPhotoAlbum.models import ImageData

    normalized_count = 0

    for page in project.pages:
        for element in page.layout.elements:
            if isinstance(element, ImageData) and element.image_path:
                original_path = element.image_path

                # Skip if already a simple relative path (assets/...)
                if not os.path.isabs(original_path) and not original_path.startswith("./projects/"):
                    continue

                # Try to extract just the filename or relative path from assets folder
                # Pattern 1: "./projects/XXX/assets/filename.jpg" -> "assets/filename.jpg"
                if "/assets/" in original_path:
                    parts = original_path.split("/assets/")
                    if len(parts) == 2:
                        new_path = os.path.join("assets", parts[1])
                        element.image_path = new_path
                        normalized_count += 1
                        print(f"Normalized path: {original_path} -> {new_path}")
                        continue

                # Pattern 2: Absolute path - try to make it relative if it's in the extraction folder
                if os.path.isabs(original_path):
                    try:
                        new_path = os.path.relpath(original_path, project_folder)
                        element.image_path = new_path
                        normalized_count += 1
                        print(f"Normalized absolute path: {original_path} -> {new_path}")
                    except ValueError:
                        # Can't make relative (different drives on Windows, etc.)
                        pass

    if normalized_count > 0:
        print(f"Normalized {normalized_count} asset paths")


def save_to_zip(project: Project, zip_path: str) -> Tuple[bool, Optional[str]]:
    """
    Save a project to a ZIP file, including all assets.

    Args:
        project: The Project instance to save
        zip_path: Path where the ZIP file should be created

    Returns:
        Tuple of (success: bool, error_message: Optional[str])
    """
    try:
        # Ensure .ppz extension
        if not zip_path.lower().endswith(".ppz"):
            zip_path += ".ppz"

        # Check for and import any external images before saving
        _import_external_images(project)

        # Serialize project to dictionary
        project_data = project.serialize()

        # Add version information
        project_data["serialization_version"] = SERIALIZATION_VERSION  # Legacy field
        project_data["data_version"] = CURRENT_DATA_VERSION  # New versioning system

        # Create ZIP file
        with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zipf:
            # Write project.json with stable sorting for git-friendly diffs
            project_json = json.dumps(project_data, indent=2, sort_keys=True)
            zipf.writestr("project.json", project_json)

            # Add all files from the assets folder
            assets_folder = project.asset_manager.assets_folder
            if os.path.exists(assets_folder):
                for root, dirs, files in os.walk(assets_folder):
                    for file in files:
                        file_path = os.path.join(root, file)
                        # Store with relative path from project folder
                        arcname = os.path.relpath(file_path, project.folder_path)
                        zipf.write(file_path, arcname)

        print(f"Project saved to {zip_path}")
        return True, None

    except Exception as e:
        error_msg = f"Error saving project: {str(e)}"
        print(error_msg)
        return False, error_msg


def save_to_zip_async(
    project: Project,
    zip_path: str,
    on_complete: Optional[Callable[[bool, Optional[str]], None]] = None,
    on_progress: Optional[Callable[[int, str], None]] = None,
) -> threading.Thread:
    """
    Save a project to a ZIP file asynchronously in a background thread.

    This provides instant UI responsiveness by:
    1. Immediately serializing project.json to a temp folder (fast)
    2. Creating the ZIP file in a background thread (slow)
    3. Calling on_complete when done

    Args:
        project: The Project instance to save
        zip_path: Path where the ZIP file should be created
        on_complete: Optional callback(success: bool, error_msg: Optional[str])
                    called when save completes
        on_progress: Optional callback(progress: int, message: str) where
                    progress is 0-100 and message describes current step

    Returns:
        The background thread (already started)
    """
    # Ensure .ppz extension
    final_zip_path = zip_path
    if not final_zip_path.lower().endswith(".ppz"):
        final_zip_path += ".ppz"

    # ---- Work done on the CALLING (main) thread ----
    # Serialization is pure Python and holds the GIL, but it's fast.
    # Doing it here keeps the background thread to file-I/O only, which
    # releases the GIL and keeps the UI responsive.
    if on_progress:
        on_progress(0, "Preparing to save...")

    _import_external_images(project)

    if on_progress:
        on_progress(10, "Serializing project data...")
    project_data = project.serialize()
    project_data["serialization_version"] = SERIALIZATION_VERSION
    project_data["data_version"] = CURRENT_DATA_VERSION
    project_json_str = json.dumps(project_data, indent=2, sort_keys=True)

    # Collect the asset file list now so the background thread doesn't
    # need to touch the (potentially temporary) project folder.
    assets_folder = project.asset_manager.assets_folder
    folder_path = project.folder_path
    asset_files: list[tuple[str, str]] = []
    if os.path.exists(assets_folder):
        for root, _dirs, files in os.walk(assets_folder):
            for file in files:
                file_path = os.path.join(root, file)
                arcname = os.path.relpath(file_path, folder_path)
                asset_files.append((file_path, arcname))

    total_files = 1 + len(asset_files)  # project.json + assets

    if on_progress:
        on_progress(20, f"Starting background write ({total_files} files)...")

    # ---- Work done on the BACKGROUND thread ----
    # Only file I/O here — zipfile/zlib/shutil all release the GIL.
    def _background_save():
        """Background thread: write ZIP file from pre-serialized data."""
        temp_dir = None
        try:
            temp_dir = tempfile.mkdtemp(prefix="pyPhotoAlbum_save_")
            temp_zip_path = os.path.join(temp_dir, "project.ppz")

            if on_progress:
                on_progress(25, f"Creating ZIP archive ({total_files} files)...")

            with zipfile.ZipFile(temp_zip_path, "w", zipfile.ZIP_DEFLATED) as zipf:
                zipf.writestr("project.json", project_json_str)

                if asset_files:
                    progress_range = 90 - 25
                    for idx, (file_path, arcname) in enumerate(asset_files):
                        zipf.write(file_path, arcname)
                        if idx % 10 == 0 or idx == len(asset_files) - 1:
                            progress = 25 + int((idx + 1) / len(asset_files) * progress_range)
                            if on_progress:
                                on_progress(progress, f"Adding assets... ({idx + 1}/{len(asset_files)})")

            if on_progress:
                on_progress(95, "Finalizing save...")

            os.makedirs(os.path.dirname(os.path.abspath(final_zip_path)), exist_ok=True)
            if os.path.exists(final_zip_path):
                os.remove(final_zip_path)
            shutil.move(temp_zip_path, final_zip_path)

            if on_progress:
                on_progress(100, "Save complete!")

            print(f"Project saved to {final_zip_path}")

            if on_complete:
                on_complete(True, None)

        except Exception as e:
            error_msg = f"Error saving project: {str(e)}"
            print(error_msg)
            if on_complete:
                on_complete(False, error_msg)

        finally:
            if temp_dir and os.path.exists(temp_dir):
                try:
                    shutil.rmtree(temp_dir)
                except Exception:
                    pass

    save_thread = threading.Thread(target=_background_save, daemon=True)
    save_thread.start()
    return save_thread


def load_from_zip(zip_path: str, extract_to: Optional[str] = None) -> Project:
    """
    Load a project from a ZIP file.

    Args:
        zip_path: Path to the ZIP file to load
        extract_to: Optional directory to extract to. If None, uses a temporary
                    directory that will be cleaned up when the project is closed.

    Returns:
        Project instance (raises exception on error)
    """
    if not os.path.exists(zip_path):
        raise FileNotFoundError(f"ZIP file not found: {zip_path}")

    # Track if we created a temp directory
    temp_dir_obj = None

    # Determine extraction directory
    if extract_to is None:
        # Create a temporary directory using TemporaryDirectory
        # This will be attached to the Project and auto-cleaned on deletion
        zip_basename = os.path.splitext(os.path.basename(zip_path))[0]
        temp_dir_obj = tempfile.TemporaryDirectory(prefix=f"pyPhotoAlbum_{zip_basename}_")
        extract_to = temp_dir_obj.name
    else:
        # Create extraction directory if it doesn't exist
        os.makedirs(extract_to, exist_ok=True)

    # Extract ZIP contents
    with zipfile.ZipFile(zip_path, "r") as zipf:
        zipf.extractall(extract_to)

    # Load project.json
    project_json_path = os.path.join(extract_to, "project.json")
    if not os.path.exists(project_json_path):
        raise ValueError("Invalid project file: project.json not found")

    with open(project_json_path, "r") as f:
        project_data = json.load(f)

    # Check version compatibility
    # Try new version field first, fall back to legacy field
    file_version = project_data.get("data_version", project_data.get("serialization_version", "1.0"))

    # Check if version is compatible
    is_compatible, error_msg = check_version_compatibility(file_version, zip_path)
    if not is_compatible:
        raise ValueError(error_msg)

    # Apply migrations if needed
    if VersionCompatibility.needs_migration(file_version):
        print(f"Migrating project from version {file_version} to {CURRENT_DATA_VERSION}...")
        project_data = DataMigration.migrate(project_data, file_version, CURRENT_DATA_VERSION)
        print(f"Migration completed successfully")
    elif file_version != CURRENT_DATA_VERSION:
        print(f"Note: Loading project with version {file_version}, current version is {CURRENT_DATA_VERSION}")

    # Create new project
    project_name = project_data.get("name", "Untitled Project")
    project = Project(name=project_name, folder_path=extract_to)

    # Deserialize project data
    project.deserialize(project_data)

    # Update folder path to extraction location
    project.folder_path = extract_to
    project.asset_manager.project_folder = extract_to
    project.asset_manager.assets_folder = os.path.join(extract_to, "assets")

    # Attach temporary directory to project (if we created one)
    # The TemporaryDirectory will auto-cleanup when the project is deleted
    if temp_dir_obj is not None:
        project._temp_dir = temp_dir_obj
        print(f"Project loaded to temporary directory: {extract_to}")

    # Normalize asset paths in all ImageData elements
    # This fixes old projects that have absolute or wrong relative paths
    _normalize_asset_paths(project, extract_to)

    # Set asset resolution context for ImageData rendering
    # Only set project folder - search paths are reserved for healing functionality
    from pyPhotoAlbum.models import set_asset_resolution_context

    set_asset_resolution_context(extract_to)

    print(f"Project loaded from {zip_path} to {extract_to}")
    return project


def get_project_info(zip_path: str) -> Optional[dict]:
    """
    Get basic information about a project without fully loading it.

    Args:
        zip_path: Path to the ZIP file

    Returns:
        Dictionary with project info, or None if error
    """
    try:
        with zipfile.ZipFile(zip_path, "r") as zipf:
            # Read project.json
            project_json = zipf.read("project.json").decode("utf-8")
            project_data = json.loads(project_json)

            return {
                "name": project_data.get("name", "Unknown"),
                "version": project_data.get("serialization_version", "Unknown"),
                "page_count": len(project_data.get("pages", [])),
                "page_size_mm": project_data.get("page_size_mm", (0, 0)),
                "working_dpi": project_data.get("working_dpi", 300),
            }
    except Exception as e:
        print(f"Error reading project info: {e}")
        return None
