"""GDAL CLI discovery and capability checks for Study Area overlays."""

from __future__ import annotations

import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Sequence, Tuple


MIN_LAYER_ALGEBRA_VERSION = (3, 12, 0)


class GdalCapabilityError(RuntimeError):
    """Raised when GDAL exists but cannot support full Study Area overlays."""


def find_gdal_executable() -> str:
    """
    Locate a usable GDAL CLI.

    Preference is given to candidates that validate with both ``gdal --version``
    and ``gdal vector layer-algebra --help`` and report GDAL 3.12 or newer. If
    only older GDAL executables are present, return the first version-valid
    executable so callers can report a precise too-old diagnostic.
    """
    first_version_valid: Optional[str] = None

    for candidate in _iter_gdal_candidates():
        version = get_gdal_version(candidate)
        if version is None:
            continue

        if first_version_valid is None:
            first_version_valid = candidate

        if version >= MIN_LAYER_ALGEBRA_VERSION and supports_layer_algebra(candidate):
            return candidate

    if first_version_valid:
        return first_version_valid

    raise RuntimeError(
        "Could not locate a working GDAL command-line tool for Study Area "
        f"overlay processing.\n\n{format_install_guidance(sys.platform)}"
    )


def require_layer_algebra_gdal(platform_name: Optional[str] = None) -> str:
    """Return a GDAL executable that supports Study Area overlay processing."""
    gdal_executable = find_gdal_executable()
    version = get_gdal_version(gdal_executable)
    guidance = format_install_guidance(platform_name or sys.platform)

    if version is None:
        raise GdalCapabilityError(
            "Could not validate the GDAL command-line tool for Study Area "
            f"overlay processing at '{gdal_executable}'.\n\n{guidance}"
        )

    if version < MIN_LAYER_ALGEBRA_VERSION:
        raise GdalCapabilityError(
            "Study Area overlays require GDAL CLI 3.12 or newer because "
            "they use 'gdal vector layer-algebra'. "
            f"Found GDAL {_format_gdal_version(version)} at '{gdal_executable}'.\n\n"
            "QGIS 4.0.0 ships GDAL 3.11, which lacks this command. Upgrade to "
            "QGIS 4.0.2 or newer (GDAL 3.12+) to run Study Area overlays.\n\n"
            f"{guidance}"
        )

    if not supports_layer_algebra(gdal_executable):
        raise GdalCapabilityError(
            "The GDAL command-line tool was found, but it does not support "
            "'gdal vector layer-algebra'. "
            f"Found GDAL {_format_gdal_version(version)} at '{gdal_executable}'.\n\n"
            f"{guidance}"
        )

    return gdal_executable


def get_gdal_version(
    gdal_executable: Optional[str] = None,
) -> Optional[Tuple[int, int, int]]:
    """Return the GDAL CLI version tuple, or ``None`` if it cannot be validated."""
    executable = gdal_executable or find_gdal_executable()
    completed = _run_gdal_probe([executable, "--version"], executable)
    if completed is None or completed.returncode != 0:
        return None

    text = "\n".join(part for part in [completed.stdout, completed.stderr] if part)
    match = re.search(r"GDAL\s+(\d+)\.(\d+)(?:\.(\d+))?", text)
    if not match:
        return None

    major = int(match.group(1))
    minor = int(match.group(2))
    patch = int(match.group(3) or 0)
    return major, minor, patch


def supports_layer_algebra(gdal_executable: Optional[str] = None) -> bool:
    """Return whether the GDAL CLI exposes ``gdal vector layer-algebra``."""
    executable = gdal_executable or find_gdal_executable()
    completed = _run_gdal_probe(
        [executable, "vector", "layer-algebra", "--help"],
        executable,
    )
    return completed is not None and completed.returncode == 0


def build_gdal_environment(gdal_executable: str) -> Dict[str, str]:
    """Build an environment that lets GDAL find its sibling data/proj files."""
    env = os.environ.copy()
    gdal_path = Path(gdal_executable).resolve()
    gdal_bin = gdal_path.parent
    roots: List[Path] = [gdal_bin, gdal_bin.parent]

    roots.extend(_qgis_prefix_roots())
    roots.extend(_environment_roots())

    proj_candidates: List[Path] = []
    gdal_data_candidates: List[Path] = []
    for root in _unique_paths(roots):
        proj_candidates.extend(
            [
                root / "share" / "proj",
                root / "apps" / "proj" / "share" / "proj",
                root / "apps" / "proj-dev" / "share" / "proj",
                root / "Resources" / "proj",
            ]
        )
        gdal_data_candidates.extend(
            [
                root / "share" / "gdal",
                root / "apps" / "gdal" / "share" / "gdal",
                root / "apps" / "gdal-dev" / "share" / "gdal",
                root / "Resources" / "gdal",
            ]
        )

    for proj_dir in proj_candidates:
        if proj_dir.exists():
            env.setdefault("PROJ_LIB", str(proj_dir))
            env.setdefault("PROJ_DATA", str(proj_dir))
            break

    for gdal_data_dir in gdal_data_candidates:
        if gdal_data_dir.exists():
            env.setdefault("GDAL_DATA", str(gdal_data_dir))
            break

    if os.name == "nt":
        for root in [gdal_bin.parent, *_environment_roots()]:
            if (root / "bin").exists():
                env.setdefault("OSGEO4W_ROOT", str(root))
                break

    path_entries = [str(gdal_bin), env.get("PATH", "")]
    env["PATH"] = os.pathsep.join(entry for entry in path_entries if entry)
    return env


