from __future__ import annotations

import logging
import math
from pathlib import Path

import numpy as np

from ._compat import GDAL_ENV

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

# Conditional imports based on the backend chosen in _compat
if GDAL_ENV:
    from osgeo import gdal
else:
    import pyproj
    import rasterio
    from rasterio.features import rasterize
    from rasterio.mask import mask
    from rasterio.transform import Affine, from_origin
    from rasterio.windows import Window
    from shapely import geometry


FLOAT_TOLERANCE = 1e-9


def _assert_north_up(transform) -> None:
    """Ensure the raster transform describes a north-up raster."""
    if hasattr(transform, "b") and hasattr(transform, "d"):
        if not math.isclose(transform.b, 0.0, abs_tol=FLOAT_TOLERANCE) or not math.isclose(
            transform.d, 0.0, abs_tol=FLOAT_TOLERANCE
        ):
            raise ValueError("Only north-up rasters (no rotation) are supported.")
    else:
        # GDAL-style tuple (c, a, b, f, d, e)
        if len(transform) < 6:
            raise ValueError("Transform must contain 6 elements.")
        if not math.isclose(transform[2], 0.0, abs_tol=FLOAT_TOLERANCE) or not math.isclose(
            transform[4], 0.0, abs_tol=FLOAT_TOLERANCE
        ):
            raise ValueError("Only north-up rasters (no rotation) are supported.")


def _shrink_axis_to_grid(min_val: float, max_val: float, origin: float, pixel_size: float) -> tuple[float, float]:
    if pixel_size == 0:
        raise ValueError("Pixel size must be non-zero to shrink bbox to pixel grid.")
    step = abs(pixel_size)
    start_idx = math.ceil(((min_val - origin) / step) - FLOAT_TOLERANCE)
    end_idx = math.floor(((max_val - origin) / step) + FLOAT_TOLERANCE)
    new_min = origin + start_idx * step
    new_max = origin + end_idx * step
    if not new_max > new_min:
        raise ValueError("Bounding box collapsed after snapping to the pixel grid.")
    return new_min, new_max


def shrink_bbox_to_pixel_grid(
    bbox: tuple[float, float, float, float],
    origin_x: float,
    origin_y: float,
    pixel_width: float,
    pixel_height: float,
) -> tuple[float, float, float, float]:
    """Shrink bbox so its edges land on the pixel grid defined by the raster origin."""

    minx, miny, maxx, maxy = bbox
    if minx >= maxx or miny >= maxy:
        raise ValueError("Bounding box is invalid (min must be < max for both axes).")
    snapped_minx, snapped_maxx = _shrink_axis_to_grid(minx, maxx, origin_x, pixel_width)
    snapped_miny, snapped_maxy = _shrink_axis_to_grid(miny, maxy, origin_y, pixel_height)
    return snapped_minx, snapped_miny, snapped_maxx, snapped_maxy


def _bounds_to_tuple(bounds) -> tuple[float, float, float, float]:
    if hasattr(bounds, "left"):
        return bounds.left, bounds.bottom, bounds.right, bounds.top
    return tuple(bounds)


def _validate_bbox_within_bounds(
    bbox: tuple[float, float, float, float], bounds, *, tol: float = FLOAT_TOLERANCE
) -> None:
    minx, miny, maxx, maxy = bbox
    left, bottom, right, top = _bounds_to_tuple(bounds)
    if minx < left - tol or maxx > right + tol or miny < bottom - tol or maxy > top + tol:
        raise ValueError("Bounding box is not fully contained within the raster dataset bounds")


def _compute_bounds_from_transform(transform, width: int, height: int) -> tuple[float, float, float, float]:
    """Return raster bounds for a GDAL-style transform tuple."""
    left = transform[0]
    top = transform[3]
    right = transform[0] + width * transform[1]
    bottom = transform[3] + height * transform[5]
    minx = min(left, right)
    maxx = max(left, right)
    miny = min(top, bottom)
    maxy = max(top, bottom)
    return minx, miny, maxx, maxy


def _normalise_bbox(bbox_sequence) -> tuple[float, float, float, float]:
    try:
        minx, miny, maxx, maxy = bbox_sequence
    except Exception as exc:  # noqa: BLE001
        raise ValueError("Bounding box must contain exactly four numeric values") from exc
    return float(minx), float(miny), float(maxx), float(maxy)


def rasterise_gdf(gdf, geom_col, ht_col, bbox=None, pixel_size: float = 1.0):
    # Define raster parameters
    if bbox is not None:
        # Unpack bbox values
        minx, miny, maxx, maxy = _normalise_bbox(bbox)
    else:
        # Use the total bounds of the GeoDataFrame
        minx, miny, maxx, maxy = map(float, gdf.total_bounds)
    if pixel_size <= 0:
        raise ValueError("Pixel size must be a positive number.")
    minx, miny, maxx, maxy = shrink_bbox_to_pixel_grid(
        (minx, miny, maxx, maxy),
        origin_x=minx,
        origin_y=maxy,
        pixel_width=pixel_size,
        pixel_height=pixel_size,
    )
    width = int(round((maxx - minx) / pixel_size))
    height = int(round((maxy - miny) / pixel_size))
    if width <= 0 or height <= 0:
        raise ValueError("Bounding box collapsed after snapping to pixel grid.")
    transform = from_origin(minx, maxy, pixel_size, pixel_size)
    # Create a blank array for the raster
    raster = np.zeros((height, width), dtype=np.float32)
    # Burn geometries into the raster
    shapes = ((geom, value) for geom, value in zip(gdf[geom_col], gdf[ht_col]))
    raster = rasterize(shapes, out_shape=raster.shape, transform=transform, fill=0, dtype=np.float32)

    return raster, transform


