from __future__ import annotations

from datetime import datetime
from pathlib import Path
from typing import Any

import numpy as np
from numpy.typing import NDArray
from scipy.ndimage import gaussian_filter1d

from vna_system.core.logging.logger import get_component_logger
from vna_system.core.processors.base_processor import BaseProcessor, UIParameter, ProcessedResult
from vna_system.core.acquisition.sweep_buffer import SweepData
from vna_system.core.config import SPEED_OF_LIGHT_M_S

logger = get_component_logger(__file__)


class BScanProcessor(BaseProcessor):
    """
    B-Scan processor for time-domain analysis of VNA measurements.

    Features
    -------
    - Optional open-air reference subtraction
    - Configurable axis: "real", "abs", "phase"
    - IFFT with frequency range filtering
    - Depth windowing with gain shaping
    - Plot history accumulation for multi-sweep heatmaps
    """


    def __init__(self, config_dir: Path) -> None:
        super().__init__("bscan", config_dir)

        # Increase history size for multi-sweep plotting
        self._max_history = 50

        # Local plot history (separate from sweep history maintained by BaseProcessor)
        self._plot_history: list[dict[str, Any]] = []

        logger.info("BScanProcessor initialized", processor_id=self.processor_id)

    # -------------------------------------------------------------------------
    # Configuration
    # -------------------------------------------------------------------------

    def _get_default_config(self) -> dict[str, Any]:
        """Return default configuration values."""
        return {
            "open_air": False,          # Toggle for reference usage
            "axis": "abs",              # "real", "abs", or "phase"
            # "data_limitation": None,    # None, "ph_only_1", "ph_only_2"
            "cut": 0.824,               # Cut parameter (meters)
            "max": 1.0,                 # Max depth (meters)
            "gain": 1.0,                # Gain exponent
            "start_freq": 100.0,        # Start frequency (MHz)
            "stop_freq": 8800.0,        # Stop frequency (MHz)
            "clear_history": False,     # UI button; not persisted
            "sigma" : 0.01,
            "border_border_m" : 0.5,
            "if_normalize" : False,
            "if_draw_level" : False,
            "detection_level" : 5,
        }

    def get_ui_parameters(self) -> list[UIParameter]:
        """Return UI parameter schema for configuration."""
        cfg = self._config  # Local alias for brevity

        return [
            UIParameter(
                name="open_air",
                label="Референс открытого воздуха",
                type="toggle",
                value=cfg["open_air"],
            ),
            UIParameter(
                name="axis",
                label="Ось",
                type="select",
                value=cfg["axis"],
                options={"choices": ["real", "imag", "abs", "phase"]},
            ),
            # UIParameter(
            #     name="data_limitation",
            #     label="Data Limitation",
            #     type="select",
            #     value=cfg["data_limitation"],
            #     options={"choices": [None, "ph_only_1", "ph_only_2"]},
            # ),
            UIParameter(
                name="cut",
                label="Отсечка (м)",
                type="slider",
                value=cfg["cut"],
                options={"min": 0.0, "max": 2.0, "step": 0.001, "dtype": "float"},
            ),
            UIParameter(
                name="max",
                label="Макс. глубина (м)",
                type="slider",
                value=cfg["max"],
                options={"min": 0.1, "max": 5.0, "step": 0.1, "dtype": "float"},
            ),
            UIParameter(
                name="gain",
                label="Коэф. усиления",
                type="slider",
                value=cfg["gain"],
                options={"min": 0.0, "max": 3.0, "step": 0.1, "dtype": "float"},
            ),
            UIParameter(
                name="start_freq",
                label="Нач. частота (МГц)",
                type="slider",
                value=cfg["start_freq"],
                options={"min": 100.0, "max": 8800.0, "step": 10.0, "dtype": "float"},
            ),
            UIParameter(
                name="stop_freq",
                label="Кон. частота (МГц)",
                type="slider",
                value=cfg["stop_freq"],
                options={"min": 100.0, "max": 8800.0, "step": 10.0, "dtype": "float"},
            ),

            # Modern features
            UIParameter(
                name="sigma",
                label="Степень сглаживания в abs режиме",
                type="slider",
                value=0.01,
                options={"min": 0.01, "max": 5.0, "step": 0.01, "dtype": "float"},
            ),
            UIParameter(
                name="if_normalize",
                label="Нормировка",
                type="toggle",
                value=False,
            ),
            UIParameter(
                name="border_border_m",
                label="Глубина границы",
                type="slider",
                value=0.5,
                options={"min": 0.0, "max": 2.5, "step": 0.01, "dtype": "float"},
            ),
            UIParameter(
                name="if_draw_level",
                label="Детекция",
                type="toggle",
                value=False,
            ),
            UIParameter(
                name="detection_level",
                label="Порог детекции,%",
                type="slider",
                value=5,
                options={"min": 0.0, "max": 100.0, "step": 0.1, "dtype": "float"},
            ),

            # Big botton
            UIParameter(
                name="clear_history",
                label="Очистить историю",
                type="button",
                value=False,
                options={"action": "Очистить накопленную историю графика"},
            ),
        ]

    def update_config(self, updates: dict[str, Any]) -> None:
        """Handle config updates, including the 'clear history' button action."""
        if updates.get("clear_history"):
            self._clear_plot_history()
            # Remove button flag before delegating to base class
            updates = {k: v for k, v in updates.items() if k != "clear_history"}

        if updates:
            super().update_config(updates)

    def _clear_plot_history(self) -> None:
        """Clear the accumulated plot and sweep history completely."""
        with self._lock:
            self._sweep_history.clear()
            self._plot_history.clear()
        logger.info("Plot and sweep history cleared completely", processor_id=self.processor_id)

    def delete_column(self, column_index: int) -> bool:
        """
        Delete a specific column (sweep) from the plot history.

        Parameters
        ----------
        column_index : int
            The 1-based column index to delete (matching the sweep number displayed to the user).

        Returns
        -------
        bool
            True if deletion was successful, False otherwise.
        """
        try:
            with self._lock:
                # Convert 1-based index to 0-based
                array_index = column_index - 1

                if array_index < 0 or array_index >= len(self._plot_history):
                    logger.warning(
                        "Invalid column index for deletion",
                        column_index=column_index,
                        history_length=len(self._plot_history)
                    )
                    return False

                # Delete from plot history
                del self._plot_history[array_index]

                # Also delete from sweep history if indices match
                if array_index < len(self._sweep_history):
                    del self._sweep_history[array_index]

                logger.info(
                    "Column deleted successfully",
                    column_index=column_index,
                    remaining_columns=len(self._plot_history)
                )
                return True

        except Exception as exc:
            logger.error("Column deletion failed", error=repr(exc), column_index=column_index)
            return False

    # -------------------------------------------------------------------------
    # Processing
    # -------------------------------------------------------------------------

    def process_sweep(
        self,
        sweep_data: SweepData,
        calibrated_data: SweepData | None,
        vna_config: dict[str, Any],
    ) -> dict[str, Any]:
        """
        Process a single sweep and prepare B-scan data.

        Returns
        -------
        dict
            Keys: time_domain_data, distance_data, frequency_range, reference_used,
                    axis_type, data_limitation, points_processed, plot_history_count
            Or: {"error": "..."} on failure.
        """
        try:
            # Skip B-scan processing for S21 mode - only works with S11
            # if vna_config and vna_config.get("mode") == "s21":
            #     return {"error": "B-scan only available in S11 mode"}

            # Choose calibrated data when provided
            data_to_process = calibrated_data or sweep_data

            complex_data = self._get_complex_s11(data_to_process)
            if complex_data is None or complex_data.size == 0:
                logger.warning("No valid complex data for B-scan processing")
                return {"error": "No valid complex data"}

            # Optional reference subtraction (latest stored reference in sweep history)
            reference_data: SweepData | None = None
            if self._config["open_air"] and self._sweep_history:
                latest_history = self._sweep_history[-1]
                reference_data = latest_history.get("reference_data")

                if reference_data is None:
                    logger.warning(f"Open air substraction cannot be done: reference_data is None")
                    self._config["open_air"] = False

            if self._config["open_air"] and reference_data is not None:
                reference_complex = self._get_complex_s11(reference_data)
                if reference_complex is not None and reference_complex.size:
                    complex_data = self._subtract_reference(complex_data, reference_complex)
                    logger.debug("Applied open-air reference subtraction")

            # Keep frequency controls in sync with the current VNA config
            self._update_frequency_ranges(vna_config)

            # Perform the main analysis pipeline
            analysis = self._perform_data_analysis(complex_data, vna_config)
            if analysis is None:
                logger.warning("Data analysis failed")
                return {"error": "Data analysis failed"}

            plot_record = {
                "time_domain_data": analysis["time_data"].tolist(),
                "distance_data": analysis["distance"].tolist(),
                "timestamp": sweep_data.timestamp,
                "frequency_range": analysis["freq_range"],
            }

            with self._lock:
                self._plot_history.append(plot_record)
                if len(self._plot_history) > self._max_history:
                    self._plot_history = self._plot_history[-self._max_history :]

            # Collect all plot history data
            with self._lock:
                all_time_domain = [record["time_domain_data"] for record in self._plot_history]
                all_distance = [record["distance_data"] for record in self._plot_history]
                # Generate sequential sweep numbers starting from 1
                all_sweep_numbers = list(range(1, len(self._plot_history) + 1))
                all_timestamps = [record["timestamp"] for record in self._plot_history]

            return {
                "time_domain_data": analysis["time_data"].tolist(),  # Latest sweep
                "distance_data": analysis["distance"].tolist(),  # Latest sweep
                "frequency_range": analysis["freq_range"],
                "reference_used": bool(self._config["open_air"] and reference_data is not None),
                "axis_type": self._config["axis"],
                "points_processed": int(complex_data.size),
                "plot_history_count": len(self._plot_history),
                # Full history data
                "all_time_domain_data": all_time_domain,
                "all_distance_data": all_distance,
                "all_sweep_numbers": all_sweep_numbers,
                "all_timestamps": all_timestamps,
            }

        except Exception as exc:  # noqa: BLE001
            logger.error("B-scan processing failed", error=repr(exc))
            return {"error": str(exc)}

    # -------------------------------------------------------------------------
    # Visualization
    # -------------------------------------------------------------------------

    def generate_plotly_config(
            self,
            processed_data: dict[str, Any],
            vna_config: dict[str, Any],  # noqa: ARG002 - reserved for future layout tweaks
        ) -> dict[str, Any]:
            """
            Produce a Plotly-compatible heatmap configuration from accumulated sweeps.
            """
            if "error" in processed_data:
                return {
                    "data": [],
                    "layout": {
                        "title": "B-Scan анализ - Ошибка",
                        "annotations": [
                            {
                                "text": f"Ошибка: {processed_data['error']}",
                                "x": 0.5,
                                "y": 0.5,
                                "xref": "paper",
                                "yref": "paper",
                                "showarrow": False,
                            }
                        ],
                        "template": "plotly_dark",
                    },
                }

            with self._lock:
                history = list(self._plot_history)

            if not history:
                return {
                    "data": [],
                    "layout": {
                        "title": "B-Scan анализ - Нет данных",
                        "xaxis": {"title": "Номер развертки"},
                        "yaxis": {"title": "Глубина (м)"},
                        "template": "plotly_dark",
                    },
                }


            Y_VALUE = self._config["border_border_m"]

            # Build scatter-like heatmap (irregular grid) from history
            x_coords: list[int] = []
            y_coords: list[float] = []
            z_values: list[float] = []

            z_values_square = np.zeros((len(history[0]["distance_data"]),len(history)),dtype=float)
            for sweep_index, item in enumerate(history, start=1):
                depths = item["distance_data"]
                amps = item["time_domain_data"]

                if self._config['if_normalize']:
                    depth_mask = np.array(depths) < Y_VALUE
                    normalized_ampls = np.array(amps) / np.max(np.array(amps)[depth_mask])
                else:
                    normalized_ampls = np.array(amps)
                z_values_square[:,sweep_index-1] = normalized_ampls

                for d, a in zip(depths, normalized_ampls, strict=False):
                    x_coords.append(sweep_index)
                    y_coords.append(d)
                    z_values.append(float(a))

            if self._config["if_draw_level"]:
                detection_level_abs = np.percentile(z_values, 100 - self._config["detection_level"])
                detected_points_mask = (np.array(z_values) > detection_level_abs) & (np.array(y_coords) > Y_VALUE)
                detected_points_x = list([float(x) for x in np.array(x_coords)[detected_points_mask]])
                detected_points_y = list([float(y) for y in np.array(y_coords)[detected_points_mask]])
                detected_trace = {
                    "type": "scatter",
                    "mode": "markers",
                    "x": detected_points_x,
                    "y": detected_points_y,
                    "marker": {
                        "size": 8,
                        "color": "red",
                        "symbol": "circle",
                    },
                    "name": "Обнаруженные точки",
                }
            else:
                detected_trace = None


            # Colorscale selection
            if self._config["axis"] == "abs":
                colorscale = "Viridis"
                heatmap_kwargs: dict[str, Any] = {}
            else:
                colorscale = "RdBu"
                heatmap_kwargs = {"zmid": 0}

            heatmap_trace = {
                "type": "heatmap",
                "x": x_coords,
                "y": y_coords,
                "z": z_values,
                "colorscale": colorscale,
                "colorbar": {"title": "Амплитуда"},
                "hovertemplate": (
                    "Развертка: %{x}<br>"
                    "Глубина: %{y:.3f} м<br>"
                    "Амплитуда: %{z:.3f}<br>"
                    "<extra></extra>"
                ),
                **heatmap_kwargs,
            }

            freq_start, freq_stop = processed_data.get("frequency_range", [0.0, 0.0])
            config_info = (
                f"Частота: {freq_start/1e6:.1f}-{freq_stop/1e6:.1f} МГц | "
                f"Усиление: {self._config['gain']:.1f} | "
                f"Отсечка: {self._config['cut']:.3f} м | "
                f"Макс глубина: {self._config['max']:.1f} м | "
                f"Ось: {self._config['axis']} | "
                f"Разверток: {len(history)}"
            )

            if processed_data.get("reference_used", False):
                config_info += " | Открытый воздух: ВКЛ"

            # if self._config["data_limitation"]:
            #     config_info += f" | Limit: {self._config['data_limitation']}"

            layout = {
                "title": f"B-Scan тепловая карта - {config_info}",
                "xaxis": {"title": "Номер развертки", "side": "bottom"},
                "yaxis": {"title": "Глубина (м)", "autorange": "reversed"},
                "hovermode": "closest",
                "height": 546,
                "template": "plotly_dark",
                "margin": {"t": 40, "r": 50, "b": 110, "l": 50},
                "autosize": True,
            }

            print(self._config['if_normalize'], self._config['if_draw_level'])
            if self._config['if_normalize'] or self._config['if_draw_level']:
                layout["shapes"] = layout.get("shapes", []) + [
                    {
                        "type": "line",
                        # по X — координаты "бумаги" (0–1 по всей ширине графика)
                        "xref": "paper",
                        # по Y — координаты данных (в метрах глубины)
                        "yref": "y",
                        "x0": 0,
                        "x1": 1,
                        "y0": Y_VALUE,
                        "y1": Y_VALUE,
                        "line": {
                            "width": 2,
                            "dash": "dash",
                            "color": "white",
                        },
                    }
                ]

            if detected_trace is not None:
                return {"data": [heatmap_trace,detected_trace], "layout": layout}
            return {"data": [heatmap_trace], "layout": layout}

    # -------------------------------------------------------------------------
    # Recalculation override
    # -------------------------------------------------------------------------

    def recalculate(self) -> ProcessedResult | None:
        """
        Recompute all plot history using current config settings.

        Unlike the base implementation that processes only the latest sweep,
        this method processes all sweep history to rebuild the complete heatmap.
        """
        with self._lock:
            if not self._sweep_history:
                logger.debug("Recalculate with empty history; returning empty result")
                # Return empty result with proper structure
                empty_data = {
                    "time_domain_data": [],
                    "distance_data": [],
                    "frequency_range": [0.0, 0.0],
                    "reference_used": False,
                    "axis_type": self._config["axis"],
                    "points_processed": 0,
                    "plot_history_count": 0,
                    "all_time_domain_data": [],
                    "all_distance_data": [],
                    "all_sweep_numbers": [],
                    "all_timestamps": [],
                }
                plotly_conf = self.generate_plotly_config(empty_data, {})
                ui_params = self.get_ui_parameters()

                return ProcessedResult(
                    processor_id=self.processor_id,
                    timestamp=datetime.now().timestamp(),
                    data=empty_data,
                    plotly_config=plotly_conf,
                    ui_parameters=ui_params,
                    metadata=self._get_metadata(),
                )

            # Clear existing plot history to rebuild from scratch
            self._plot_history.clear()

            # Process all sweeps in history with current config
            last_processed = None
            last_vna_config = {}
            for entry in self._sweep_history:
                sweep_data = entry["sweep_data"]
                calibrated_data = entry["calibrated_data"]
                vna_config = entry["vna_config"]

                # Use process_sweep to handle the processing logic
                processed = self.process_sweep(sweep_data, calibrated_data, vna_config)

                # Skip if processing failed
                if "error" not in processed:
                    last_processed = processed
                    last_vna_config = vna_config

            # Trim plot history if needed
            if len(self._plot_history) > self._max_history:
                self._plot_history = self._plot_history[-self._max_history:]

            logger.info("Recalculated B-scan with all history",
                        plot_records=len(self._plot_history),
                        sweep_records=len(self._sweep_history))

            # Build result from last successful processing
            if last_processed is None:
                return None

            # Generate plotly config and wrap into ProcessedResult
            plotly_conf = self.generate_plotly_config(last_processed, last_vna_config)
            ui_params = self.get_ui_parameters()

            return ProcessedResult(
                processor_id=self.processor_id,
                timestamp=datetime.now().timestamp(),
                data=last_processed,
                plotly_config=plotly_conf,
                ui_parameters=ui_params,
                metadata=self._get_metadata(),
            )

    # -------------------------------------------------------------------------
    # Low-level helpers
    # -------------------------------------------------------------------------

    def _get_complex_s11(self, sweep_data: SweepData) -> NDArray[np.complex128] | None:
        """Extract complex S11 array from sweep points."""
        try:
            if not sweep_data.points:
                return None

            # Each point is expected as a pair (real, imag)
            arr = np.asarray(sweep_data.points, dtype=float)
            if arr.ndim != 2 or arr.shape[1] != 2:
                raise ValueError("Expected Nx2 array for (real, imag) points")

            complex_data = arr[:, 0] + 1j * arr[:, 1]
            return complex_data.astype(np.complex128, copy=False)

        except Exception as exc:  # noqa: BLE001
            logger.error("Failed to extract complex S11 data", error=repr(exc))
            return None

    def _subtract_reference(
        self,
        signal: NDArray[np.complex128],
        reference: NDArray[np.complex128],
    ) -> NDArray[np.complex128]:
        """Subtract reference from signal (complex subtraction), length-safe."""
        try:
            n = min(signal.size, reference.size)
            if n == 0:
                return signal
            result = signal[:n] - reference[:n]
            logger.debug("Reference subtraction completed", points=n)
            return result
        except Exception as exc:  # noqa: BLE001
            logger.error("Reference subtraction failed", error=repr(exc))
            return signal  # Non-fatal; continue with original signal

    def _update_frequency_ranges(self, vna_config: dict[str, Any]) -> None:
        """Clamp configured frequency sliders to VNA limits."""
        if not vna_config:
            return

        start_freq_hz = float(vna_config.get("start_freq", 100e6))
        stop_freq_hz = float(vna_config.get("stop_freq", 8.8e9))

        start_mhz = start_freq_hz / 1e6
        stop_mhz = stop_freq_hz / 1e6

        current_start = float(self._config["start_freq"])
        current_stop = float(self._config["stop_freq"])

        # Ensure order and clamp to VNA bounds
        new_start = max(start_mhz, min(stop_mhz, current_start))
        new_stop = max(new_start, min(stop_mhz, current_stop))

        self._config["start_freq"] = new_start
        self._config["stop_freq"] = new_stop

    def _perform_data_analysis(
        self,
        complex_data: NDArray[np.complex128],
        vna_config: dict[str, Any],
    ) -> dict[str, Any] | None:
        """Full analysis pipeline: limit -> IFFT -> depth shaping."""
        try:
            # Determine effective frequency range (Hz)
            if vna_config:
                freq_start = float(vna_config.get("start_freq", 100e6))
                freq_stop = float(vna_config.get("stop_freq", 8.8e9))
            else:
                freq_start = self._config["start_freq"] * 1e6
                freq_stop = self._config["stop_freq"] * 1e6

            # Determine sigma for smoothing
            if vna_config:
                calc_type = self._config["axis"]
                sigma = self._config["sigma"]
            else:
                calc_type = self._config["axis"]
                sigma = self._config["sigma"]

            # Frequency vector over current data length
            freq_axis = np.linspace(freq_start, freq_stop, complex_data.size, dtype=float)

            # print("freq_axis", freq_axis.shape, freq_axis[0], freq_axis[-1])

            # Optionally normalize amplitude (phase-only modes)
            # limited = self._apply_data_limitations(complex_data)

            # IFFT to time domain
            depth_m, time_response = self._perform_ifft(complex_data, freq_axis, axis=self._config["axis"])

            # Depth windowing and gain shaping
            depth_out, time_out = self._apply_depth_processing(depth_m, time_response)

            if calc_type == 'abs':
                filtered_time_out = gaussian_filter1d(time_out, sigma=sigma)
            else:
                filtered_time_out = time_out

            return {
                "time_data": filtered_time_out,
                "distance": depth_out,
                "freq_range": [freq_start, freq_stop],
                "complex_time": complex_data,
            }
        except Exception as exc:  # noqa: BLE001
            logger.error("Data analysis failed", error=repr(exc))
            return None

    # def _apply_data_limitations(self, s: NDArray[np.complex128]) -> NDArray[np.complex128]:
    #     """
    #     Apply optional amplitude normalization to emphasize phase information.

    #     Modes
    #     -----
    #     - None: passthrough
    #     - "ph_only_1": normalize by magnitude
    #     - "ph_only_2": same normalization (kept for behavioral parity)
    #     """
    #     try:
    #         mode = self._config.get("data_limitation")
    #         if mode in {"ph_only_1", "ph_only_2"}:
    #             # Avoid division by zero
    #             return s / (np.abs(s) + 1e-12)
    #         return s
    #     except Exception as exc:  # noqa: BLE001
    #         logger.error("Data limitation processing failed", error=repr(exc))
    #         return s

    def _perform_ifft(
        self,
        s_array: NDArray[np.complex128],
        frequencies_hz: NDArray[np.floating],
        axis: str = "abs",
    ) -> tuple[NDArray[np.floating], NDArray[np.floating]]:
        """
        Frequency-to-time conversion with zero-padding and frequency offset handling.

        Returns
        -------
        depth_m : ndarray
            One-way distance in meters corresponding to the time axis.
        y_fin : ndarray
            Time-domain response (abs/real/phase per `axis`).
        """
        try:
            # Apply frequency band mask from current config (Hz)
            start_hz = float(self._config.get("start_freq", 100.0)) * 1e6
            stop_hz = float(self._config.get("stop_freq", 8800.0)) * 1e6

            mask = (frequencies_hz >= start_hz) & (frequencies_hz <= stop_hz)
            f = frequencies_hz[mask]
            s = s_array[mask]

            # print("f", f.shape, f[0], f[-1])

            n = f.size
            if n < 2:
                raise ValueError("Not enough frequency points after filtering")

            df = (f[-1] - f[0]) / (n - 1)
            if df <= 0.0:
                raise ValueError("Non-increasing frequency grid")

            # Frequency offset index
            k0 = int(np.round(f[0] / df))

            # FFT size (next power of two for 2*(k0 + n - 1))
            min_len = 2 * (k0 + n - 1)
            N_fft = 1 << int(np.ceil(np.log2(min_len)))

            dt = 1.0 / (N_fft * df)
            t_sec = np.arange(N_fft, dtype=float) * dt

            # Build shifted spectrum
            H = np.zeros(N_fft, dtype=np.complex128)
            H[k0 : k0 + n] = s

            y = np.fft.ifft(H)

            # Convert time (s) to one-way distance (m): d = c * t
            depth_m = t_sec * SPEED_OF_LIGHT_M_S

            if axis == "abs":
                y_fin = np.abs(y)
            elif axis == "real":
                y_fin = np.real(y)
            elif axis == "imag":
                y_fin = np.imag(y)
            elif axis == "phase":
                y_fin = np.angle(y)
            else:
                raise ValueError(f"Invalid axis parameter: {axis!r}")

            # Truncate output to computed length (optional: keep full)
            return depth_m[: y_fin.size], y_fin.astype(float, copy=False)

        except Exception as exc:  # noqa: BLE001
            logger.error("IFFT failed", error=repr(exc))
            # Graceful fallback: synthetic axes with magnitude of input
            depth_fallback = np.linspace(0.0, 1.0, s_array.size, dtype=float)
            return depth_fallback, np.abs(s_array).astype(float, copy=False)

    def _apply_depth_processing(
        self,
        depth_m: NDArray[np.floating],
        response: NDArray[np.floating],
    ) -> tuple[NDArray[np.floating], NDArray[np.floating]]:
        """
        Apply depth windowing and gain shaping.

        Window
        ------
        Keep samples where:
            depth in [2*cut, 2*cut + 2*max]
        Then map depth to:
            depth' = (depth - 2*cut) / 2

        Gain
        ----
        response' = response * (depth' ** gain)
        """
        try:
            cut = float(self._config["cut"])
            max_depth = float(self._config["max"])
            gain = float(self._config["gain"])

            # Window: account for two-way path in raw depth (factor 2)
            lo = 2.0 * cut
            hi = lo + 2.0 * max_depth
            mask = (depth_m >= lo) & (depth_m <= hi)

            if not np.any(mask):
                # Nothing in window; return empty arrays
                return depth_m[:0], response[:0]

            depth_win = depth_m[mask]
            resp_win = response[mask]

            # Convert to one-way depth relative to the cut
            depth_out = (depth_win - lo) / 2.0

            # Depth-dependent gain (safe for zero depth with exponent >= 0)
            with np.errstate(invalid="ignore"):
                gain_shape = np.power(depth_out, gain, dtype=float)
                # Replace NaN/inf (e.g., 0**negative) with 0
                gain_shape[~np.isfinite(gain_shape)] = 0.0

            response_out = resp_win * gain_shape
            return depth_out, response_out

        except Exception as exc:  # noqa: BLE001
            logger.error("Depth processing failed", error=repr(exc))
            return depth_m, response

    # -------------------------------------------------------------------------
    # State export override
    # -------------------------------------------------------------------------

    def get_full_state(self) -> dict[str, Any]:
        """
        Return complete processor state including BScan-specific data.

        Extends base implementation with plot history count and plot data.
        """
        # Get base state (includes active_reference)
        state = super().get_full_state()

        # Add BScan-specific information
        with self._lock:
            state["plot_history_count"] = len(self._plot_history)

            # Include plot history data for preview functionality
            all_time_domain = [record["time_domain_data"] for record in self._plot_history]
            all_distance = [record["distance_data"] for record in self._plot_history]
            all_sweep_numbers = list(range(1, len(self._plot_history) + 1))
            all_timestamps = [record["timestamp"] for record in self._plot_history]

            state["plot_data"] = {
                "all_time_domain_data": all_time_domain,
                "all_distance_data": all_distance,
                "all_sweep_numbers": all_sweep_numbers,
                "all_timestamps": all_timestamps,
            }

        return state