def format_install_guidance(platform: str) -> str:
    """Return platform-specific GDAL install guidance for user-facing errors."""
    normalized = (platform or sys.platform).lower()
    restart_note = (
        "After installing or upgrading GDAL, restart QGIS so it sees the updated PATH."
    )

    if normalized.startswith("win"):
        return (
            "Install QGIS/OSGeo4W with GDAL 3.12 or newer, or add the matching "
            "OSGeo4W/QGIS bin folder to PATH, for example "
            "C:\\OSGeo4W\\bin or C:\\Program Files\\QGIS <version>\\bin. "
            f"{restart_note}"
        )

    if normalized == "darwin" or normalized.startswith("mac"):
        return (
            "Install GDAL 3.12 or newer with Homebrew (brew install gdal), or "
            "use a QGIS build that includes GDAL 3.12+ and make its bin folder "
            "available on PATH. Common locations include /opt/homebrew/bin/gdal, "
            "/usr/local/bin/gdal, and the QGIS.app Contents/MacOS/bin folder. "
            f"{restart_note}"
        )

    return (
        "Install GDAL 3.12 or newer with your system package manager, Conda, "
        "Linuxbrew, or a QGIS package that includes the modern GDAL CLI. Common "
        "locations include /usr/bin/gdal, /usr/local/bin/gdal, and "
        "/home/linuxbrew/.linuxbrew/bin/gdal. "
        f"{restart_note}"
    )


def _format_gdal_version(version: Tuple[int, int, int]) -> str:
    return ".".join(str(part) for part in version)


def _run_gdal_probe(
    command: Sequence[str],
    gdal_executable: str,
) -> Optional[subprocess.CompletedProcess[str]]:
    try:
        return subprocess.run(
            list(command),
            capture_output=True,
            text=True,
            check=False,
            timeout=15,
            env=build_gdal_environment(gdal_executable),
            **_subprocess_window_kwargs(),
        )
    except Exception:
        return None


def _iter_gdal_candidates() -> Iterable[str]:
    names = ["gdal.exe", "gdal"] if os.name == "nt" else ["gdal", "gdal.exe"]
    candidates: List[Path] = []

    for name in names:
        resolved = shutil.which(name)
        if resolved:
            candidates.append(Path(resolved))

    for root in _qgis_prefix_roots():
        candidates.extend(_candidate_bins_for_root(root, names))

    candidates.extend(_known_platform_candidates(names))

    for candidate in _unique_paths(candidates):
        if candidate.exists() and candidate.is_file():
            yield str(candidate)


def _candidate_bins_for_root(root: Path, names: Sequence[str]) -> List[Path]:
    relative_dirs = [
        Path(""),
        Path("bin"),
        Path("apps") / "gdal" / "bin",
        Path("apps") / "gdal-dev" / "bin",
        # macOS QGIS bundles place the unified ``gdal`` CLI directly in
        # Contents/MacOS (alongside python3), not in a bin subfolder. Older
        # layouts used Contents/MacOS/bin, so keep both.
        Path("Contents") / "MacOS",
        Path("Contents") / "MacOS" / "bin",
        Path("Contents") / "Resources" / "bin",
    ]
    return [root / rel / name for rel in relative_dirs for name in names]


