# -*- coding: utf-8 -*-
"""
GeoView Engine — Drone Photo Viewer for QGIS.

Creates georeferenced VRT files from drone photos using EXIF GPS
and camera sensor specs, then loads them as individual QGIS layers
in a dedicated layer group.

Key features:
- GPS Auto-Positioning from EXIF
- Scale-to-Altitude (GSD calculation)
- Smart AGL detection (MSL vs AGL)
- 18+ DJI camera profiles
- VRT Technology (zero-copy, ~1KB per photo)
- QGIS Layer Group with individual toggle

Author: Lộc Vũ Trung (LVT)
License: GPL-3.0
Source: Adapted from DroneFly/processing/image_stitcher.py
"""

import logging
import math
import os
from typing import Callable, Dict, List, Optional, Tuple

logger = logging.getLogger(__name__)


# ====================================================================
# Camera sensor database — (sensor_width_mm, default_focal_mm)
# ====================================================================

SENSOR_PROFILES: Dict[str, Tuple[float, float, str]] = {
    # model_id: (sensor_width_mm, default_focal_mm, drone_name)
    # --- DJI Air Series ---
    "FC8282": (17.3, 6.72, "DJI Air 3"),
    "FC7203": (9.7, 4.49, "DJI Air 2S"),
    # --- DJI Mini Series ---
    "FC3582": (9.7, 6.72, "DJI Mini 4 Pro"),
    "FC7303": (9.7, 4.49, "DJI Mini 3"),
    "FC3411": (9.7, 4.49, "DJI Mini 3 Pro"),
    "FC7403": (6.4, 4.49, "DJI Mini 2 SE"),
    # --- DJI Mavic Series ---
    "L2D-20c": (17.3, 12.29, "DJI Mavic 3 Classic"),
    "L1D-20c": (13.2, 10.26, "DJI Mavic 2 Pro"),
    "FC3170": (6.4, 4.49, "DJI Mavic Air 2"),
    "FC220": (6.17, 4.73, "DJI Mavic Pro"),
    "FC7103": (9.7, 6.72, "DJI Mavic 3 Pro"),
    # --- DJI Phantom Series ---
    "FC6310": (13.2, 8.8, "DJI Phantom 4 Pro"),
    "FC6520": (13.2, 8.8, "DJI Phantom 4 Pro V2"),
    "FC330": (6.17, 3.61, "DJI Phantom 4"),
    "FC300X": (6.17, 3.61, "DJI Phantom 3 Pro"),
    "FC300S": (6.17, 3.61, "DJI Phantom 3 Std"),
    # --- DJI Enterprise ---
    "FC6540": (17.3, 8.8, "DJI Phantom 4 RTK"),
    "M3E": (9.7, 4.49, "DJI Matrice 30"),
}

_DEFAULT_SENSOR_WIDTH_MM = 13.2
_DEFAULT_FOCAL_MM = 4.5

# Supported image extensions
_PHOTO_EXTENSIONS = {".jpg", ".jpeg", ".dng", ".tif", ".tiff", ".png"}


# ====================================================================
# Helper functions
# ====================================================================

