# --- START OF FILE map_manager.py ---

# map_manager.py
"""
Manages the retrieval, caching, and stitching of map tiles from a selected map service.

Handles interaction with the map service provider interface, local disk caching,
and providing map tile images to the application.
"""

# Standard library imports
import logging
import os
import time
import threading
from pathlib import Path  # For easier path manipulation
from typing import Tuple, Optional, List, Dict  # <<< AGGIUNTO QUESTO IMPORT
import io  # To handle byte stream from requests as image
import shutil  # For cache clearing

# Third-party imports
try:
    import requests  # For downloading tiles
except ImportError:
    requests = None  # Handle optional dependency
try:
    from PIL import Image  # For handling image data (opening, saving, stitching)
except ImportError:
    Image = None  # Handle optional dependency


# Local application imports
from map_services import BaseMapService  # The interface we depend on
import config  # For cache directory, online fetching flag, etc.


class MapTileManager:
    """
    Manages fetching, caching, and assembling map tiles.

    Uses a specific map service provider (implementing BaseMapService)
    to get tile URLs and handles local caching to support offline use
    and reduce network requests. Requires 'requests' and 'Pillow' libraries.
    """

    # Default values (can be overridden by config)
    DEFAULT_CACHE_DIR = "map_cache"
    DEFAULT_ENABLE_ONLINE = True
    DEFAULT_TIMEOUT_SECONDS = 5  # Timeout for network requests
    DEFAULT_RETRY_DELAY_SECONDS = 1  # Delay before retrying a failed download
    DEFAULT_MAX_RETRIES = 2  # Number of retries for failed downloads

    def __init__(
        self,
        map_service: BaseMapService,
        cache_base_dir: Optional[str] = None,
        enable_online_fetching: Optional[bool] = None,
    ):  # <<< 'Optional' ora è definito
        """
        Initializes the MapTileManager.

        Args:
            map_service (BaseMapService): An instance of a map service provider
                                           (e.g., OpenStreetMapService).
            cache_base_dir (Optional[str]): The root directory for caching tiles.
                                            If None, uses config.MAP_CACHE_DIRECTORY or DEFAULT_CACHE_DIR.
            enable_online_fetching (Optional[bool]): Whether to download tiles if not in cache.
                                                     If None, uses config.ENABLE_ONLINE_MAP_FETCHING or DEFAULT_ENABLE_ONLINE.

        Raises:
            TypeError: If map_service is not a valid BaseMapService instance.
            ImportError: If 'requests' or 'Pillow' libraries are not installed.
        """
        self._log_prefix = "[MapTileManager]"
        logging.debug(f"{self._log_prefix} Initializing...")

        # --- Dependency Check ---
        if requests is None:
            logging.critical(
                f"{self._log_prefix} 'requests' library is required but not found. Please install it (`pip install requests`)."
            )
            raise ImportError("'requests' library not found.")
        if Image is None:
            logging.critical(
                f"{self._log_prefix} 'Pillow' library is required but not found. Please install it (`pip install Pillow`)."
            )
            raise ImportError("'Pillow' library not found.")

        # --- Service Validation ---
        if not isinstance(map_service, BaseMapService):
            logging.critical(
                f"{self._log_prefix} Invalid map_service provided. Must be an instance of BaseMapService."
            )
            raise TypeError("map_service must be an instance of BaseMapService")

        self.map_service = map_service
        self.service_name = map_service.name  # Get name for cache sub-directory

        # --- Configuration ---
        # Determine cache directory
        if cache_base_dir is None:
            cache_base_dir = getattr(
                config, "MAP_CACHE_DIRECTORY", self.DEFAULT_CACHE_DIR
            )
        # Create specific cache path for this service
        self.cache_dir = Path(cache_base_dir) / self.service_name
        logging.debug(f"{self._log_prefix} Cache directory set to: {self.cache_dir}")

        # Determine online fetching status
        if enable_online_fetching is None:
            self.enable_online = getattr(
                config, "ENABLE_ONLINE_MAP_FETCHING", self.DEFAULT_ENABLE_ONLINE
            )
        else:
            self.enable_online = enable_online_fetching
        logging.debug(
            f"{self._log_prefix} Online fetching enabled: {self.enable_online}"
        )

        # Network request parameters
        self.user_agent = "SAR_ControlPanel/1.0 (Python Requests; +http://example.com/bot)"  # Be a good citizen
        self.request_headers = {"User-Agent": self.user_agent}
        self.request_timeout = self.DEFAULT_TIMEOUT_SECONDS
        self.max_retries = self.DEFAULT_MAX_RETRIES
        self.retry_delay = self.DEFAULT_RETRY_DELAY_SECONDS

        # Ensure the cache directory exists (create if necessary)
        self._ensure_cache_dir_exists()

        # Lock for thread safety during cache access/modification
        self._cache_lock = threading.Lock()

        logging.debug(
            f"{self._log_prefix} Initialized for service '{self.service_name}' with cache at '{self.cache_dir}'. Online: {self.enable_online}"
        )

    def _ensure_cache_dir_exists(self):
        """Creates the cache directory structure if it doesn't exist."""
        try:
            # Use parents=True to create intermediate directories if needed
            # exist_ok=True prevents error if directory already exists
            self.cache_dir.mkdir(parents=True, exist_ok=True)
            logging.debug(
                f"{self._log_prefix} Cache directory check/creation successful: {self.cache_dir}"
            )
        except OSError as e:
            # Log error if creation fails, application might need to handle this.
            logging.error(
                f"{self._log_prefix} Failed to create cache directory '{self.cache_dir}': {e}"
            )
        except Exception as e:
            logging.exception(
                f"{self._log_prefix} Unexpected error ensuring cache directory exists:"
            )

    def _get_tile_cache_path(self, z: int, x: int, y: int) -> Path:
        """
        Constructs the full path for a specific tile within the cache.
        Uses a structure like /cache_base/service_name/zoom/x/y.png

        Args:
            z (int): Zoom level.
            x (int): Tile X coordinate.
            y (int): Tile Y coordinate.

        Returns:
            Path: The full Path object for the tile file.
        """
        # Creates paths like: .../map_cache/osm/12/1234/5678.png
        return self.cache_dir / str(z) / str(x) / f"{y}.png"

    def _load_from_cache(
        self, cache_path: Path, tile_coords_str: str
    ) -> Optional[Image.Image]:  # <<< 'Optional' ora è definito
        """Attempts to load a tile image from the cache file (thread-safe read)."""
        logging.debug(
            f"{self._log_prefix} Checking cache for tile {tile_coords_str} at {cache_path}"
        )
        try:
            # Use lock for thread-safe file access check/read
            with self._cache_lock:
                if cache_path.is_file():
                    logging.debug(
                        f"{self._log_prefix} Cache hit for tile {tile_coords_str}. Loading from disk."
                    )
                    # Open the image file
                    img = Image.open(cache_path)
                    # Crucial: Load data immediately to release file lock sooner.
                    img.load()
                    # Convert to RGB for consistency if not already
                    if img.mode != "RGB":
                        logging.debug(
                            f"{self._log_prefix} Converting cached image {tile_coords_str} from mode {img.mode} to RGB."
                        )
                        img = img.convert("RGB")
                    logging.debug(
                        f"{self._log_prefix} Tile {tile_coords_str} loaded successfully from cache."
                    )
                    return img
                else:
                    logging.debug(
                        f"{self._log_prefix} Cache miss for tile {tile_coords_str}."
                    )
                    return None
        except IOError as e:
            logging.error(
                f"{self._log_prefix} Error reading cached tile {cache_path}: {e}. Treating as cache miss."
            )
            return None
        except Exception as e:
            logging.exception(
                f"{self._log_prefix} Unexpected error accessing cache file {cache_path}:"
            )
            return None

    def _download_and_cache_tile(
        self, z: int, x: int, y: int, cache_path: Path, tile_coords_str: str
    ) -> Optional[Image.Image]:  # <<< 'Optional' ora è definito
        """Attempts to download a tile, process it, and save it to cache."""
        if not self.enable_online:
            logging.debug(
                f"{self._log_prefix} Online fetching disabled. Cannot download tile {tile_coords_str}."
            )
            return None

        # Get URL from the map service
        tile_url = self.map_service.get_tile_url(z, x, y)
        if not tile_url:
            logging.error(
                f"{self._log_prefix} Failed to get URL for tile {tile_coords_str} from service '{self.service_name}'."
            )
            return None

        logging.debug(
            f"{self._log_prefix} Downloading tile {tile_coords_str} from: {tile_url}"
        )  # Log download attempt as INFO

        downloaded_image = None
        for attempt in range(self.max_retries + 1):
            try:
                # Perform the network request
                response = requests.get(
                    tile_url,
                    headers=self.request_headers,
                    timeout=self.request_timeout,
                    stream=True,
                )
                response.raise_for_status()  # Raise HTTPError for bad responses (4xx, 5xx)

                # Check content type (optional but recommended)
                content_type = response.headers.get("content-type", "").lower()
                if "image" not in content_type:
                    logging.warning(
                        f"{self._log_prefix} Downloaded content for {tile_coords_str} may not be an image (Content-Type: {content_type}). Processing anyway."
                    )

                # Read content into memory
                image_data = response.content
                if not image_data:
                    logging.warning(
                        f"{self._log_prefix} Downloaded empty content for {tile_coords_str}. Skipping."
                    )
                    break  # Stop retrying if content is empty

                # --- Process and Cache Downloaded Image ---
                try:
                    img = Image.open(io.BytesIO(image_data))
                    img.load()  # Load data

                    # Convert to RGB for consistency
                    if img.mode != "RGB":
                        logging.debug(
                            f"{self._log_prefix} Converting downloaded image {tile_coords_str} from mode {img.mode} to RGB."
                        )
                        img = img.convert("RGB")

                    logging.debug(
                        f"{self._log_prefix} Tile {tile_coords_str} downloaded successfully (Attempt {attempt + 1})."
                    )

                    # Save to cache (thread-safe)
                    self._save_tile_to_cache(
                        cache_path, img
                    )  # Pass Pillow image directly

                    downloaded_image = img
                    break  # Success, exit retry loop

                except (IOError, Image.UnidentifiedImageError) as img_err:
                    logging.error(
                        f"{self._log_prefix} Failed to process downloaded image data for {tile_coords_str} from {tile_url}: {img_err}"
                    )
                    break  # Don't retry if data is corrupt
                except Exception as proc_err:
                    logging.exception(
                        f"{self._log_prefix} Unexpected error processing downloaded image {tile_coords_str}:"
                    )
                    break  # Don't retry on unexpected processing errors

            except requests.exceptions.Timeout:
                logging.warning(
                    f"{self._log_prefix} Timeout downloading tile {tile_coords_str} (Attempt {attempt + 1}/{self.max_retries + 1})."
                )
            except requests.exceptions.RequestException as req_err:
                status_code = getattr(req_err.response, "status_code", "N/A")
                logging.warning(
                    f"{self._log_prefix} Error downloading tile {tile_coords_str} (Status: {status_code}, Attempt {attempt + 1}/{self.max_retries + 1}): {req_err}"
                )
                # Decide if retry makes sense based on status code (e.g., retry on 5xx, not on 404)
                if status_code == 404:
                    logging.error(
                        f"{self._log_prefix} Tile {tile_coords_str} not found (404). Not retrying."
                    )
                    break  # No point retrying a 404
            except Exception as dl_err:
                logging.exception(
                    f"{self._log_prefix} Unexpected error downloading tile {tile_coords_str} (Attempt {attempt + 1}):"
                )
                # Break on unexpected errors during download itself

            # Wait before retrying if not the last attempt
            if attempt < self.max_retries:
                logging.debug(
                    f"{self._log_prefix} Waiting {self.retry_delay}s before retrying download for {tile_coords_str}."
                )
                time.sleep(self.retry_delay)

        if downloaded_image is None:
            logging.error(
                f"{self._log_prefix} Failed to download tile {tile_coords_str} after {self.max_retries + 1} attempts."
            )

        return downloaded_image

    def get_tile_image(
        self, z: int, x: int, y: int, force_refresh: bool = False
    ) -> Optional[Image.Image]:  # <<< 'Optional' ora è definito
        """
        Retrieves a map tile image, using cache first, then attempting download if enabled.

        Args:
            z (int): Zoom level.
            x (int): Tile X coordinate.
            y (int): Tile Y coordinate.
            force_refresh (bool): If True, ignore cache and force download (if online). Defaults to False.

        Returns:
            Optional[Image.Image]: A PIL Image object of the tile, or None if retrieval failed.
        """
        tile_coords_str = f"({z},{x},{y})"  # For logging
        logging.debug(f"{self._log_prefix} Requesting tile image for {tile_coords_str}")

        cache_path = self._get_tile_cache_path(z, x, y)
        image = None

        # --- 1. Check Cache ---
        if not force_refresh:
            image = self._load_from_cache(cache_path, tile_coords_str)

        # --- 2. Attempt Download (if cache miss/force refresh) ---
        if image is None:
            image = self._download_and_cache_tile(z, x, y, cache_path, tile_coords_str)

        # --- 3. Handle Failure ---
        if image is None:
            logging.warning(
                f"{self._log_prefix} Failed to retrieve tile {tile_coords_str}. Returning placeholder."
            )
            # Return a placeholder tile instead of None
            image = self.get_placeholder_tile()
            if image is None:  # If even placeholder fails
                logging.error(
                    f"{self._log_prefix} Could not even create a placeholder tile."
                )

        return image

    def _save_tile_to_cache(self, cache_path: Path, image: Image.Image):
        """
        Saves the downloaded tile image (Pillow object) to the cache directory (thread-safe).

        Args:
            cache_path (Path): The full path where the tile should be saved.
            image (Image.Image): The PIL Image object (already loaded and converted).
        """
        # Use lock for thread-safe directory creation and file writing
        with self._cache_lock:
            try:
                # Ensure the specific subdirectory for the tile exists (e.g., .../zoom/x/)
                cache_path.parent.mkdir(parents=True, exist_ok=True)

                # Save the image using Pillow (infers format from extension)
                image.save(cache_path)
                logging.debug(f"{self._log_prefix} Saved tile to cache: {cache_path}")

            except IOError as e:
                logging.error(
                    f"{self._log_prefix} Error saving tile to cache {cache_path}: {e}"
                )
            except Exception as e:
                logging.exception(
                    f"{self._log_prefix} Unexpected error saving tile to cache {cache_path}:"
                )

    def stitch_map_image(
        self, z: int, x_range: Tuple[int, int], y_range: Tuple[int, int]
    ) -> Optional[Image.Image]:  # <<< 'Optional' ora è definito
        """
        Retrieves and stitches together multiple tiles to form a larger map image.

        Args:
            z (int): The zoom level for all tiles.
            x_range (Tuple[int, int]): The inclusive start and end X tile coordinates (min_x, max_x).
            y_range (Tuple[int, int]): The inclusive start and end Y tile coordinates (min_y, max_y).

        Returns:
            Optional[Image.Image]: A single PIL Image object containing the stitched map.
                                   Placeholders are used for missing tiles. Returns None only on major errors.
        """
        log_prefix_stitch = f"{self._log_prefix}[Stitch]"
        logging.debug(
            f"{log_prefix_stitch} Request to stitch map for zoom {z}, X range {x_range}, Y range {y_range}"
        )

        min_x, max_x = x_range
        min_y, max_y = y_range

        if not (min_x <= max_x and min_y <= max_y):
            logging.error(
                f"{log_prefix_stitch} Invalid tile ranges provided: X={x_range}, Y={y_range}"
            )
            return None

        num_tiles_x = (max_x - min_x) + 1
        num_tiles_y = (max_y - min_y) + 1
        tile_size = self.map_service.tile_size

        if tile_size <= 0:
            logging.error(
                f"{log_prefix_stitch} Invalid tile size ({tile_size}) reported by map service. Cannot stitch."
            )
            return None

        # Calculate dimensions of the final stitched image
        total_width = num_tiles_x * tile_size
        total_height = num_tiles_y * tile_size
        logging.debug(
            f"{log_prefix_stitch} Final image dimensions: {total_width}x{total_height} ({num_tiles_x}x{num_tiles_y} tiles)"
        )

        # Create a new blank image to paste tiles onto
        try:
            stitched_image = Image.new("RGB", (total_width, total_height))
        except Exception as e:
            logging.exception(
                f"{log_prefix_stitch} Failed to create blank image for stitching:"
            )
            return None

        # Loop through tiles, get image (or placeholder), and paste
        for j, y in enumerate(range(min_y, max_y + 1)):  # Iterate Y (rows) first
            for i, x in enumerate(
                range(min_x, max_x + 1)
            ):  # Iterate X (columns) second
                # Get the individual tile image (will return placeholder on failure)
                tile_img = self.get_tile_image(
                    z, x, y
                )  # Handles caching/download/placeholder

                if tile_img is None:
                    # This should ideally not happen if get_placeholder_tile works
                    logging.critical(
                        f"{log_prefix_stitch} Failed to retrieve tile OR placeholder for ({z},{x},{y}). Aborting stitch."
                    )
                    return None  # Abort if even placeholder fails

                # Calculate paste position
                paste_x = i * tile_size
                paste_y = j * tile_size
                logging.debug(
                    f"{log_prefix_stitch} Pasting tile ({z},{x},{y}) at pixel position ({paste_x},{paste_y})"
                )

                # Paste the tile onto the main image
                try:
                    stitched_image.paste(tile_img, (paste_x, paste_y))
                except Exception as e:
                    logging.exception(
                        f"{log_prefix_stitch} Error pasting tile ({z},{x},{y}) at ({paste_x},{paste_y}):"
                    )
                    # Continue trying to paste other tiles, but log the error
                    pass  # The tile will be missing / area blank

        logging.debug(
            f"{log_prefix_stitch} Map stitching complete for zoom {z}, X={x_range}, Y={y_range}."
        )
        return stitched_image

    def get_placeholder_tile(
        self,
    ) -> Optional[Image.Image]:  # <<< 'Optional' ora è definito
        """
        Returns a placeholder tile image (e.g., grey square) if Pillow is available.

        Returns:
            Optional[Image.Image]: The placeholder image or None if Image module not loaded.
        """
        if Image is None:
            logging.warning(
                f"{self._log_prefix} Cannot create placeholder tile: Pillow library not available."
            )
            return None
        try:
            size = self.map_service.tile_size
            color = getattr(
                config, "OFFLINE_MAP_PLACEHOLDER_COLOR", (200, 200, 200)
            )  # Light grey default RGB
            # Ensure color is RGB tuple for Pillow
            if isinstance(color, tuple) and len(color) == 3:
                rgb_color = (int(color[0]), int(color[1]), int(color[2]))
            else:
                logging.warning(
                    f"{self._log_prefix} Invalid placeholder color {color} in config. Using default grey."
                )
                rgb_color = (200, 200, 200)

            return Image.new("RGB", (size, size), color=rgb_color)
        except Exception as e:
            logging.exception(f"{self._log_prefix} Error creating placeholder tile:")
            return None

    def clear_cache(
        self, zoom_level: Optional[int] = None
    ):  # <<< 'Optional' ora è definito
        """
        Deletes cached tiles for the current service.

        Args:
            zoom_level (Optional[int]): If specified, only clear tiles for this zoom level.
                                        If None, clear the entire cache for the service.
        """
        log_prefix_clear = f"{self._log_prefix}[CacheClear]"
        target_dir = self.cache_dir
        if zoom_level is not None:
            target_dir = target_dir / str(zoom_level)
            clear_desc = f"zoom level {zoom_level}"
        else:
            clear_desc = "entire service cache"

        logging.debug(
            f"{log_prefix_clear} Attempting to clear {clear_desc} at {target_dir}"
        )

        if not target_dir.exists():
            logging.warning(
                f"{log_prefix_clear} Cache directory/level '{target_dir}' does not exist. Nothing to clear."
            )
            return

        # Use lock to prevent conflicts during deletion
        with self._cache_lock:
            try:
                if target_dir.is_dir():
                    shutil.rmtree(target_dir)
                    logging.debug(
                        f"{log_prefix_clear} Successfully cleared {clear_desc} cache at {target_dir}."
                    )
                    # Recreate the base cache dir if we deleted the whole service cache
                    # Only if the base cache dir itself was the target_dir
                    if zoom_level is None and target_dir == self.cache_dir:
                        self._ensure_cache_dir_exists()
                else:
                    logging.warning(
                        f"{log_prefix_clear} Target path '{target_dir}' is not a directory. Cannot clear."
                    )
            except OSError as e:
                logging.error(
                    f"{log_prefix_clear} Error clearing cache at '{target_dir}': {e}"
                )
            except Exception as e:
                logging.exception(
                    f"{log_prefix_clear} Unexpected error clearing cache '{target_dir}':"
                )


# --- END OF FILE map_manager.py ---