def check_path(path_str: str | Path, make_dir: bool = False) -> Path:
    # Ensure path exists
    path = Path(path_str).absolute()
    if not path.parent.exists():
        if make_dir:
            path.parent.mkdir(parents=True, exist_ok=True)
        else:
            raise OSError(
                f"Parent directory {path.parent} does not exist for path {path}. Set make_dir=True to create it."
            )
    if not path.exists() and not path.suffix:
        if make_dir:
            path.mkdir(parents=True, exist_ok=True)
        else:
            raise OSError(f"Path {path} does not exist. Set make_dir=True to create it.")
    return path


# Default color scale ranges for preview images (ensures consistency across timesteps)
# Format: prefix -> (vmin, vmax)
_PREVIEW_RANGES: dict[str, tuple[float, float]] = {
    "tmrt": (0, 80),  # Mean radiant temperature (°C)
    "utci": (-40, 50),  # Universal Thermal Climate Index (°C)
    "pet": (-40, 50),  # Physiological Equivalent Temperature (°C)
    "shadow": (0, 1),  # Shadow fraction (0=sun, 1=shade)
    "kdown": (0, 1200),  # Downwelling shortwave radiation (W/m²)
    "kup": (0, 800),  # Upwelling shortwave radiation (W/m²)
    "ldown": (150, 550),  # Downwelling longwave radiation (W/m²)
    "lup": (250, 650),  # Upwelling longwave radiation (W/m²)
    "svf": (0, 1),  # Sky view factor
    "gvf": (0, 1),  # Ground view factor
}


def _get_preview_range(filename: str) -> tuple[float, float] | None:
    """Get the color scale range for a variable based on filename prefix."""
    name = filename.lower()
    for prefix, range_vals in _PREVIEW_RANGES.items():
        if name.startswith(prefix):
            return range_vals
    return None


def _generate_preview_png(data_arr: np.ndarray, out_path: Path, max_size: int = 512, colormap: str = "turbo") -> None:
    """
    Generate a color PNG preview image from raster data.

    Uses consistent color scales for known variable types (tmrt, utci, shadow, etc.)
    to enable visual comparison across timesteps. Falls back to percentile-based
    scaling for unknown variables.

    Args:
        data_arr: 2D numpy array to visualize
        out_path: Output file path (preview will be saved as .preview.png)
        max_size: Maximum dimension for the preview image (maintains aspect ratio)
        colormap: Matplotlib colormap name (default: 'turbo'). Falls back to grayscale if unavailable.
                  Common options: 'turbo', 'viridis', 'plasma', 'inferno', 'magma', 'coolwarm'
    """
    try:
        from PIL import Image

        # Handle NaN values
        valid_mask = ~np.isnan(data_arr)
        if not np.any(valid_mask):
            return  # All NaN, skip preview

        # Use variable-specific range if available, otherwise fall back to percentiles
        preset_range = _get_preview_range(out_path.stem)
        if preset_range is not None:
            vmin, vmax = preset_range
        else:
            # Fallback: use percentiles for unknown variables
            valid_data = data_arr[valid_mask]
            vmin, vmax = np.nanpercentile(valid_data, [2, 98])

        if vmax <= vmin:
            vmax = vmin + 1  # Avoid division by zero

        # Normalize to 0-1
        normalized = np.clip((data_arr - vmin) / (vmax - vmin), 0, 1)
        normalized = np.nan_to_num(normalized, nan=0)

        # Try to apply matplotlib colormap for color output
        try:
            import matplotlib.pyplot as plt

            # Get colormap and apply
            cmap = plt.get_cmap(colormap)
            colored = cmap(normalized)  # Returns RGBA in [0, 1]

            # Convert to RGB uint8 (drop alpha channel)
            rgb = (colored[:, :, :3] * 255).astype(np.uint8)
            img = Image.fromarray(rgb, mode="RGB")
        except (ImportError, ValueError):
            # Fallback to grayscale if matplotlib not available or colormap invalid
            grayscale = (normalized * 255).astype(np.uint8)
            img = Image.fromarray(grayscale, mode="L")

        # Resize to max_size while maintaining aspect ratio
        if max(img.size) > max_size:
            ratio = max_size / max(img.size)
            new_size = (int(img.width * ratio), int(img.height * ratio))
            img = img.resize(new_size, Image.Resampling.LANCZOS)

        # Save preview
        preview_path = out_path.with_suffix(".preview.png")
        img.save(preview_path, "PNG")
        logger.debug(f"Saved preview: {preview_path}")
    except ImportError:
        logger.debug("PIL not available, skipping preview generation")
    except Exception as e:
        logger.warning(f"Failed to generate preview: {e}")


