#!/usr/bin/env python3
"""Image preprocessing helpers shared by every detector.

The candidate-generation strategies differ: pick a working
channel (gray / an HSV or BGR channel / a channel difference), optionally
stretch brightness-contrast, optionally build an HSV colour mask, and turn a
physical part diameter into a pixel radius band. Centralising these keeps the
detectors focused on their algorithm and keeps the vocabulary (config keys)
identical across methods.
"""
from __future__ import annotations

from typing import Optional

import cv2
import numpy as np


def to_gray(image) -> np.ndarray:
    """Accept an image path or an already-loaded array (gray or BGR) and return
    a single-channel uint8 image."""
    if isinstance(image, str):
        loaded = cv2.imread(image)
        if loaded is None:
            raise FileNotFoundError(f"Cannot read image: {image}")
        image = loaded
    if image.ndim == 3:
        return cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    return image


def to_bgr(image) -> Optional[np.ndarray]:
    """Return a 3-channel BGR image, or None if a single-channel array was
    given (colour-based methods then simply skip)."""
    if isinstance(image, str):
        loaded = cv2.imread(image)
        if loaded is None:
            raise FileNotFoundError(f"Cannot read image: {image}")
        return loaded
    if image.ndim == 2:
        return None
    return image


def select_channel(bgr: np.ndarray, channel: str) -> np.ndarray:
    """Return a single-channel working image for the requested channel name.

    Supported: ``gray`` (default), HSV channels ``h``/``s``/``v``, BGR channels
    ``b``/``g``/``r``, and the difference ``b-r`` (useful to isolate blue
    fiducials, as yellow_fpc_alignment does). Falls back to gray for a
    single-channel input.
    """
    channel = (channel or "gray").lower()
    if bgr.ndim == 2:
        return bgr
    if channel == "gray":
        return cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
    if channel in ("h", "s", "v"):
        hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)
        return hsv[:, :, {"h": 0, "s": 1, "v": 2}[channel]]
    if channel in ("b", "g", "r"):
        return bgr[:, :, {"b": 0, "g": 1, "r": 2}[channel]]
    if channel in ("b-r", "r-b"):
        b = bgr[:, :, 0].astype(np.int16)
        r = bgr[:, :, 2].astype(np.int16)
        diff = (b - r) if channel == "b-r" else (r - b)
        return np.clip(diff, 0, 255).astype(np.uint8)
    # Unknown name → gray, so a typo degrades gracefully instead of crashing.
    return cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)


def adjust_brightness_contrast(img: np.ndarray, min_value: float, max_value: float) -> np.ndarray:
    """Linear brightness/contrast stretch mapping [min_value, max_value] → [0, 255].

    Same transform NITTO uses (``adjust_brightness_contrast_by_value``): with
    min_value=0, max_value<255 it boosts contrast so a faint edge separates from
    the background before thresholding. A no-op when max_value<=0.
    """
    if max_value <= 0:
        return img
    min_norm = min_value / 255.0
    max_norm = max_value / 255.0
    denom = (max_norm - min_norm)
    if abs(denom) < 1e-9:
        return img
    contrast = 1.0 / denom
    brightness = -min_norm * contrast * 255
    return cv2.addWeighted(img, contrast, img, 0, brightness)


def blur(img: np.ndarray, ksize: int) -> np.ndarray:
    """Gaussian blur with an odd-kernel guard; no-op when ksize<=1."""
    if ksize is None or ksize <= 1:
        return img
    k = int(ksize)
    if k % 2 == 0:
        k += 1
    return cv2.GaussianBlur(img, (k, k), 0)


def fiji_hue_to_cv(h_fiji: int) -> int:
    """Convert a Fiji/ImageJ hue (0-255) to OpenCV's 0-179 range."""
    return round(h_fiji * 179 / 255)


def hsv_mask(bgr: np.ndarray, h_range, s_range, v_range,
             inv: bool = False, hue_scale: str = "cv") -> np.ndarray:
    """Binary HSV in-range mask (255 = selected).

    Args:
        h_range (tuple): (min, max) hue.
        s_range (tuple): (min, max) saturation.
        v_range (tuple): (min, max) value.
        inv (bool): invert the mask.
        hue_scale (str): ``"cv"`` (0-179, default) or ``"fiji"`` (0-255, auto-converted).
    """
    hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)
    h_min, h_max = h_range
    if hue_scale == "fiji":
        h_min, h_max = fiji_hue_to_cv(h_min), fiji_hue_to_cv(h_max)
    lower = np.array([h_min, s_range[0], v_range[0]], dtype=np.uint8)
    upper = np.array([h_max, s_range[1], v_range[1]], dtype=np.uint8)
    mask = cv2.inRange(hsv, lower, upper)
    return cv2.bitwise_not(mask) if inv else mask


def radius_band(diameter_mm=None, calibration: float = 0.0, tol: float = 0.3,
                r_min=None, r_max=None, scale: float = 1.0) -> tuple:
    """Resolve the (r_min, r_max) pixel band a detector must accept.

    Priority: explicit ``r_min``/``r_max`` win; otherwise, when both
    ``diameter_mm`` and ``calibration`` (mm/px) are given, derive the expected
    radius ``r = (diameter_mm/2) * scale / calibration`` and widen by ``±tol``.
    Falls back to (0, +inf) when nothing is configured.

    Deriving from the physical diameter (mm) is the key robustness trick 
    uses: the band is invariant to camera/scale changes and cheaply
    rejects the large dark/bright blobs that otherwise get picked as a rounder
    "circle" than the real target.
    """
    lo = float(r_min) if r_min not in (None, 0) else None
    hi = float(r_max) if r_max not in (None, 0) else None
    if (lo is None or hi is None) and diameter_mm and calibration and calibration > 0:
        expected = (float(diameter_mm) / 2.0) * float(scale) / float(calibration)
        if lo is None:
            lo = max(1.0, expected * (1.0 - tol))
        if hi is None:
            hi = expected * (1.0 + tol)
    if lo is None:
        lo = 0.0
    if hi is None:
        hi = float("inf")
    return float(lo), float(hi)
