"""State builders and config parsing helpers for the main window."""

from __future__ import annotations

import json

from python_app.models.gui_profile_model import (
    GuiBscanStateModel,
    GuiDataActionsStateModel,
    GuiGprStateModel,
    GuiLegacyGprStateModel,
    GuiPassThroughStateModel,
    GuiPreprocessDialogStateModel,
    GuiProcessingStateModel,
    GuiProfileModel,
    GuiStateModel,
    GuiSwitchStateModel,
)
from python_app.models.run_config_model import ComboModel, GprRxGeometryModel, GprTxGeometryModel, RunConfigModel
from python_app.models.run_config_validation import validate_gpr_model
from python_app.orchestration.config_writer import parse_combos_from_text
from python_app.orchestration.preprocess_assets import (
    PREPROCESS_ASSET_KEYS,
    VISIBLE_PREPROCESS_ASSET_KEYS,
    preprocess_asset_model,
)
from python_app.storage.npz_store import radar_key_from_config


class AppWindowConfigStateBuildersMixin:
    """Build stable and GUI-only config models from current widget state."""

    @staticmethod
    def _parse_csv_int_list(text: str) -> list[int]:
        """Parse comma-separated integer selection list."""
        cleaned = text.strip()
        if not cleaned:
            return []
        values: list[int] = []
        for part in cleaned.split(","):
            token = part.strip()
            if not token:
                continue
            values.append(int(token))
        return values

    @staticmethod
    def _parse_multiline_string_list(text: str) -> list[str]:
        """Parse one command argument per non-empty line."""
        return [line.strip() for line in text.splitlines() if line.strip()]

    @staticmethod
    def _parse_string_env_json(text: str) -> dict[str, str]:
        """Parse strict string-to-string environment JSON."""
        cleaned = text.strip()
        if not cleaned:
            return {}
        payload = json.loads(cleaned)
        if not isinstance(payload, dict):
            raise ValueError("Environment JSON must be an object")
        env: dict[str, str] = {}
        for key, value in payload.items():
            if not isinstance(key, str) or not isinstance(value, str):
                raise ValueError("Environment JSON must contain only string keys and values")
            env[key] = value
        return env

    @staticmethod
    def _parse_geometry_line_coordinates(parts: list[str]) -> tuple[float, float, float]:
        """Parse 1/2/3 trailing coordinate fields into (x, y, z); missing axes default to 0."""
        x_m = float(parts[0])
        y_m = float(parts[1]) if len(parts) >= 2 else 0.0
        z_m = float(parts[2]) if len(parts) >= 3 else 0.0
        return x_m, y_m, z_m

    @staticmethod
    def _parse_gpr_tx_geometry_text(text: str) -> list[GprTxGeometryModel]:
        """Parse line-based Tx geometry editor text: `output_pos x_m [y_m] [z_m]`."""
        entries: list[GprTxGeometryModel] = []
        for line_number, raw_line in enumerate(text.splitlines(), start=1):
            line = raw_line.strip()
            if not line:
                continue
            parts = line.split()
            if len(parts) < 2 or len(parts) > 4:
                raise ValueError(
                    f"Invalid Tx geometry line {line_number}: expected `output_pos x_m [y_m] [z_m]`"
                )
            x_m, y_m, z_m = AppWindowConfigStateBuildersMixin._parse_geometry_line_coordinates(parts[1:])
            entries.append(
                GprTxGeometryModel(output_pos=int(parts[0]), x_m=x_m, y_m=y_m, z_m=z_m)
            )
        return entries

    @staticmethod
    def _parse_gpr_rx_geometry_text(text: str) -> list[GprRxGeometryModel]:
        """Parse line-based Rx geometry editor text: `input_pos x_m [y_m] [z_m]`."""
        entries: list[GprRxGeometryModel] = []
        for line_number, raw_line in enumerate(text.splitlines(), start=1):
            line = raw_line.strip()
            if not line:
                continue
            parts = line.split()
            if len(parts) < 2 or len(parts) > 4:
                raise ValueError(
                    f"Invalid Rx geometry line {line_number}: expected `input_pos x_m [y_m] [z_m]`"
                )
            x_m, y_m, z_m = AppWindowConfigStateBuildersMixin._parse_geometry_line_coordinates(parts[1:])
            entries.append(
                GprRxGeometryModel(input_pos=int(parts[0]), x_m=x_m, y_m=y_m, z_m=z_m)
            )
        return entries

    @staticmethod
    def _format_combos_text_from_config(config: RunConfigModel) -> str:
        """Render configured combos for UI text editor, keeping full matrix as empty."""
        if config.is_matrix_radar:
            return ",".join(
                f"{int(combo.input)}:{int(combo.output)}"
                for combo in RunConfigModel.build_matrix_radar_virtual_combos()
            )
        combos = list(config.combos)
        full_combos = config.build_full_combos(config.input_switch.positions, config.output_switch.positions)
        if len(combos) == len(full_combos) and all(
            int(left.input) == int(right.input) and int(left.output) == int(right.output)
            for left, right in zip(combos, full_combos, strict=True)
        ):
            return ""
        return ",".join(f"{int(combo.input)}:{int(combo.output)}" for combo in combos)

    @staticmethod
    def _default_gpr_input_positions_from_config(config: RunConfigModel) -> str:
        """Build default live GPR input-position selection from stable config."""
        geometry_values = {int(entry.input_pos) for entry in config.gpr.rx_geometry}
        combo_values = {int(combo.input) for combo in config.combos}
        values = sorted(geometry_values & combo_values) or sorted(geometry_values)
        return ",".join(str(value) for value in values)

    @staticmethod
    def _default_gpr_output_positions_from_config(config: RunConfigModel) -> str:
        """Build default live GPR output-position selection from stable config."""
        geometry_values = {int(entry.output_pos) for entry in config.gpr.tx_geometry}
        combo_values = {int(combo.output) for combo in config.combos}
        values = sorted(geometry_values & combo_values) or sorted(geometry_values)
        return ",".join(str(value) for value in values)

    @staticmethod
    def _default_gpr_visible_x_bounds_from_config(config: RunConfigModel) -> tuple[float, float]:
        """Build default visible X-range for GPR object-only rendering."""
        x_values = [float(entry.x_m) for entry in config.gpr.tx_geometry]
        x_values.extend(float(entry.x_m) for entry in config.gpr.rx_geometry)
        if not x_values:
            return (-2.0, 2.0)
        margin_m = 2.0
        return (min(x_values) - margin_m, max(x_values) + margin_m)

    @staticmethod
    def _history_limit_for_config(config: RunConfigModel) -> int:
        """Return unified GUI history limit derived from config ring capacities."""
        return max(
            1,
            min(
                int(config.rings.raw_tap.capacity),
                int(config.rings.preprocessed_tap.capacity),
                int(config.rings.results.capacity),
            ),
        )

    def _default_gui_state_for_config(self, config: RunConfigModel) -> GuiStateModel:
        """Build fallback GUI-only defaults for a stable run config."""
        default_combo = config.combos[0] if config.combos else ComboModel(input=0, output=0)
        default_mode = "single" if len(config.combos) == 1 else "text"
        default_gpr_x_min_m, default_gpr_x_max_m = self._default_gpr_visible_x_bounds_from_config(config)
        return GuiStateModel(
            switches=GuiSwitchStateModel(
                combo_mode=default_mode,
                combos_text=self._format_combos_text_from_config(config),
                single_input=str(int(default_combo.input)),
                single_output=str(int(default_combo.output)),
            ),
            processing=GuiProcessingStateModel(
                selected_mode="pass_through",
                pass_through=GuiPassThroughStateModel(
                    show_magnitude=True,
                    show_phase=True,
                    combo_filter="",
                    fixed_y_enabled=False,
                    y_min_db=-100.0,
                    y_max_db=0.0,
                ),
                bscan=GuiBscanStateModel(
                    axis="abs",
                    cut_m=0.824,
                    max_depth_m=1.0,
                    gain=1.0,
                    start_freq_mhz=100.0,
                    stop_freq_mhz=8800.0,
                    subtract_mean_ascan=False,
                ),
                gpr=GuiGprStateModel(
                    input_positions=self._default_gpr_input_positions_from_config(config),
                    output_positions=self._default_gpr_output_positions_from_config(config),
                    min_depth_m=2.0,
                    max_depth_m=14.0,
                    range_comp_power=0.28,
                    angle_comp_power=0.10,
                    score_mode="combined",
                    max_detected_objects_to_draw=5,
                    draw_top_m_objects=2,
                    start_freq_mhz=3000.0,
                    stop_freq_mhz=6000.0,
                    background_subtract_enabled=True,
                    background_mean_count=10,
                    remove_sidelobe_objects_enabled=True,
                    imaging_plane_y_m=0.0,
                    render_mode="heatmap",
                    min_visible_score=0.0,
                    visible_x_min_m=default_gpr_x_min_m,
                    visible_x_max_m=default_gpr_x_max_m,
                    visible_z_min_m=0.0,
                    visible_z_max_m=14.0,
                ),
                legacy_gpr=GuiLegacyGprStateModel(
                    input_positions=self._default_gpr_input_positions_from_config(config),
                    output_positions=self._default_gpr_output_positions_from_config(config),
                    min_depth_m=2.0,
                    max_depth_m=14.0,
                    comp_power=0.2,
                    start_freq_mhz=3000.0,
                    stop_freq_mhz=6000.0,
                    speed_m_s=0.0,
                    ignore_socket_speed_enabled=False,
                    look_angle_deg=0.0,
                    snr_thresh=4.5,
                    snr_comp_max=25.0,
                    background_subtract_enabled=True,
                    background_mean_count=10,
                    render_mode="heatmap",
                    min_visible_pair_count=1,
                    visible_x_min_m=default_gpr_x_min_m,
                    visible_x_max_m=default_gpr_x_max_m,
                    visible_z_min_m=0.0,
                    visible_z_max_m=14.0,
                ),
            ),
            data_actions=GuiDataActionsStateModel(
                save_count=10,
                save_path=str(self._project_root / "python_app/data/snapshots"),
                save_name="snapshot_manual",
            ),
            preprocess_dialog=GuiPreprocessDialogStateModel(
                set_name="set_001",
                radar_config_dir="",
                use_all_radar_configs=False,
            ),
        )

    def _current_preprocess_set_name(self) -> str:
        """Return current preprocess dialog set name, even when dialog is still closed."""
        if self._preprocess_dialog is not None:
            self._preprocess_set_name = self._preprocess_dialog.set_name()
        return self._preprocess_set_name

    def _current_preprocess_radar_config_dir(self) -> str:
        """Return current preprocess dialog radar-config directory path."""
        if self._preprocess_dialog is not None:
            self._preprocess_radar_config_dir = self._preprocess_dialog.radar_config_dir()
        return self._preprocess_radar_config_dir

    def _current_preprocess_use_all_radar_configs(self) -> bool:
        """Return whether multi-radar preprocessing is enabled in the dialog."""
        if self._preprocess_dialog is not None:
            self._preprocess_use_all_radar_configs = self._preprocess_dialog.use_all_radar_configs()
        return bool(self._preprocess_use_all_radar_configs)

    def _current_preprocess_median_sweep_count(self) -> int:
        """Return current per-combo median sweep count from the dialog."""
        if self._preprocess_dialog is not None:
            self._preprocess_median_sweep_count = self._preprocess_dialog.median_sweep_count()
        return max(1, int(self._preprocess_median_sweep_count))

    def _build_gui_state(self) -> GuiStateModel:
        """Build GUI-only persistent state from current widget values."""
        return GuiStateModel(
            switches=GuiSwitchStateModel(
                combo_mode=(
                    "text"
                    if self._is_matrix_radar_model_selected()
                    else "single"
                    if self._single_combo_select_button.isChecked()
                    else "text"
                ),
                combos_text=(
                    self._fixed_matrix_radar_combo_text()
                    if self._is_matrix_radar_model_selected()
                    else self._combos_text.text().strip()
                ),
                single_input=self._single_combo_input.text().strip(),
                single_output=self._single_combo_output.text().strip(),
            ),
            processing=GuiProcessingStateModel(
                selected_mode=self._processing_mode.currentText(),
                pass_through=GuiPassThroughStateModel(
                    show_magnitude=bool(self._show_magnitude_checkbox.isChecked()),
                    show_phase=bool(self._show_phase_checkbox.isChecked()),
                    combo_filter=self._pass_through_combo_filter_input.text().strip(),
                    fixed_y_enabled=bool(self._pass_through_fixed_y_enabled.isChecked()),
                    y_min_db=float(self._pass_through_y_min_db.value()),
                    y_max_db=float(self._pass_through_y_max_db.value()),
                ),
                bscan=GuiBscanStateModel(
                    axis=self._bscan_axis.currentText(),
                    cut_m=float(self._bscan_cut_m.value()),
                    max_depth_m=float(self._bscan_max_depth_m.value()),
                    gain=float(self._bscan_gain.value()),
                    start_freq_mhz=float(self._bscan_start_freq_mhz.value()),
                    stop_freq_mhz=float(self._bscan_stop_freq_mhz.value()),
                    subtract_mean_ascan=bool(self._bscan_subtract_mean_ascan.isChecked()),
                ),
                gpr=GuiGprStateModel(
                    input_positions=self._gpr_input_positions_input.text().strip(),
                    output_positions=self._gpr_output_positions_input.text().strip(),
                    min_depth_m=float(self._gpr_min_depth_m.value()),
                    max_depth_m=float(self._gpr_max_depth_m.value()),
                    range_comp_power=float(self._gpr_range_comp_power.value()),
                    angle_comp_power=float(self._gpr_angle_comp_power.value()),
                    score_mode=self._gpr_score_mode.currentText(),
                    max_detected_objects_to_draw=int(self._gpr_max_detected_objects_to_draw.value()),
                    draw_top_m_objects=int(self._gpr_draw_top_m_objects.value()),
                    start_freq_mhz=float(self._gpr_start_freq_mhz.value()),
                    stop_freq_mhz=float(self._gpr_stop_freq_mhz.value()),
                    background_subtract_enabled=bool(self._gpr_background_subtract_enabled.isChecked()),
                    background_mean_count=int(self._gpr_background_mean_count.value()),
                    remove_sidelobe_objects_enabled=bool(self._gpr_remove_sidelobe_objects_enabled.isChecked()),
                    imaging_plane_y_m=float(self._gpr_imaging_plane_y_m.value()),
                    render_mode=self._gpr_render_mode.currentText(),
                    min_visible_score=float(self._gpr_min_visible_score.value()),
                    visible_x_min_m=float(self._gpr_visible_x_min_m.value()),
                    visible_x_max_m=float(self._gpr_visible_x_max_m.value()),
                    visible_z_min_m=float(self._gpr_visible_z_min_m.value()),
                    visible_z_max_m=float(self._gpr_visible_z_max_m.value()),
                ),
                legacy_gpr=GuiLegacyGprStateModel(
                    mode=self._legacy_gpr_config_mode.currentText(),
                    input_positions=self._legacy_gpr_input_positions_input.text().strip(),
                    output_positions=self._legacy_gpr_output_positions_input.text().strip(),
                    min_depth_m=float(self._legacy_gpr_min_depth_m.value()),
                    max_depth_m=float(self._legacy_gpr_max_depth_m.value()),
                    comp_power=float(self._legacy_gpr_comp_power.value()),
                    start_freq_mhz=float(self._legacy_gpr_start_freq_mhz.value()),
                    stop_freq_mhz=float(self._legacy_gpr_stop_freq_mhz.value()),
                    speed_m_s=float(self._legacy_gpr_speed_m_s.value()),
                    ignore_socket_speed_enabled=bool(self._legacy_gpr_ignore_socket_speed_enabled.isChecked()),
                    look_angle_deg=float(self._legacy_gpr_look_angle_deg.value()),
                    snr_thresh=float(self._legacy_gpr_snr_thresh.value()),
                    snr_comp_max=float(self._legacy_gpr_snr_comp_max.value()),
                    background_subtract_enabled=bool(self._legacy_gpr_background_subtract_enabled.isChecked()),
                    background_mean_count=int(self._legacy_gpr_background_mean_count.value()),
                    render_mode=self._legacy_gpr_render_mode.currentText(),
                    min_visible_pair_count=int(self._legacy_gpr_min_visible_pair_count.value()),
                    visible_x_min_m=float(self._legacy_gpr_visible_x_min_m.value()),
                    visible_x_max_m=float(self._legacy_gpr_visible_x_max_m.value()),
                    visible_z_min_m=float(self._legacy_gpr_visible_z_min_m.value()),
                    visible_z_max_m=float(self._legacy_gpr_visible_z_max_m.value()),
                ),
            ),
            data_actions=GuiDataActionsStateModel(
                save_count=int(self._save_count.value()),
                save_path=self._save_path_input.text().strip(),
                save_name=self._save_name_input.text().strip(),
            ),
            preprocess_dialog=GuiPreprocessDialogStateModel(
                set_name=self._current_preprocess_set_name(),
                radar_config_dir=self._current_preprocess_radar_config_dir(),
                use_all_radar_configs=self._current_preprocess_use_all_radar_configs(),
                median_sweep_count=self._current_preprocess_median_sweep_count(),
            ),
        )

    def _build_gui_profile(self) -> GuiProfileModel:
        """Build full GUI config profile from current window state."""
        return GuiProfileModel(
            run_config=self._build_config(),
            gui=self._build_gui_state(),
        )

    def _build_config(self) -> RunConfigModel:
        """Build `RunConfigModel` from current GUI widget values."""
        config = self._defaults_config.clone()

        config.radar.sweep.start_hz = float(self._start_hz_input.text().strip())
        config.radar.sweep.stop_hz = float(self._stop_hz_input.text().strip())
        config.radar.sweep.points = int(self._points_input.text().strip())
        config.radar.sweep.if_bandwidth_hz = float(self._ifbw_input.text().strip())
        config.radar.sweep.power_dbm = float(self._power_input.text().strip())
        if config.is_kamil_adc:
            self._apply_adc_settings_to_config(config)

        config.runtime.settling_ms = int(self._settling_ms.text().strip())
        config.runtime.processing_live_config_path = str(self._live_config_writer.path)

        if config.is_multi_device:
            if len(config.radar.multi_device.slave_serials) != 2:
                raise ValueError("LibreVNA multi-device mode requires exactly two slave serials")
            config.apply_device_model_constraints()
        else:
            if self._single_combo_select_button.isChecked():
                config.combos = [
                    ComboModel(
                        input=int(self._single_combo_input.text().strip()),
                        output=int(self._single_combo_output.text().strip()),
                    )
                ]
            else:
                combo_text = self._combos_text.text()
                config.combos = parse_combos_from_text(combo_text)
                config.ensure_combos()
            if self._processing_mode.currentText() not in {"gpr", "legacy_gpr"} and self._switches_are_effectively_static(config):
                config.combos = [ComboModel(input=0, output=0)]

        for key in PREPROCESS_ASSET_KEYS:
            preprocess_asset_model(config, key).bundle_path = ""
        for key in VISIBLE_PREPROCESS_ASSET_KEYS:
            preprocess_asset_model(config, key).set_name = self._selected_preprocess_sets.get(key, "")
        config.gpr.relative_permittivity = float(self._gpr_relative_permittivity.value())
        config.gpr.tx_geometry = self._parse_gpr_tx_geometry_text(self._gpr_tx_geometry_input.toPlainText())
        config.gpr.rx_geometry = self._parse_gpr_rx_geometry_text(self._gpr_rx_geometry_input.toPlainText())
        validate_gpr_model(
            config.gpr,
            input_switch_positions=config.input_switch.positions,
            output_switch_positions=config.output_switch.positions,
        )
        return config

    def _radar_key(self, config: RunConfigModel) -> str:
        """Build radar key used by preprocess-set storage lookup."""
        return radar_key_from_config(
            model_name=config.radar.model,
            serial=config.radar.serial,
            sweep_start_hz=config.radar.sweep.start_hz,
            sweep_stop_hz=config.radar.sweep.stop_hz,
            sweep_points=config.radar.sweep.points,
            ifbw_hz=config.radar.sweep.if_bandwidth_hz,
            power_dbm=config.radar.sweep.power_dbm,
            extra_serials=config.radar_key_extra_parts() or None,
        )

    def _radar_key_from_ui(self) -> str:
        """Build current radar key directly from radar widgets only."""
        model_name = self._defaults_config.radar.model or RunConfigModel.LIBREVNA_MODEL
        extra_parts = self._defaults_config.radar_key_extra_parts()
        if self._defaults_config.is_kamil_adc:
            config = self._defaults_config.clone()
            config.radar.sweep.start_hz = float(self._start_hz_input.text().strip())
            config.radar.sweep.stop_hz = float(self._stop_hz_input.text().strip())
            config.radar.sweep.points = int(self._points_input.text().strip())
            config.radar.sweep.if_bandwidth_hz = float(self._ifbw_input.text().strip())
            config.radar.sweep.power_dbm = float(self._power_input.text().strip())
            self._apply_adc_settings_to_config(config)
            extra_parts = config.radar_key_extra_parts()
        return radar_key_from_config(
            model_name=model_name,
            serial=self._defaults_config.radar.serial,
            sweep_start_hz=float(self._start_hz_input.text().strip()),
            sweep_stop_hz=float(self._stop_hz_input.text().strip()),
            sweep_points=int(self._points_input.text().strip()),
            ifbw_hz=float(self._ifbw_input.text().strip()),
            power_dbm=float(self._power_input.text().strip()),
            extra_serials=extra_parts or None,
        )

    def _apply_adc_settings_to_config(self, config: RunConfigModel) -> None:
        """Copy visible ADC collector and optical-board settings into config."""
        adc = config.radar.kamil_adc
        adc.project_dir = self._adc_project_dir_input.text().strip()
        adc.executable_path = self._adc_executable_path_input.text().strip()
        adc.tty_path = self._adc_tty_path_input.text().strip()
        adc.args = self._parse_multiline_string_list(self._adc_args_input.toPlainText())
        adc.env = self._parse_string_env_json(self._adc_env_input.toPlainText())
        adc.startup_timeout_s = float(self._adc_startup_timeout_s_input.text().strip())
        adc.sweep_timeout_s = float(self._adc_sweep_timeout_s_input.text().strip())
        adc.stop_timeout_s = float(self._adc_stop_timeout_s_input.text().strip())

        optical = config.radar.laser_control
        optical.enabled = bool(self._optical_enabled_checkbox.isChecked())
        optical.port = self._optical_port_input.text().strip()
        optical.mode = self._optical_mode_combo.currentText()
        optical.pi_coeff1_p = int(self._optical_pi_coeff1_p_input.text().strip())
        optical.pi_coeff1_i = int(self._optical_pi_coeff1_i_input.text().strip())
        optical.pi_coeff2_p = int(self._optical_pi_coeff2_p_input.text().strip())
        optical.pi_coeff2_i = int(self._optical_pi_coeff2_i_input.text().strip())

        optical.manual.temp1 = float(self._optical_manual_temp1_input.text().strip())
        optical.manual.temp2 = float(self._optical_manual_temp2_input.text().strip())
        optical.manual.current1 = float(self._optical_manual_current1_input.text().strip())
        optical.manual.current2 = float(self._optical_manual_current2_input.text().strip())

        optical.variation.variation_type = self._optical_variation_type_combo.currentText()
        optical.variation.static_temp1 = float(self._optical_static_temp1_input.text().strip())
        optical.variation.static_temp2 = float(self._optical_static_temp2_input.text().strip())
        optical.variation.static_current1 = float(self._optical_static_current1_input.text().strip())
        optical.variation.static_current2 = float(self._optical_static_current2_input.text().strip())
        optical.variation.min_value = float(self._optical_min_value_input.text().strip())
        optical.variation.max_value = float(self._optical_max_value_input.text().strip())
        optical.variation.step = float(self._optical_step_input.text().strip())
        optical.variation.time_step = int(self._optical_time_step_input.text().strip())
        optical.variation.delay_time = int(self._optical_delay_time_input.text().strip())

    @staticmethod
    def _switches_are_effectively_static(config: RunConfigModel) -> bool:
        """Return `True` when non-GPR switch setup effectively yields one fixed combo."""
        if config.is_multi_device:
            return False
        has_single_position = config.input_switch.positions <= 1 and config.output_switch.positions <= 1
        both_mock = config.input_switch.driver_mode == "mock" and config.output_switch.driver_mode == "mock"
        return has_single_position or both_mock