def save_raster(
    out_path_str: str,
    data_arr: np.ndarray,
    trf_arr: list[float],
    crs_wkt: str | None,
    no_data_val: float = -9999,
    coerce_f64_to_f32: bool = True,
    use_cog: bool = True,
    generate_preview: bool = True,
):
    """
    Save raster to GeoTIFF (Cloud-Optimized by default).

    Args:
        out_path_str: Output file path
        data_arr: 2D numpy array to save
        trf_arr: GDAL-style geotransform [top_left_x, pixel_width, rotation, top_left_y, rotation, pixel_height]
        crs_wkt: CRS in WKT format
        no_data_val: No-data value to use
        coerce_f64_to_f32: If True, convert float64 arrays to float32 before saving
                           (default: True for memory efficiency)
        use_cog: If True, save as Cloud-Optimized GeoTIFF with built-in overviews
                 (default: True for better OS thumbnail support)
        generate_preview: If True, generate a sidecar .preview.png file for OS thumbnails
                         (default: True for float data that can't be previewed directly)
    """
    # Only convert float64 to float32, leave ints/bools unchanged
    if coerce_f64_to_f32 and data_arr.dtype == np.float64:
        data_arr = data_arr.astype(np.float32)

    attempts = 2
    while attempts > 0:
        attempts -= 1
        try:
            out_path = check_path(out_path_str, make_dir=True)
            height, width = data_arr.shape

            if GDAL_ENV is False:
                trf = Affine.from_gdal(*trf_arr)
                crs = None
                if crs_wkt:
                    crs = pyproj.CRS(crs_wkt)

                if use_cog:
                    # Write as Cloud-Optimized GeoTIFF
                    # COG driver creates overviews automatically
                    from rasterio.io import MemoryFile

                    # Create in memory first, then write as COG
                    memfile = MemoryFile()
                    with memfile.open(
                        driver="GTiff",
                        height=height,
                        width=width,
                        count=1,
                        dtype=data_arr.dtype,
                        crs=crs,
                        transform=trf,
                        nodata=no_data_val,
                    ) as mem:
                        mem.write(data_arr, 1)

                    # Now copy to COG format
                    from rasterio.shutil import copy

                    copy(
                        memfile.open(),
                        out_path,
                        driver="COG",
                        overview_resampling="average",
                    )
                    memfile.close()
                    logger.debug(f"Saved COG: {out_path}")
                else:
                    # Standard GeoTIFF
                    with rasterio.open(
                        out_path,
                        "w",
                        driver="GTiff",
                        height=height,
                        width=width,
                        count=1,
                        dtype=data_arr.dtype,
                        crs=crs,
                        transform=trf,
                        nodata=no_data_val,
                    ) as dst:
                        dst.write(data_arr, 1)
            else:
                # GDAL backend
                if use_cog:
                    # Use COG driver (GDAL 3.1+)
                    driver = gdal.GetDriverByName("COG")
                    if driver is None:
                        # Fallback to GTiff with overviews if COG driver not available
                        logger.warning("COG driver not available, using GTiff with overviews")
                        driver = gdal.GetDriverByName("GTiff")
                        options = ["TILED=YES"]
                        ds = driver.Create(str(out_path), width, height, 1, gdal.GDT_Float32, options)
                        ds.SetGeoTransform(trf_arr)
                        if crs_wkt:
                            ds.SetProjection(crs_wkt)
                        band = ds.GetRasterBand(1)
                        band.SetNoDataValue(no_data_val)
                        band.WriteArray(data_arr)
                        # Build overviews
                        if min(height, width) > 256:
                            overview_levels = []
                            size = min(height, width)
                            level = 2
                            while size // level > 128:
                                overview_levels.append(level)
                                level *= 2
                            if overview_levels:
                                ds.BuildOverviews("AVERAGE", overview_levels)
                        ds = None
                    else:
                        # COG driver requires creating via CreateCopy from memory dataset
                        mem_driver = gdal.GetDriverByName("MEM")
                        mem_ds = mem_driver.Create("", width, height, 1, gdal.GDT_Float32)
                        mem_ds.SetGeoTransform(trf_arr)
                        if crs_wkt:
                            mem_ds.SetProjection(crs_wkt)
                        band = mem_ds.GetRasterBand(1)
                        band.SetNoDataValue(no_data_val)
                        band.WriteArray(data_arr)

                        # Copy to COG
                        cog_options = ["OVERVIEW_RESAMPLING=AVERAGE"]
                        driver.CreateCopy(str(out_path), mem_ds, options=cog_options)
                        mem_ds = None
                        logger.debug(f"Saved COG: {out_path}")
                else:
                    # Standard GeoTIFF
                    driver = gdal.GetDriverByName("GTiff")
                    ds = driver.Create(str(out_path), width, height, 1, gdal.GDT_Float32)
                    ds.SetGeoTransform(trf_arr)
                    if crs_wkt:
                        ds.SetProjection(crs_wkt)
                    band = ds.GetRasterBand(1)
                    band.SetNoDataValue(no_data_val)
                    band.WriteArray(data_arr)
                    ds = None

            # Generate sidecar preview PNG for float data (OS can't render float GeoTIFFs)
            if generate_preview and np.issubdtype(data_arr.dtype, np.floating):
                _generate_preview_png(data_arr, out_path)

            return
        except Exception as e:
            if attempts == 0:
                raise e
            logger.warning(f"Failed to save raster to {out_path_str}: {e}. Retrying...")