def extract_gps(filepath: str) -> Dict:
    """Extract GPS and camera metadata from EXIF.

    Returns dict with keys: lat, lon, alt, focal_length, datetime,
    make, model.  Empty dict if no GPS data.
    """
    try:
        from PIL import Image
        from PIL.ExifTags import TAGS, GPSTAGS
    except ImportError:
        logger.error("Pillow not available for EXIF reading")
        return {}

    result: Dict = {}

    try:
        img = Image.open(filepath)
    except Exception as exc:
        logger.warning("Cannot open image %s: %s", filepath, exc)
        return result

    # Read EXIF tags
    exif_data: Dict = {}
    try:
        raw_exif = img._getexif()
        if raw_exif:
            for tag_id, value in raw_exif.items():
                tag_name = TAGS.get(tag_id, tag_id)
                exif_data[tag_name] = value
    except Exception:
        img.close()
        return result

    img_w, img_h = img.width, img.height
    result["img_width"] = img_w
    result["img_height"] = img_h
    img.close()

    # GPS Info
    gps_info_raw = exif_data.get("GPSInfo")
    if not gps_info_raw:
        return result

    decoded_gps: Dict = {}
    for key, val in gps_info_raw.items():
        decoded_key = GPSTAGS.get(key, key)
        decoded_gps[decoded_key] = val

    # Latitude/Longitude
    lat_dms = decoded_gps.get("GPSLatitude")
    lat_ref = decoded_gps.get("GPSLatitudeRef")
    lon_dms = decoded_gps.get("GPSLongitude")
    lon_ref = decoded_gps.get("GPSLongitudeRef")

    if not all([lat_dms, lat_ref, lon_dms, lon_ref]):
        return result

    try:
        lat = _dms_to_decimal(
            float(lat_dms[0]), float(lat_dms[1]), float(lat_dms[2]), lat_ref)
        lon = _dms_to_decimal(
            float(lon_dms[0]), float(lon_dms[1]), float(lon_dms[2]), lon_ref)
    except Exception as exc:
        logger.warning("GPS DMS conversion failed for %s: %s", filepath, exc)
        return result

    result["lat"] = lat
    result["lon"] = lon

    # Altitude
    try:
        alt = decoded_gps.get("GPSAltitude")
        if alt is not None:
            result["alt"] = float(alt)
    except (ValueError, TypeError):
        pass  # nosec B110

    # Focal length
    try:
        fl = exif_data.get("FocalLength")
        if fl is not None:
            result["focal_length"] = float(fl)
    except (ValueError, TypeError):
        pass  # nosec B110

    # DateTime
    dt_str = exif_data.get("DateTimeOriginal") or exif_data.get("DateTime")
    if dt_str:
        result["datetime"] = str(dt_str)

    # Camera make & model
    result["make"] = str(exif_data.get("Make", ""))
    result["model"] = str(exif_data.get("Model", ""))

    return result


def _dms_to_decimal(degrees, minutes, seconds, ref):
    """Convert degrees-minutes-seconds to decimal degrees."""
    decimal = degrees + minutes / 60.0 + seconds / 3600.0
    if ref in ("S", "W"):
        decimal = -decimal
    return decimal


def get_sensor_profile(model: str) -> Tuple[float, float, str]:
    """Look up sensor specs for a camera model.

    Returns (sensor_width_mm, focal_mm, drone_name).
    """
    for key, (sw, fl, name) in SENSOR_PROFILES.items():
        if key.lower() in model.lower():
            return (sw, fl, name)
    return (_DEFAULT_SENSOR_WIDTH_MM, _DEFAULT_FOCAL_MM, "Unknown")


def calculate_gsd(altitude_m, focal_length_mm, sensor_width_mm,
                  image_width_px):
    """Calculate Ground Sample Distance in metres per pixel.

    GSD = (altitude × sensor_width) / (focal_length × image_width)
    """
    if focal_length_mm <= 0 or image_width_px <= 0:
        return 0.05  # fallback: 5 cm/px
    return (altitude_m * sensor_width_mm) / (focal_length_mm * image_width_px)


def scan_photo_folder(folder: str, extent_rect=None) -> List[str]:
    """Scan folder recursively for supported image files.

    Args:
        folder: Path to scan.
        extent_rect: Optional (xmin, ymin, xmax, ymax) to filter by GPS.

    Returns:
        List of absolute paths to image files.
    """
    photos = []
    for root, _dirs, files in os.walk(folder):
        for f in sorted(files):
            ext = os.path.splitext(f)[1].lower()
            if ext in _PHOTO_EXTENSIONS:
                photos.append(os.path.join(root, f))
    return photos


# ====================================================================
# GeoView Engine
# ====================================================================