def _known_platform_candidates(names: Sequence[str]) -> List[Path]:
    candidates: List[Path] = []

    if os.name == "nt":
        roots = [
            Path("C:/OSGeo4W"),
            Path("C:/OSGeo4W64"),
        ]
        for env_name in ["OSGEO4W_ROOT", "QGIS_PREFIX_PATH"]:
            env_value = os.environ.get(env_name)
            if env_value:
                roots.append(Path(env_value))

        for program_files in ["ProgramFiles", "ProgramFiles(x86)"]:
            base = os.environ.get(program_files)
            if not base:
                continue
            base_path = Path(base)
            if base_path.exists():
                roots.extend(base_path.glob("QGIS*"))

        for root in roots:
            candidates.extend(_candidate_bins_for_root(root, names))
        return candidates

    fixed = [
        Path("/opt/homebrew/bin/gdal"),
        Path("/usr/local/bin/gdal"),
        Path("/usr/bin/gdal"),
        Path("/home/linuxbrew/.linuxbrew/bin/gdal"),
    ]
    candidates.extend(fixed)

    # macOS: the official QGIS.app bundle may be named QGIS.app, QGIS-LTR.app,
    # or carry an edition/version suffix with varying case and separators
    # (e.g. QGIS_Final-4.0.2.app, QGIS-final-4.0.0.app). Glob every */*.app
    # whose name starts with "qgis" and feed its Contents/MacOS to the shared
    # candidate logic, which covers both the bare Contents/MacOS layout (gdal
    # sits directly there in current builds) and the legacy bin subfolder.
    for apps_dir in [Path("/Applications"), Path.home() / "Applications"]:
        if not apps_dir.exists():
            continue
        for app in apps_dir.glob("*.app"):
            if not app.name.lower().startswith("qgis"):
                continue
            candidates.extend(
                _candidate_bins_for_root(app / "Contents" / "MacOS", names)
            )

    for prefix in [Path("/usr"), Path("/usr/local"), Path("/opt/homebrew")]:
        candidates.extend(_candidate_bins_for_root(prefix, names))

    return candidates


def _qgis_prefix_roots() -> List[Path]:
    roots: List[Path] = []
    for env_name in ["QGIS_PREFIX_PATH"]:
        env_value = os.environ.get(env_name)
        if env_value:
            roots.append(Path(env_value))

    try:
        from qgis.core import QgsApplication

        prefix_text = QgsApplication.prefixPath()
    except Exception:
        prefix_text = ""

    if prefix_text:
        prefix = Path(prefix_text)
        roots.extend([prefix, prefix.parent, prefix.parent.parent])

    return [root for root in roots if str(root)]


def _environment_roots() -> List[Path]:
    roots: List[Path] = []
    for env_name in ["OSGEO4W_ROOT", "GDAL_HOME", "CONDA_PREFIX", "QGIS_PREFIX_PATH"]:
        env_value = os.environ.get(env_name)
        if env_value:
            roots.append(Path(env_value))
    return roots


def _unique_paths(paths: Iterable[Path]) -> List[Path]:
    seen = set()
    unique: List[Path] = []
    for path in paths:
        key = str(path).lower() if os.name == "nt" else str(path)
        if key in seen:
            continue
        seen.add(key)
        unique.append(path)
    return unique


def _subprocess_window_kwargs() -> Dict[str, object]:
    if os.name != "nt":
        return {}

    kwargs: Dict[str, object] = {
        "creationflags": getattr(subprocess, "CREATE_NO_WINDOW", 0),
    }
    startupinfo_cls = getattr(subprocess, "STARTUPINFO", None)
    if startupinfo_cls is not None:
        startupinfo = startupinfo_cls()
        startupinfo.dwFlags |= getattr(subprocess, "STARTF_USESHOWWINDOW", 0)
        kwargs["startupinfo"] = startupinfo
    return kwargs


def build_geometry_cleanup_command(
    gdal_executable: str,
    source_path: str,
    source_layer: str,
    output_path: str,
    output_layer: str,
) -> List[str]:
    """argv for one ``gdal vector pipeline`` pass over a polygon Study Area.

    Drops every zero-area feature, then stamps MULTIPOLYGON on the layer so
    gpkg_geometry_columns matches what is actually stored.

    STEP ORDER IS THE WHOLE POINT. ``set-geom-type --geometry-type MULTIPOLYGON
    --skip`` does NOT skip closed linestrings -- it polygonizes them (a 10x10
    closed ring becomes a 100 m2 polygon), and it keeps degenerate zero-area
    polygons. Running the ST_Area filter FIRST removes both classes before
    anything can be coerced; set-geom-type then has only real polygons left to
    normalize. Reversing these two steps silently fabricates polygons.

    ST_Area is evaluated by the GeoPackage driver's own SQLite dialect (NOT
    OGRSQL: OGRSQL's OGR_GEOM_AREA returns the *enclosed* area of a closed
    linestring and would keep the artifacts).

    ponytail: the geometry column is hard-coded to GDAL's GeoPackage default
    ``geom``. Every input here is a GeoPackage this same overlay chain just
    wrote, so it always holds; if it ever stops holding, the SQL step exits
    non-zero and the caller raises with stderr logged rather than silently
    skipping the cleanup.
    """
    return [
        gdal_executable,
        "vector",
        "pipeline",
        "read",
        "--input",
        source_path,
        "--input-layer",
        source_layer,
        "!",
        "sql",
        "--sql",
        f'SELECT * FROM "{source_layer}" WHERE ST_Area(geom) > 0',
        "!",
        "set-geom-type",
        "--geometry-type",
        "MULTIPOLYGON",
        "--skip",
        "!",
        "write",
        "--output",
        output_path,
        "--output-layer",
        output_layer,
        "--format",
        "GPKG",
        "--overwrite",
        "--lco",
        "LAUNDER=NO",
    ]