def get_raster_metadata(path_str: str | Path) -> dict:
    """
    Get raster metadata without loading the whole file.
    Returns dict with keys: rows, cols, transform, crs, nodata, res.
    Transform is always a list [c, a, b, f, d, e] (GDAL-style).
    CRS is always a WKT string (or None).
    """
    path = check_path(path_str)
    if GDAL_ENV is False:
        with rasterio.open(path) as src:
            # Convert Affine to GDAL-style list
            trf = src.transform
            transform_list = [trf.c, trf.a, trf.b, trf.f, trf.d, trf.e]
            # Convert CRS to WKT string
            crs_wkt = src.crs.to_wkt() if src.crs is not None else None
            return {
                "rows": src.height,
                "cols": src.width,
                "transform": transform_list,
                "crs": crs_wkt,
                "nodata": src.nodata,
                "res": src.res,  # (xres, yres)
                "bounds": src.bounds,
            }
    else:
        ds = gdal.Open(str(path))
        if ds is None:
            raise OSError(f"Could not open {path}")
        gt = ds.GetGeoTransform()
        return {
            "rows": ds.RasterYSize,
            "cols": ds.RasterXSize,
            "transform": gt,
            "crs": ds.GetProjection() or None,
            "nodata": ds.GetRasterBand(1).GetNoDataValue(),
            "res": (gt[1], abs(gt[5])),  # Approximate resolution
        }


def read_raster_window(path_str: str | Path, window: tuple[slice, slice], band: int = 1) -> np.ndarray:
    """
    Read a window from a raster file.
    window is (row_slice, col_slice).
    """
    path = check_path(path_str)
    row_slice, col_slice = window

    # Handle None slices (read full dimension)
    # This is tricky without knowing full shape, so we assume caller provides valid slices
    # or we'd need to open file to check shape first.
    # For now, assume valid integer slices.

    if GDAL_ENV is False:
        with rasterio.open(path) as src:
            # rasterio Window(col_off, row_off, width, height)
            # Slices are start:stop
            r_start = row_slice.start if row_slice.start is not None else 0
            r_stop = row_slice.stop if row_slice.stop is not None else src.height
            c_start = col_slice.start if col_slice.start is not None else 0
            c_stop = col_slice.stop if col_slice.stop is not None else src.width

            win = Window(c_start, r_start, c_stop - c_start, r_stop - r_start)  # type: ignore[too-many-positional-arguments]
            return src.read(band, window=win)
    else:
        ds = gdal.Open(str(path))
        if ds is None:
            raise OSError(f"Could not open {path}")

        r_start = row_slice.start if row_slice.start is not None else 0
        r_stop = row_slice.stop if row_slice.stop is not None else ds.RasterYSize
        c_start = col_slice.start if col_slice.start is not None else 0
        c_stop = col_slice.stop if col_slice.stop is not None else ds.RasterXSize

        xoff = c_start
        yoff = r_start
        xsize = c_stop - c_start
        ysize = r_stop - r_start

        return ds.GetRasterBand(band).ReadAsArray(xoff, yoff, xsize, ysize)