class GeoViewEngine:
    """Creates georeferenced VRT files and loads them as QGIS layers.

    Each drone photo becomes an individual raster layer placed at its
    GPS position with the correct ground scale based on altitude and
    camera sensor specifications.
    """

    def __init__(
        self,
        photo_folder: str,
        flight_altitude_agl: Optional[float] = None,
        progress_callback: Optional[Callable] = None,
        extent_filter=None,
    ):
        self.photo_folder = photo_folder
        self.flight_altitude_agl = flight_altitude_agl
        self.progress_callback = progress_callback
        self.extent_filter = extent_filter  # (xmin, ymin, xmax, ymax)
        self.vrt_files: List[str] = []
        self.photo_info: List[Dict] = []
        self.detected_camera = "Unknown"
        self.detected_agl = 0.0
        self.total_coverage_ha = 0.0

    def run(self) -> str:
        """Execute the GeoView pipeline.

        Returns:
            Path to VRT output directory.

        Raises:
            RuntimeError: If no photos could be processed.
        """
        self._report(0, "Scanning photos...")

        # 1. Scan folder
        photos = scan_photo_folder(self.photo_folder)
        total = len(photos)
        if total == 0:
            raise RuntimeError("No photos found in the selected folder.")

        self._report(5, f"Found {total} photos, reading EXIF...")

        # 2. Read EXIF from sample to detect camera + altitude
        sample_data = []
        for p in photos[:min(20, total)]:
            gps = extract_gps(p)
            if gps.get("lat") is not None:
                sample_data.append(gps)

        if not sample_data:
            raise RuntimeError(
                "No GPS data found in photos.\n"
                "Make sure photos have EXIF GPS tags (drone photos)."
            )

        # Detect camera
        model = sample_data[0].get("model", "")
        sw, fl, drone_name = get_sensor_profile(model)
        self.detected_camera = f"{drone_name} ({model})" if model else "Unknown"

        # 3. Smart AGL detection
        agl = self.flight_altitude_agl
        if agl is None or agl <= 0:
            sample_alts = [d["alt"] for d in sample_data if d.get("alt")]
            if sample_alts:
                avg_msl = sum(sample_alts) / len(sample_alts)
                alt_spread = max(sample_alts) - min(sample_alts)
                if avg_msl > 300 and alt_spread < 100:
                    # Highland area, GPS reports MSL
                    agl = 80.0
                    self._report(8, f"MSL detected ({avg_msl:.0f}m), using AGL={agl:.0f}m")
                elif avg_msl <= 300:
                    agl = avg_msl
                else:
                    agl = 100.0
            else:
                agl = 100.0

        self.detected_agl = agl
        self._report(10, f"Camera: {self.detected_camera}, AGL: {agl:.0f}m")

        # 4. Create VRT output directory
        vrt_dir = os.path.join(self.photo_folder, ".geoview_vrt")
        os.makedirs(vrt_dir, exist_ok=True)

        # 5. Process each photo
        vrt_files = []
        skipped = 0
        total_area = 0.0

        for idx, photo_path in enumerate(photos):
            pct = 10 + int(85 * (idx / total))
            self._report(pct, f"[{idx + 1}/{total}] {os.path.basename(photo_path)}")

            gps = extract_gps(photo_path)
            if "lat" not in gps or "lon" not in gps:
                skipped += 1
                continue

            # Filter by map extent
            if self.extent_filter:
                xmin, ymin, xmax, ymax = self.extent_filter
                if not (xmin <= gps["lon"] <= xmax and ymin <= gps["lat"] <= ymax):
                    skipped += 1
                    continue

            try:
                vrt_path, area_ha = self._write_vrt(
                    photo_path, gps, vrt_dir, agl, sw, fl)
                vrt_files.append(vrt_path)
                total_area += area_ha
                self.photo_info.append({
                    "path": photo_path,
                    "vrt": vrt_path,
                    "lat": gps["lat"],
                    "lon": gps["lon"],
                    "alt": gps.get("alt", 0),
                    "name": os.path.basename(photo_path),
                })
            except Exception as exc:
                logger.error("VRT failed for %s: %s", photo_path, exc)
                skipped += 1

        if not vrt_files:
            raise RuntimeError(
                "No images could be georeferenced.\n"
                "Check that photos have valid GPS EXIF data."
            )

        self.vrt_files = vrt_files
        self.total_coverage_ha = total_area

        self._report(
            100,
            f"✅ {len(vrt_files)} photos loaded ({skipped} skipped), "
            f"Coverage: {total_area:.1f} ha"
        )
        return vrt_dir

    @staticmethod
    def _write_vrt(photo_path, gps, vrt_dir, agl, sensor_w, default_focal):
        """Write a single georeferenced VRT file.

        Returns (vrt_path, area_hectares).
        """
        from PIL import Image as PILImage

        img = PILImage.open(photo_path)
        img_w, img_h = img.width, img.height
        img.close()

        lat = gps["lat"]
        lon = gps["lon"]
        focal = gps.get("focal_length")
        model = gps.get("model", "")

        sw, fl_default, _ = get_sensor_profile(model)
        if sensor_w > 0:
            sw = sensor_w
        if focal is None or focal <= 0:
            focal = default_focal if default_focal > 0 else fl_default

        gsd_m = calculate_gsd(agl, focal, sw, img_w)

        # GSD in degrees
        cos_lat = math.cos(math.radians(abs(lat)))
        if cos_lat < 0.001:
            cos_lat = 0.001
        pixel_w_deg = gsd_m / (111320.0 * cos_lat)
        pixel_h_deg = gsd_m / 110540.0

        # Upper-left corner (photo center = GPS position)
        ul_x = lon - (img_w / 2.0) * pixel_w_deg
        ul_y = lat + (img_h / 2.0) * pixel_h_deg

        # Coverage area in hectares
        ground_w = img_w * gsd_m
        ground_h = img_h * gsd_m
        area_ha = (ground_w * ground_h) / 10000.0

        # Escape path for XML
        src_path = photo_path.replace('&', '&amp;')

        # Detect bands (RGB=3, RGBA=4)
        bands = 3
        band_xml = ""
        color_interps = ["Red", "Green", "Blue", "Alpha"]
        for b in range(1, bands + 1):
            ci = color_interps[b - 1] if b <= len(color_interps) else "Undefined"
            band_xml += f"""  <VRTRasterBand dataType="Byte" band="{b}">
    <ColorInterp>{ci}</ColorInterp>
    <SimpleSource>
      <SourceFilename relativeToVRT="0">{src_path}</SourceFilename>
      <SourceBand>{b}</SourceBand>
      <SrcRect xOff="0" yOff="0" xSize="{img_w}" ySize="{img_h}" />
      <DstRect xOff="0" yOff="0" xSize="{img_w}" ySize="{img_h}" />
    </SimpleSource>
  </VRTRasterBand>
"""

        vrt_xml = f"""<VRTDataset rasterXSize="{img_w}" rasterYSize="{img_h}">
  <SRS>EPSG:4326</SRS>
  <GeoTransform>{ul_x}, {pixel_w_deg}, 0.0, {ul_y}, 0.0, {-pixel_h_deg}</GeoTransform>
{band_xml}</VRTDataset>
"""

        basename = os.path.splitext(os.path.basename(photo_path))[0]
        vrt_path = os.path.join(vrt_dir, f"{basename}.vrt")

        with open(vrt_path, 'w', encoding='utf-8') as fh:
            fh.write(vrt_xml)

        return vrt_path, area_ha

    def load_to_qgis(self, group_name="📷 Drone Photos", max_visible=10):
        """Load all VRT files into a QGIS layer group.

        Args:
            group_name: Name of the layer group in the Layers panel.
            max_visible: How many photos to show by default (rest hidden).

        Returns:
            The QgsLayerTreeGroup created.
        """
        from qgis.core import QgsRasterLayer, QgsProject

        root = QgsProject.instance().layerTreeRoot()

        # Remove existing group with same name
        existing = root.findGroup(group_name)
        if existing:
            root.removeChildNode(existing)

        # Create new group at top
        group = root.insertGroup(0, group_name)

        for idx, vrt_path in enumerate(self.vrt_files):
            basename = os.path.splitext(os.path.basename(vrt_path))[0]
            layer = QgsRasterLayer(vrt_path, basename)

            if layer.isValid():
                # Set 70% opacity for better overlay viewing
                layer.renderer().setOpacity(0.7)
                QgsProject.instance().addMapLayer(layer, False)
                node = group.addLayer(layer)

                # Show first N photos, hide rest
                if idx >= max_visible:
                    node.setItemVisibilityChecked(False)
            else:
                logger.warning("Invalid VRT layer: %s", vrt_path)

        # Expand group in legend
        group.setExpanded(True)

        return group

    def _report(self, percent, message):
        """Send progress update."""
        logger.debug("[%d%%] %s", percent, message)
        if self.progress_callback:
            try:
                self.progress_callback(percent, message)
            except Exception:  # nosec B110
                pass  # nosec B110