def load_raster(
    path_str: str, bbox: list[int] | None = None, band: int = 0, coerce_f64_to_f32: bool = True
) -> tuple[np.ndarray, list[float], str | None, float | None]:
    """
    Load raster, optionally crop to bbox.

    Args:
        path_str: Path to raster file
        bbox: Optional bounding box [minx, miny, maxx, maxy]
        band: Band index to read (0-based)
        coerce_f64_to_f32: If True, coerce array to float32 (default: True for memory efficiency)

    Returns:
        Tuple of (array, transform, crs_wkt, no_data_value)
    """
    # Load raster, optionally crop to bbox
    path = check_path(path_str, make_dir=False)
    if not path.exists():
        raise FileNotFoundError(f"Raster file {path} does not exist.")
    if GDAL_ENV is False:
        with rasterio.open(path) as dataset:
            _assert_north_up(dataset.transform)
            crs_wkt = dataset.crs.to_wkt() if dataset.crs is not None else None
            no_data_val = dataset.nodata
            transform = dataset.transform
            if bbox is not None:
                bbox_tuple = _normalise_bbox(bbox)
                snapped_bbox = shrink_bbox_to_pixel_grid(
                    bbox_tuple,
                    origin_x=transform.c,
                    origin_y=transform.f,
                    pixel_width=transform.a,
                    pixel_height=transform.e,
                )
                _validate_bbox_within_bounds(snapped_bbox, dataset.bounds)
                bbox_geom = geometry.box(*snapped_bbox)
                rast, trf = mask(dataset, [bbox_geom], crop=True)
            else:
                rast = dataset.read()
                trf = transform
            # Convert rasterio Affine to GDAL-style list
            trf_arr = [trf.c, trf.a, trf.b, trf.f, trf.d, trf.e]
            # rast shape: (bands, rows, cols)
            if rast.ndim == 3:
                if band < 0 or band >= rast.shape[0]:
                    raise IndexError(f"Requested band {band} out of range; raster has {rast.shape[0]} band(s)")
                rast_arr = rast[band]
                # Only convert float64 to float32, leave ints/bools unchanged
                if coerce_f64_to_f32 and rast_arr.dtype == np.float64:
                    rast_arr = rast_arr.astype(np.float32)
            else:
                rast_arr = rast
                # Only convert float64 to float32, leave ints/bools unchanged
                if coerce_f64_to_f32 and rast_arr.dtype == np.float64:
                    rast_arr = rast_arr.astype(np.float32)
    else:
        dataset = gdal.Open(str(path))
        if dataset is None:
            raise FileNotFoundError(f"Could not open {path}")
        trf = dataset.GetGeoTransform()
        _assert_north_up(trf)
        # GetProjection returns WKT string (or empty string)
        crs_wkt = dataset.GetProjection() or None
        rb = dataset.GetRasterBand(band + 1)
        if rb is None:
            dataset = None
            raise IndexError(f"Requested band {band} out of range in GDAL dataset")
        rast_arr = rb.ReadAsArray()
        # Only convert float64 to float32, leave ints/bools unchanged
        if coerce_f64_to_f32 and rast_arr.dtype == np.float64:
            rast_arr = rast_arr.astype(np.float32)
        no_data_val = rb.GetNoDataValue()
        if bbox is not None:
            bbox_tuple = _normalise_bbox(bbox)
            snapped_bbox = shrink_bbox_to_pixel_grid(
                bbox_tuple,
                origin_x=trf[0],
                origin_y=trf[3],
                pixel_width=trf[1],
                pixel_height=trf[5],
            )
            bounds = _compute_bounds_from_transform(trf, dataset.RasterXSize, dataset.RasterYSize)
            _validate_bbox_within_bounds(snapped_bbox, bounds)
            min_x, min_y, max_x, max_y = snapped_bbox
            pixel_width = trf[1]
            pixel_height = abs(trf[5])
            xoff = int(round((min_x - trf[0]) / pixel_width))
            yoff = int(round((trf[3] - max_y) / pixel_height))
            xsize = int(round((max_x - min_x) / pixel_width))
            ysize = int(round((max_y - min_y) / pixel_height))
            # guard offsets/sizes
            if xoff < 0 or yoff < 0 or xsize <= 0 or ysize <= 0:
                dataset = None
                raise ValueError("Computed window from bbox is out of raster bounds or invalid")
            rast_arr = rast_arr[yoff : yoff + ysize, xoff : xoff + xsize]
            trf_arr = [min_x, trf[1], 0, max_y, 0, trf[5]]
        else:
            trf_arr = [trf[0], trf[1], 0, trf[3], 0, trf[5]]
        dataset = None  # ensure dataset closed
    # Handle no-data (support NaN)
    if no_data_val is not None and not np.isnan(no_data_val):
        logger.info(f"No-data value is {no_data_val}, replacing with NaN")
        rast_arr[rast_arr == no_data_val] = np.nan
    if rast_arr.size == 0:
        raise ValueError("Raster array is empty after loading/cropping")
    return rast_arr, trf_arr, crs_wkt, no_data_val


def xy_to_lnglat(crs_wkt: str | None, x, y):
    """Convert x, y coordinates to longitude and latitude.

    Accepts scalar or array-like x/y. If crs_wkt is None the inputs are
    assumed already to be lon/lat and are returned unchanged.
    """
    if crs_wkt is None:
        logger.info("No CRS provided, assuming coordinates are already in WGS84 (lon/lat).")
        return x, y

    try:
        if GDAL_ENV is False:
            source_crs = pyproj.CRS(crs_wkt)
            target_crs = pyproj.CRS(4326)  # WGS84
            transformer = pyproj.Transformer.from_crs(source_crs, target_crs, always_xy=True)
            lng, lat = transformer.transform(x, y)
        else:
            old_cs = gdal.osr.SpatialReference()
            old_cs.ImportFromWkt(crs_wkt)
            new_cs = gdal.osr.SpatialReference()
            new_cs.ImportFromEPSG(4326)
            transform = gdal.osr.CoordinateTransformation(old_cs, new_cs)
            out = transform.TransformPoint(float(x), float(y))
            lng, lat = out[0], out[1]

        return lng, lat

    except Exception:
        logger.exception("Failed to transform coordinates")
        raise


def create_empty_raster(
    path_str: str | Path,
    rows: int,
    cols: int,
    transform: list[float],
    crs_wkt: str,
    dtype=np.float32,
    nodata: float = -9999,
    bands: int = 1,
):
    """
    Create an empty GeoTIFF file initialized with nodata.
    """
    path = check_path(path_str, make_dir=True)

    if GDAL_ENV is False:
        trf = Affine.from_gdal(*transform)
        crs = None
        if crs_wkt:
            crs = pyproj.CRS(crs_wkt)

        with rasterio.open(
            path,
            "w",
            driver="GTiff",
            height=rows,
            width=cols,
            count=bands,
            dtype=dtype,
            crs=crs,
            transform=trf,
            nodata=nodata,
        ):
            pass  # Just create empty raster
    else:
        driver = gdal.GetDriverByName("GTiff")
        # Map numpy dtype to GDAL type
        gdal_type = gdal.GDT_Float32  # Default
        if dtype == np.float64:
            gdal_type = gdal.GDT_Float64
        elif dtype == np.int32:
            gdal_type = gdal.GDT_Int32
        elif dtype == np.int16:
            gdal_type = gdal.GDT_Int16
        elif dtype == np.uint8:
            gdal_type = gdal.GDT_Byte

        ds = driver.Create(str(path), cols, rows, bands, gdal_type)
        ds.SetGeoTransform(transform)
        if crs_wkt:
            ds.SetProjection(crs_wkt)
        for b in range(1, bands + 1):
            band = ds.GetRasterBand(b)
            band.SetNoDataValue(nodata)
            band.Fill(nodata)
        ds = None


def write_raster_window(path_str: str | Path, data: np.ndarray, window: tuple[slice, slice], band: int = 1):
    """
    Write a data array to a specific window in an existing raster.
    window is (row_slice, col_slice).
    """
    path = check_path(path_str)
    row_slice, col_slice = window

    if GDAL_ENV is False:
        from rasterio.windows import Window

        with rasterio.open(path, "r+") as dst:
            win = Window(
                col_slice.start,  # type: ignore[too-many-positional-arguments]
                row_slice.start,
                col_slice.stop - col_slice.start,
                row_slice.stop - row_slice.start,
            )
            dst.write(data, band, window=win)
    else:
        ds = gdal.Open(str(path), gdal.GA_Update)
        if ds is None:
            raise OSError(f"Could not open {path} for update")

        xoff = col_slice.start
        yoff = row_slice.start

        ds.GetRasterBand(band).WriteArray(data, xoff, yoff)
        ds = None


class _EpwDataIndex:
    """Lightweight index class mimicking pandas DatetimeIndex for EPW data."""

    def __init__(self, timestamps: list):
        self._timestamps = timestamps
        self.tz = None
        self.name = "datetime"

    def __len__(self):
        return len(self._timestamps)

    def __getitem__(self, idx):
        return self._timestamps[idx]

    def __iter__(self):
        return iter(self._timestamps)

    def __ge__(self, other):
        """Greater than or equal comparison, returns boolean array."""
        return _BooleanArray([t >= other for t in self._timestamps])

    def __le__(self, other):
        """Less than or equal comparison, returns boolean array."""
        return _BooleanArray([t <= other for t in self._timestamps])

    def __gt__(self, other):
        """Greater than comparison, returns boolean array."""
        return _BooleanArray([t > other for t in self._timestamps])

    def __lt__(self, other):
        """Less than comparison, returns boolean array."""
        return _BooleanArray([t < other for t in self._timestamps])

    @property
    def empty(self):
        return len(self._timestamps) == 0

    @property
    def year(self):
        return [t.year for t in self._timestamps]

    @property
    def month(self):
        return _IndexAccessor([t.month for t in self._timestamps])

    @property
    def day(self):
        return _IndexAccessor([t.day for t in self._timestamps])

    @property
    def hour(self):
        return _IndexAccessor([t.hour for t in self._timestamps])

    def min(self):
        return min(self._timestamps) if self._timestamps else None

    def max(self):
        return max(self._timestamps) if self._timestamps else None

    def tz_localize(self, tz):
        # Return self since we don't handle timezones in the fallback
        return self


class _IndexAccessor:
    """Helper for index property access like df.index.hour."""

    def __init__(self, values: list):
        self._values = values

    def __iter__(self):
        return iter(self._values)

    def __gt__(self, other):
        return _BooleanArray([v > other for v in self._values])

    def __ge__(self, other):
        return _BooleanArray([v >= other for v in self._values])

    def __lt__(self, other):
        return _BooleanArray([v < other for v in self._values])

    def __le__(self, other):
        return _BooleanArray([v <= other for v in self._values])

    def __eq__(self, other):
        return _BooleanArray([v == other for v in self._values])

    def isin(self, values_set):
        return [v in values_set for v in self._values]


class _BooleanArray:
    """Helper for boolean array operations (& and |)."""

    def __init__(self, values: list):
        self._values = values

    def __and__(self, other):
        if isinstance(other, _BooleanArray):
            return _BooleanArray([a and b for a, b in zip(self._values, other._values)])
        return _BooleanArray([a and b for a, b in zip(self._values, other)])

    def __or__(self, other):
        if isinstance(other, _BooleanArray):
            return _BooleanArray([a or b for a, b in zip(self._values, other._values)])
        return _BooleanArray([a or b for a, b in zip(self._values, other)])

    def __iter__(self):
        return iter(self._values)

    def __getitem__(self, idx):
        return self._values[idx]

    def __len__(self):
        return len(self._values)

    def all(self):
        return all(self._values)

    def any(self):
        return any(self._values)

    def tolist(self):
        return self._values


class _EpwRow:
    """Lightweight row class mimicking pandas Series for EPW data."""

    def __init__(self, data: dict):
        self._data = data

    def __getitem__(self, key):
        return self._data.get(key, float("nan"))

    def get(self, key, default=None):
        """Get value with default, like dict.get()."""
        val = self._data.get(key, default)
        if val is None or (isinstance(val, float) and val != val):  # NaN check
            return default
        return val


class _EpwColumn:
    """Lightweight column accessor mimicking a pandas Series for a single column."""

    def __init__(self, values: list):
        self._values = values

    def __getitem__(self, idx):
        return self._values[idx]

    def __len__(self):
        return len(self._values)

    def __iter__(self):
        return iter(self._values)

    def min(self):
        return min(v for v in self._values if v == v)  # skip NaN

    def max(self):
        return max(v for v in self._values if v == v)  # skip NaN

    def __ge__(self, other):
        return _BooleanArray([v >= other for v in self._values])

    def __le__(self, other):
        return _BooleanArray([v <= other for v in self._values])

    def __gt__(self, other):
        return _BooleanArray([v > other for v in self._values])

    def __lt__(self, other):
        return _BooleanArray([v < other for v in self._values])

    def all(self):
        return all(self._values)


class _EpwIloc:
    """Positional indexing for _EpwDataFrame."""

    def __init__(self, rows: list[dict]):
        self._rows = rows

    def __getitem__(self, idx):
        return _EpwRow(self._rows[idx])


class _EpwDataFrame:
    """Lightweight DataFrame-like class for EPW data without pandas dependency."""

    def __init__(self, rows: list[dict], timestamps: list):
        self._rows = rows
        self._timestamps = timestamps
        self.index = _EpwDataIndex(timestamps)

    def __len__(self):
        return len(self._rows)

    @property
    def columns(self):
        """Column names from the first row."""
        if self._rows:
            return list(self._rows[0].keys())
        return []

    @property
    def iloc(self):
        """Positional indexing (returns _EpwRow objects)."""
        return _EpwIloc(self._rows)

    def __getitem__(self, key):
        """Access by column name (str) or filter by boolean mask."""
        if isinstance(key, str):
            return _EpwColumn([row.get(key, float("nan")) for row in self._rows])
        if isinstance(key, _BooleanArray):
            key = key._values
        if isinstance(key, list):
            filtered_rows = [r for r, m in zip(self._rows, key) if m]
            filtered_ts = [t for t, m in zip(self._timestamps, key) if m]
            return _EpwDataFrame(filtered_rows, filtered_ts)
        raise TypeError(f"Unsupported indexing type: {type(key)}")

    @property
    def empty(self):
        return len(self._rows) == 0

    def iterrows(self):
        """Iterate over (timestamp, row) pairs."""
        for ts, row_data in zip(self._timestamps, self._rows):
            yield _EpwTimestamp(ts), _EpwRow(row_data)

    def to_dataframe(self):
        """Convert to pandas DataFrame if pandas is available.

        Returns:
            pd.DataFrame with DatetimeIndex, or self if pandas unavailable.
        """
        try:
            import pandas as pd

            df = pd.DataFrame(self._rows)
            df.index = pd.DatetimeIndex(self._timestamps, name="datetime")
            return df
        except ImportError:
            return self


class _EpwTimestamp:
    """Wrapper for datetime to provide pandas-like interface."""

    def __init__(self, dt_obj):
        self._dt = dt_obj

    def __getattr__(self, name):
        return getattr(self._dt, name)

    def to_pydatetime(self):
        return self._dt

    def replace(self, **kwargs):
        return self._dt.replace(**kwargs)


def _parse_epw_metadata(path: Path) -> dict:
    """Parse EPW header to extract metadata."""
    metadata = {}
    with open(path, encoding="utf-8") as f:
        location_line = f.readline().strip()
        if not location_line.startswith("LOCATION"):
            raise ValueError("Invalid EPW file: first line must start with 'LOCATION'")

        location_parts = location_line.split(",")
        if len(location_parts) < 10:
            raise ValueError(f"Invalid LOCATION line: expected at least 10 fields, got {len(location_parts)}")

        metadata["city"] = location_parts[1].strip()
        metadata["state"] = location_parts[2].strip()
        metadata["country"] = location_parts[3].strip()
        metadata["latitude"] = float(location_parts[6])
        metadata["longitude"] = float(location_parts[7])
        metadata["tz_offset"] = float(location_parts[8])
        metadata["elevation"] = float(location_parts[9])

    return metadata


def _read_epw_pure_python(path: Path) -> tuple:
    """Pure Python EPW parser without pandas dependency."""
    import csv
    from datetime import datetime as dt_class

    metadata = _parse_epw_metadata(path)

    # Column indices for the fields we need
    # EPW format has 35 fields per line
    col_indices = {
        "year": 0,
        "month": 1,
        "day": 2,
        "hour": 3,
        "minute": 4,
        "temp_air": 6,
        "relative_humidity": 8,
        "atmospheric_pressure": 9,
        "ghi": 13,
        "dni": 14,
        "dhi": 15,
        "wind_direction": 20,
        "wind_speed": 21,
    }

    na_values = {"99", "999", "9999", "99999", "999999999", ""}

    rows = []
    timestamps = []

    with open(path, encoding="utf-8") as f:
        # Skip 8 header lines
        for _ in range(8):
            f.readline()

        reader = csv.reader(f)
        for line in reader:
            if len(line) < 22:
                continue

            try:
                year = int(line[col_indices["year"]])
                month = int(line[col_indices["month"]])
                day = int(line[col_indices["day"]])
                hour = int(line[col_indices["hour"]])
                minute = int(line[col_indices["minute"]])

                # EPW uses 1-24 hour format; hour 24 means midnight of next day
                if hour == 24:
                    hour = 0
                    # We'd need to add a day, but for simplicity just use hour 0
                    # This matches pandas behavior with errors="coerce"

                timestamp = dt_class(year, month, day, hour, minute)
                timestamps.append(timestamp)

                def parse_float(idx, row_data=line):
                    val = row_data[idx].strip()
                    if val in na_values:
                        return float("nan")
                    try:
                        return float(val)
                    except (ValueError, TypeError):
                        return float("nan")

                row = {
                    "temp_air": parse_float(col_indices["temp_air"]),
                    "relative_humidity": parse_float(col_indices["relative_humidity"]),
                    "atmospheric_pressure": parse_float(col_indices["atmospheric_pressure"]),
                    "ghi": parse_float(col_indices["ghi"]),
                    "dni": parse_float(col_indices["dni"]),
                    "dhi": parse_float(col_indices["dhi"]),
                    "wind_speed": parse_float(col_indices["wind_speed"]),
                    "wind_direction": parse_float(col_indices["wind_direction"]),
                }
                rows.append(row)
            except (ValueError, IndexError):
                continue

    if not rows:
        raise ValueError("EPW file contains no valid data rows")

    df = _EpwDataFrame(rows, timestamps)
    logger.info(f"Loaded EPW file: {metadata['city']}, {len(df)} timesteps (pure Python parser)")

    return df, metadata


def download_epw(
    latitude: float,
    longitude: float,
    output_path: str | Path,
    *,
    timeout: int = 60,
) -> Path:
    """
    Download a Typical Meteorological Year (TMY) EPW file from PVGIS.

    Uses the EU Joint Research Centre's PVGIS API (no API key required).
    Coverage is near-global (all continents except polar regions),
    using ERA5 reanalysis data.

    Args:
        latitude: Latitude in decimal degrees (-90 to 90).
        longitude: Longitude in decimal degrees (-180 to 180).
        output_path: Path where the EPW file will be saved.
        timeout: HTTP request timeout in seconds (default 60).

    Returns:
        Path to the saved EPW file.

    Raises:
        ValueError: If coordinates are out of range.
        ConnectionError: If the PVGIS server is unreachable.
        RuntimeError: If the download fails (e.g. location over ocean).

    Example:
        >>> from solweig.io import download_epw
        >>> path = download_epw(37.98, 23.73, "athens.epw")
        >>> data, metadata = read_epw(path)
    """
    import urllib.error
    import urllib.request

    if not -90 <= latitude <= 90:
        raise ValueError(f"Latitude must be between -90 and 90, got {latitude}")
    if not -180 <= longitude <= 180:
        raise ValueError(f"Longitude must be between -180 and 180, got {longitude}")

    output_path = Path(output_path)

    url = f"https://re.jrc.ec.europa.eu/api/v5_3/tmy?lat={latitude}&lon={longitude}&outputformat=epw"

    logger.info(f"Downloading EPW from PVGIS for ({latitude:.4f}, {longitude:.4f})...")

    try:
        req = urllib.request.Request(url)
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            data = resp.read()
    except urllib.error.HTTPError as e:
        if e.code == 400:
            raise RuntimeError(
                f"PVGIS has no data for ({latitude}, {longitude}). The location may be over ocean or outside coverage."
            ) from e
        raise RuntimeError(f"PVGIS download failed (HTTP {e.code}): {e.reason}") from e
    except urllib.error.URLError as e:
        raise ConnectionError(f"Cannot reach PVGIS server: {e.reason}") from e

    if len(data) < 1000:
        # PVGIS returns a short error message for invalid locations
        text = data.decode("utf-8", errors="replace")
        raise RuntimeError(f"PVGIS returned an error: {text.strip()}")

    output_path.parent.mkdir(parents=True, exist_ok=True)
    output_path.write_bytes(data)

    lines = data.decode("utf-8", errors="replace").split("\n")
    n_data_lines = len(lines) - 8  # subtract header lines
    logger.info(f"Saved EPW file: {output_path} ({n_data_lines} hourly records)")

    return output_path


def read_epw(path: str | Path) -> tuple:
    """
    Read EnergyPlus Weather (EPW) file and return weather data with metadata.

    EPW files have 8 header lines followed by hourly weather data.
    Uses pure Python parser (no pandas/scipy dependencies).

    Args:
        path: Path to EPW file (string or Path)

    Returns:
        Tuple of (data, metadata_dict):
        - data: DataFrame-like object with datetime index and weather columns:
            - temp_air: Dry bulb temperature (°C)
            - relative_humidity: Relative humidity (%)
            - atmospheric_pressure: Atmospheric pressure (Pa)
            - ghi: Global horizontal irradiance (W/m²)
            - dni: Direct normal irradiance (W/m²)
            - dhi: Diffuse horizontal irradiance (W/m²)
            - wind_speed: Wind speed (m/s)
            - wind_direction: Wind direction (degrees)
        - metadata_dict: Dictionary with keys:
            - city: Location city name
            - latitude: Latitude (degrees)
            - longitude: Longitude (degrees)
            - elevation: Elevation (m)
            - tz_offset: Timezone offset (hours)

    Raises:
        FileNotFoundError: If EPW file doesn't exist
        ValueError: If EPW file is malformed
    """
    path = Path(path)
    if not path.exists():
        raise FileNotFoundError(f"EPW file not found: {path}")

    return _read_epw_pure_python(path)
