#  Copyright 2026 European Union
#  Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
#  SPDX-License-Identifier: EUPL-1.2
"""Unit tests covering the steering GUI controller API.

:Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
:Description: Keep controller behaviors isolated and verify validation, persistence, and snapshots.
"""

from __future__ import annotations

from pathlib import Path
from typing import Any, Iterable

import pytest
import tomlkit

from mafw.db.db_model import MAFwBaseModel
from mafw.models.filter_schema import FilterSchema

# Skip this module if PySide6 is not available
pytest.importorskip('PySide6', reason='PySide6 is required for GUI tests')


from mafw.db.db_configurations import default_conf
from mafw.mafw_errors import MissingProcessorsToRunIssue
from mafw.models.parameter_schema import ParameterSchema
from mafw.models.processor_schema import ProcessorSchema
from mafw.plugin_manager import LoadedPlugins
from mafw.steering.builder import ValidationLevel
from mafw.steering.models import ParameterSource
from mafw.steering_gui.controllers.steering_controller import (
    SteeringController,
    SteeringControllerError,
    build_database_connection_configuration,
)

pytestmark = pytest.mark.gui


class MockProcessor:
    @classmethod
    def parameter_schema(cls) -> list[ParameterSchema]:
        return [
            ParameterSchema(name='alpha', annotation=int, default=1, help='alpha', is_list=False, is_dict=False),
            ParameterSchema(name='beta', annotation=str, default='x', help='beta', is_list=False, is_dict=False),
        ]

    @classmethod
    def filter_schema(cls) -> None:
        return None

    @classmethod
    def processor_schema(cls) -> ProcessorSchema:
        return ProcessorSchema(parameters=cls.parameter_schema(), filter=cls.filter_schema())


class ModelA(MAFwBaseModel):
    pass


class ModelB(MAFwBaseModel):
    pass


class MockProcessorWithFilter:
    @classmethod
    def processor_schema(cls) -> ProcessorSchema:
        return ProcessorSchema(
            parameters=[],
            filter=FilterSchema(root_model=ModelA, allowed_models=(ModelB,)),
        )


def test_processors_list_copy_isolation() -> None:
    """Ensure processors_to_run snapshots cannot mutate the builder state."""

    controller = SteeringController()
    controller.add_processor_to_run_list('Core')
    snapshot = controller.get_processors_to_run()
    snapshot.append('Injected')
    assert 'Injected' not in controller.get_processors_to_run()


def test_move_processor_reorders_run_list() -> None:
    """Ensure the controller can reposition a processor within the run list."""

    controller = SteeringController()
    controller.add_processor_to_run_list('First')
    controller.add_processor_to_run_list('Second')
    controller.add_processor_to_run_list('Third')

    controller.move_processor_in_run_list('First', 2)
    assert controller.get_processors_to_run() == ['Second', 'Third', 'First']


def test_move_processor_rejects_unknown_entries() -> None:
    """Ensure moving an unscheduled processor raises a controller error."""

    controller = SteeringController()
    with pytest.raises(SteeringControllerError):
        controller.move_processor_in_run_list('Unknown', 0)


def test_move_processor_rejects_out_of_range_index() -> None:
    """Ensure the controller rejects invalid run list positions."""

    controller = SteeringController()
    controller.add_processor_to_run_list('A')
    with pytest.raises(SteeringControllerError):
        controller.move_processor_in_run_list('A', 5)


def test_move_pipeline_entry_reorders_top_level() -> None:
    """Ensure pipeline entries can be reordered within the top-level run list."""

    controller = SteeringController()
    controller.add_processor_to_run_list('First')
    controller.add_processor_to_run_list('Second')
    controller.add_processor_to_run_list('Third')

    controller.move_pipeline_entry(
        entry_name='First',
        entry_is_group=False,
        source_group=None,
        target_group=None,
        position=2,
    )
    assert controller.get_processors_to_run() == ['Second', 'Third', 'First']


def test_move_pipeline_entry_moves_into_group() -> None:
    """Ensure pipeline entries can be moved from top-level into a group."""

    controller = SteeringController()
    controller.add_processor_to_run_list('First')
    controller.add_processor_to_run_list('Second')
    controller.add_group('GroupA', ['First'])
    controller.add_processor_to_run_list('GroupA')

    controller.move_pipeline_entry(
        entry_name='Second',
        entry_is_group=False,
        source_group=None,
        target_group='GroupA',
        position=0,
    )
    assert controller.get_processors_to_run() == ['First', 'GroupA']
    assert controller.get_group_snapshot('GroupA').processors == ['Second', 'First']


def test_move_pipeline_entry_moves_out_of_group() -> None:
    """Ensure pipeline entries can be moved from a group to top-level."""

    controller = SteeringController()
    controller.add_group('GroupB', ['Alpha', 'Beta'])
    controller.add_processor_to_run_list('GroupB')

    controller.move_pipeline_entry(
        entry_name='Alpha',
        entry_is_group=False,
        source_group='GroupB',
        target_group=None,
        position=0,
    )
    assert controller.get_processors_to_run() == ['Alpha', 'GroupB']
    assert controller.get_group_snapshot('GroupB').processors == ['Beta']


def test_remove_pipeline_entry_from_group() -> None:
    """Removing a pipeline entry from a group should update its list."""

    controller = SteeringController()
    controller.add_group('GroupX', ['A', 'B'])
    controller.add_processor_to_run_list('GroupX')

    controller.remove_pipeline_entry('A', 'GroupX')
    assert controller.get_group_snapshot('GroupX').processors == ['B']


def test_rename_group_updates_references() -> None:
    """Renaming a group should update run-list and nested references."""

    controller = SteeringController()
    controller.add_group('GroupA', ['Proc1'])
    controller.add_group('Parent', ['GroupA'])
    controller.add_group_to_run_list('GroupA')

    controller.rename_group('GroupA', 'GroupX')

    assert controller.get_processors_to_run() == ['GroupX']
    assert controller.get_group_snapshot('Parent').processors == ['GroupX']
    assert controller.get_group_snapshot('GroupX').name == 'GroupX'


def test_rename_group_rejects_conflicting_name() -> None:
    """Renaming a group to an existing processor name should fail."""

    controller = SteeringController()
    controller.add_processor_to_run_list('ProcA')
    controller.add_group('GroupA', [])

    with pytest.raises(SteeringControllerError):
        controller.rename_group('GroupA', 'ProcA')


def test_set_group_description_normalizes_empty() -> None:
    """Empty descriptions should be stored as None."""

    controller = SteeringController()
    controller.add_group('GroupA', [], 'hello')

    controller.set_group_description('GroupA', '   ')

    assert controller.get_group_snapshot('GroupA').description is None


def test_add_group_entry_rejects_cycle() -> None:
    """Adding a group that creates a cycle should raise."""

    controller = SteeringController()
    controller.add_group('A', ['B'])
    controller.add_group('B', [])

    with pytest.raises(SteeringControllerError):
        controller.add_group_entry_to_group('B', 'A')


def test_add_processors_to_group_with_defaults_populates_schema_defaults() -> None:
    """Ensure processors added to groups get schema defaults."""

    controller = SteeringController()
    plugins = LoadedPlugins()
    plugins.processor_dict = {'MockProcessor': MockProcessor}
    controller.plugins = plugins
    controller.add_group('GroupA', [])

    controller.add_processors_to_group_with_defaults('GroupA', ['MockProcessor'])

    group = controller.get_group_snapshot('GroupA')
    assert group.processors == ['MockProcessor']
    config = controller.get_processor_snapshot('MockProcessor')
    assert config.parameters['alpha'].value == 1
    assert config.parameters['beta'].value == 'x'
    assert config.parameters['alpha'].source == ParameterSource.DEFAULT


def test_move_pipeline_entry_prevents_cycles() -> None:
    """Ensure moving groups does not introduce cycles."""

    controller = SteeringController()
    controller.add_group('GroupA', ['GroupB'])
    controller.add_group('GroupB', [])
    controller.add_processor_to_run_list('GroupA')

    with pytest.raises(SteeringControllerError):
        controller.move_pipeline_entry(
            entry_name='GroupA',
            entry_is_group=True,
            source_group=None,
            target_group='GroupB',
            position=0,
        )


def test_validate_reports_missing_processor() -> None:
    """Ensure validation reports missing processors_to_run and ensure_valid raises."""

    controller = SteeringController()
    issues = controller.validate()
    assert any(isinstance(issue, MissingProcessorsToRunIssue) for issue in issues)
    with pytest.raises(MissingProcessorsToRunIssue):
        controller.ensure_valid()


def test_save_validates_before_writing(tmp_path: Path) -> None:
    """Ensure save_as validates by default and writes a TOML file."""

    controller = SteeringController()
    controller.add_processor_to_run_list('Core')
    target = tmp_path / 'steering.toml'
    controller.save_as(target)

    content = tomlkit.loads(target.read_text(encoding='utf-8'))
    assert content['processors_to_run'] == ['Core']
    assert controller.current_file() == target


def test_save_as_requires_valid_configuration(tmp_path: Path) -> None:
    """Ensure save_as raises when the resolver is invalid and cannot serialize."""

    controller = SteeringController()
    target = tmp_path / 'steering.toml'
    with pytest.raises(MissingProcessorsToRunIssue):
        controller.save_as(target)


def test_save_as_skips_validation_for_invalid_builder(tmp_path: Path) -> None:
    """Ensure skip-validation save_as still writes even when the configuration is invalid."""

    controller = SteeringController()
    target = tmp_path / 'invalid.toml'
    controller.save_as(target, skip_validation=True)
    assert target.exists()
    content = tomlkit.loads(target.read_text(encoding='utf-8'))
    assert 'processors_to_run' in content


def test_save_allows_invalid_configuration_when_requested(tmp_path: Path) -> None:
    """Ensure skip-validation save persists an already-tracked invalid file."""

    controller = SteeringController()
    target = tmp_path / 'invalid.toml'
    controller.save_as(target, skip_validation=True)
    controller.set_analysis_description('desc')
    controller.save(skip_validation=True)
    assert target.exists()


def test_processor_snapshot_isolated() -> None:
    """Ensure processor snapshots return deep copies of mutable fields."""

    controller = SteeringController()
    controller.add_processor_to_run_list('Worker')
    controller.set_processor_parameter('Worker', 'threshold', 7)
    snapshot = controller.get_processor_snapshot('Worker')
    snapshot.parameters['threshold'].value = 99
    assert controller.get_processor_snapshot('Worker').parameters['threshold'].value == 7


def test_remove_processor_parameter_clears_override() -> None:
    """Removing a processor parameter should drop the override entry."""

    controller = SteeringController()
    controller.add_processor_to_run_list('Proc')
    controller.set_processor_parameter('Proc', 'alpha', 1)

    controller.remove_processor_parameter('Proc', 'alpha')
    snapshot = controller.get_processor_snapshot('Proc')
    assert 'alpha' not in snapshot.parameters


def test_clear_processor_parameters_removes_all_overrides() -> None:
    """Clearing processor parameters should remove every override entry."""

    controller = SteeringController()
    controller.add_processor_to_run_list('Proc')
    controller.set_processor_parameter('Proc', 'alpha', 1)
    controller.set_processor_parameter('Proc', 'beta', 'x')

    controller.clear_processor_parameters('Proc')
    snapshot = controller.get_processor_snapshot('Proc')
    assert snapshot.parameters == {}


def test_rename_processor_entry_moves_replica() -> None:
    """Renaming a replica should update the run list and config key."""

    controller = SteeringController()
    controller.add_processor_to_run_list('Proc#A')
    controller.set_processor_parameter('Proc#A', 'alpha', 5)

    controller.rename_processor_entry('Proc#A', 'Proc#B', None)
    assert controller.get_processors_to_run() == ['Proc#B']
    snapshot = controller.get_processor_snapshot('Proc#B')
    assert snapshot.parameters['alpha'].value == 5


def test_rename_base_to_replica_copies_config() -> None:
    """Renaming a base entry to a replica should preserve base configuration data."""

    controller = SteeringController()
    controller.add_processor_to_run_list('Proc')
    controller.set_processor_parameter('Proc', 'alpha', 3)

    controller.rename_processor_entry('Proc', 'Proc#R1', None)
    assert controller.get_processors_to_run() == ['Proc#R1']
    assert controller.get_processor_snapshot('Proc#R1').parameters['alpha'].value == 3


def test_group_attributes_copy() -> None:
    """Ensure group attribute getters return copies that do not mutate state."""

    controller = SteeringController()
    controller.add_processor_to_run_list('Core')
    controller.add_group('Chain', ['Core'])
    controller.set_group_attributes('Chain', {'stage': 'pre'})
    attrs = controller.get_group_attributes('Chain')
    attrs['stage'] = 'post'
    assert controller.get_group_attributes('Chain')['stage'] == 'pre'


def test_db_pragmas_copy() -> None:
    """Ensure DB pragmas getters expose copies of the stored mapping."""

    controller = SteeringController()
    controller.set_db_pragmas({'journal_mode': 'wal'})
    pragmas = controller.get_db_pragmas()
    pragmas['journal_mode'] = 'off'
    assert controller.get_db_pragmas()['journal_mode'] == 'wal'


def test_build_connection_test_task_uses_controller_settings(monkeypatch: pytest.MonkeyPatch) -> None:
    """Ensure the connection task leverages the controller configuration and attributes."""

    controller = SteeringController()
    controller.set_db_url('sqlite:///tmp/state.db')
    controller.set_db_pragmas({'journal_mode': 'wal'})
    controller.set_db_attribute('user', 'tester')
    controller.set_db_attribute('read_default_file', '/tmp/.my.cnf')

    connect_calls: list[tuple[str, dict[str, Any]]] = []

    class DummyDatabase:
        def connect(self) -> None:
            pass

        def close(self) -> None:
            pass

    def fake_connect(url: str, **kwargs: Any) -> DummyDatabase:
        connect_calls.append((url, kwargs))
        return DummyDatabase()

    monkeypatch.setattr('playhouse.db_url.connect', fake_connect)

    task = controller.build_connection_test_task()
    assert task() is True

    assert len(connect_calls) == 1
    url, params = connect_calls[0]
    assert url == 'sqlite:///tmp/state.db'
    assert 'user' not in params
    assert 'read_default_file' not in params
    assert params['pragmas'] == {'journal_mode': 'wal'}


def test_build_connection_test_task_applies_overrides(monkeypatch: pytest.MonkeyPatch) -> None:
    """Ensure custom configurations override the controller values during testing."""

    controller = SteeringController()
    controller.set_db_url('sqlite:///:memory:')

    connect_calls: list[tuple[str, dict[str, Any]]] = []

    def fake_connect(url: str, **kwargs: Any) -> Any:
        connect_calls.append((url, kwargs))

        class DummyDatabase:
            def connect(self) -> None:
                pass

            def close(self) -> None:
                pass

        return DummyDatabase()

    monkeypatch.setattr('playhouse.db_url.connect', fake_connect)
    override = {
        'url': 'postgresql://user@host/db',
        'user': 'override',
        'read_default_file': '/etc/.pgpass',
        'pragmas': {'schema': 'public'},
    }
    task = controller.build_connection_test_task(override)
    assert task() is True

    assert len(connect_calls) == 1
    url, params = connect_calls[0]
    assert url == override['url']
    assert params['user'] == 'override'
    assert 'read_default_file' not in params
    assert 'pragmas' not in params


def test_build_database_connection_configuration_requires_url() -> None:
    """Ensure the helper rejects configurations without any URL."""

    with pytest.raises(ValueError):
        build_database_connection_configuration({})


def test_build_database_connection_configuration_mysql_allows_missing_read_defaults() -> None:
    """Ensure the mysql branch emits URL/user even when read_default_file is absent."""

    normalized = build_database_connection_configuration({'url': 'mysql://localhost/db', 'user': 'tester'})
    assert normalized['URL'] == 'mysql://localhost/db'
    assert normalized['user'] == 'tester'
    assert 'read_default_file' not in normalized


def test_build_database_connection_configuration_postgresql_works_without_user() -> None:
    """Ensure the postgresql branch is exercised even when user info is missing."""

    normalized = build_database_connection_configuration({'url': 'postgresql://localhost/db'})
    assert normalized['URL'] == 'postgresql://localhost/db'
    assert 'user' not in normalized


def test_build_database_connection_configuration_mysql_includes_read_defaults() -> None:
    """Ensure the mysql branch copies read_default_file when provided."""

    normalized = build_database_connection_configuration(
        {
            'url': 'mysql://localhost/db',
            'user': 'tester',
            'read_default_file': '/tmp/.my.cnf',
        }
    )
    assert normalized['URL'] == 'mysql://localhost/db'
    assert normalized['user'] == 'tester'
    assert normalized['read_default_file'] == '/tmp/.my.cnf'


def test_build_connection_test_task_uses_uppercase_url_override(monkeypatch: pytest.MonkeyPatch) -> None:
    """Ensure the uppercase URL key in overrides is respected for final_url resolution."""

    controller = SteeringController()
    controller.set_db_url(None)

    connect_calls: list[tuple[str, dict[str, Any]]] = []

    class DummyDatabase:
        def connect(self) -> None:
            pass

        def close(self) -> None:
            pass

    def fake_connect(url: str, **kwargs: Any) -> DummyDatabase:
        connect_calls.append((url, kwargs))
        return DummyDatabase()

    monkeypatch.setattr('playhouse.db_url.connect', fake_connect)
    task = controller.build_connection_test_task({'URL': 'sqlite:///tmp/case.db', 'pragmas': {'journal_mode': 'wal'}})
    assert task() is True

    assert len(connect_calls) == 1
    url, params = connect_calls[0]
    assert url == 'sqlite:///tmp/case.db'
    assert 'URL' not in params
    assert params['pragmas'] == {'journal_mode': 'wal'}


def test_single_step_undo_redo_cycle() -> None:
    """Ensure a mutation can be undone once and then redone."""

    controller = SteeringController()
    controller.add_processor_to_run_list('Core')
    assert controller.can_undo()
    controller.undo()
    assert controller.get_processors_to_run() == []
    assert controller.can_redo()
    controller.redo()
    assert controller.get_processors_to_run() == ['Core']
    assert not controller.can_redo()
    assert controller.can_undo()


def test_multi_step_undo_reverts_all_changes() -> None:
    """Ensure multiple undo calls rewind every recorded change."""

    controller = SteeringController()
    controller.add_processor_to_run_list('First')
    controller.add_processor_to_run_list('Second')
    controller.undo()
    assert controller.get_processors_to_run() == ['First']
    controller.undo()
    assert controller.get_processors_to_run() == []
    assert not controller.can_undo()


def test_multi_step_redo_reapplies_history() -> None:
    """Ensure redo sequentially re-applies undone states."""

    controller = SteeringController()
    controller.add_processor_to_run_list('Alpha')
    controller.add_processor_to_run_list('Beta')
    controller.undo()
    controller.undo()
    controller.redo()
    assert controller.get_processors_to_run() == ['Alpha']
    assert controller.can_redo()
    controller.redo()
    assert controller.get_processors_to_run() == ['Alpha', 'Beta']
    assert not controller.can_redo()


def test_reset_clears_history_and_disables_undo() -> None:
    """Ensure reset returns to the initial state and clears undo/redo."""

    controller = SteeringController()
    controller.add_processor_to_run_list('Core')
    controller.reset()
    assert controller.get_processors_to_run() == []
    assert not controller.can_undo()


def test_reset_sets_default_values() -> None:
    """Resetting the controller should restore the builder defaults."""

    controller = SteeringController()
    controller.reset()
    assert controller.get_analysis_name() == 'analysis-name'
    assert controller.get_analysis_description() == 'analysis-description'
    assert controller.get_new_only() is True
    assert controller.get_create_standard_tables() is True
    assert controller.get_db_url() == 'sqlite:///:memory:'
    assert controller.get_db_pragmas() == default_conf['sqlite']['pragmas']
    assert controller.get_ui_interface() == 'rich'


def test_setter_defaults_do_not_mutate_if_value_matches() -> None:
    """Setting globals to the current value should avoid recording dirty state."""

    controller = SteeringController()
    controller.reset()

    controller._mark_clean()
    controller.set_new_only(True)
    assert controller.dirty is False
    controller.set_create_standard_tables(True)
    assert controller.dirty is False


def test_setters_reject_same_value_after_change() -> None:
    """Mutating a flag actually toggles dirty when the value differs."""

    controller = SteeringController()
    controller.reset()

    controller._mark_clean()
    controller.set_new_only(False)
    assert controller.get_new_only() is False
    assert controller.dirty is True
    controller._mark_clean()
    controller.set_create_standard_tables(False)
    assert controller.get_create_standard_tables() is False
    assert controller.dirty is True


def test_db_attribute_helpers_set_and_clear() -> None:
    controller = SteeringController()
    controller.set_db_attribute('read_default_file', '/tmp/.my.cnf')
    assert controller.get_db_attributes()['read_default_file'] == '/tmp/.my.cnf'
    controller.set_db_attribute('read_default_file', None)
    assert 'read_default_file' not in controller.get_db_attributes()


def test_db_configuration_toggle_updates_flag() -> None:
    controller = SteeringController()
    assert controller.is_db_configuration_enabled() is True
    controller.disable_db_configuration()
    assert controller.is_db_configuration_enabled() is False
    controller.enable_db_configuration()
    assert controller.is_db_configuration_enabled() is True


def test_build_pipeline_detects_cycles() -> None:
    controller = SteeringController()
    controller.add_processor_to_run_list('Core')
    controller.add_group('Loop', ['Loop'])
    controller.add_processor_to_run_list('Loop')

    pipeline = controller.build_pipeline()
    loop_node = next(item for item in pipeline.items if item.name() == 'Loop')
    assert loop_node.children
    assert loop_node.children[0].children == []


def test_max_history_limit_trims_old_snapshots() -> None:
    """Ensure a bounded history discards older snapshots when the limit is exceeded."""

    controller = SteeringController(max_history=1)
    controller.add_processor_to_run_list('Core')
    controller.set_processor_parameter('Core', 'threshold', 1)
    controller.set_processor_parameter('Core', 'threshold', 2)

    controller.undo()
    assert controller.get_processor_snapshot('Core').parameters['threshold'].value == 1
    with pytest.raises(SteeringControllerError):
        controller.undo()


def test_save_skips_validation_when_requested(tmp_path: Path) -> None:
    """Ensure skip validation still writes when the current file is tracked."""

    controller = SteeringController()
    controller.add_processor_to_run_list('Core')
    target = tmp_path / 'steering.toml'
    controller.save_as(target)
    controller.save(skip_validation=True)
    assert target.exists()
    assert 'processors_to_run' in target.read_text(encoding='utf-8')


def test_save_requires_tracked_file() -> None:
    """Ensure save raises before Save As when no file is associated."""

    controller = SteeringController()
    with pytest.raises(ValueError):
        controller.save()


def test_save_writes_to_tracked_file(tmp_path: Path) -> None:
    """Ensure save writes to the previously registered path and clears dirty state."""

    controller = SteeringController()
    controller.add_processor_to_run_list('Core')
    target = tmp_path / 'steering.toml'
    controller.save_as(target)
    controller.set_analysis_name('run')
    controller.save()

    content = tomlkit.loads(target.read_text(encoding='utf-8'))
    assert content['analysis_name'] == 'run'
    assert controller.current_file() == target
    assert not controller.dirty


def test_validation_level_preference_and_reset() -> None:
    """Ensure validation preference is tracked and reset returns to the default."""

    controller = SteeringController()
    assert controller.validation_level() == ValidationLevel.SEMANTIC
    controller.set_validation_level(None)
    assert controller.validation_level() is None
    controller.reset()
    assert controller.validation_level() == ValidationLevel.SEMANTIC


def test_ensure_valid_returns_when_configuration_is_valid() -> None:
    """Ensure ensure_valid silently succeeds when no validation issues exist."""

    controller = SteeringController()
    controller.add_processor_to_run_list('Core')
    controller.ensure_valid()


def test_redo_without_future_state_raises_error() -> None:
    """Ensure redo raises when no future snapshots are available."""

    controller = SteeringController()
    with pytest.raises(SteeringControllerError):
        controller.redo()


def test_set_filter_conditionals_allows_none() -> None:
    """Ensure clearing conditionals exercises the None branch."""

    controller = SteeringController()
    controller.add_processor_to_run_list('Core')
    controller.set_filter_conditionals('Core', 'ModelA', [{'field': 'value'}])
    controller.set_filter_conditionals('Core', 'ModelA', None)
    filters = controller.get_processor_snapshot('Core').filters['ModelA']
    assert '__conditional__' not in filters


def test_from_file_initializes_builder(tmp_path: Path) -> None:
    """Ensure from_file loads a steering file and mirrors its processors_to_run."""

    source = tmp_path / 'base.toml'
    creator = SteeringController()
    creator.add_processor_to_run_list('FromFile')
    creator.save_as(source)

    controller = SteeringController.from_file(source)
    assert controller.get_processors_to_run() == ['FromFile']


def test_from_file_without_db_configuration_disables_section(tmp_path: Path) -> None:
    minimal = tmp_path / 'minimal.toml'
    minimal.write_text('processors_to_run = []\n', encoding='utf-8')
    controller = SteeringController.from_file(minimal)
    assert controller.is_db_configuration_enabled() is False


def test_processors_helpers_update_and_remove_entries() -> None:
    """Ensure set/remove helpers touch the builder directly."""

    controller = SteeringController()
    controller.set_processors_to_run(['One', 'Two'])
    assert controller.get_processors_to_run() == ['One', 'Two']
    controller.remove_processor_from_run_list('One')
    assert controller.get_processors_to_run() == ['Two']


def test_analysis_database_and_ui_helpers_round_trip() -> None:
    """Ensure analysis, DB, and UI getters reflect the corresponding setters."""

    controller = SteeringController()
    controller.set_analysis_name('run')
    controller.set_analysis_description('desc')
    controller.set_new_only(True)
    controller.set_create_standard_tables(False)
    controller.set_db_url('sqlite:///state.db')
    controller.set_db_pragmas({'journal_mode': 'wal'})
    controller.set_ui_interface('console')

    assert controller.get_analysis_name() == 'run'
    assert controller.get_analysis_description() == 'desc'
    assert controller.get_new_only() is True
    assert controller.get_create_standard_tables() is False
    assert controller.get_db_url() == 'sqlite:///state.db'
    assert controller.get_db_pragmas()['journal_mode'] == 'wal'
    assert controller.get_ui_interface() == 'console'


def test_globals_dirty_signal_triggers_only_on_actual_change() -> None:
    """Ensure the dirty signal ignores redundant setters and resets fire clean transitions."""

    controller = SteeringController()
    events: list[bool] = []
    controller.dirty_state_signal.connect(lambda value: events.append(value))

    controller.set_analysis_name('run')
    controller.set_analysis_name('run')
    assert events == [True]

    controller.reset()
    assert events == [True, False]


def test_get_globals_returns_current_metadata() -> None:
    """Ensure the UI snapshot reflects every metadata field."""

    controller = SteeringController()
    controller.set_analysis_name('run')
    controller.set_analysis_description('desc')
    controller.set_new_only(True)
    controller.set_create_standard_tables(False)

    data = controller.get_globals()
    assert data['analysis_name'] == 'run'
    assert data['description'] == 'desc'
    assert data['new_only'] is True
    assert data['create_standard_tables'] is False


def test_processor_snapshot_and_replica_helpers() -> None:
    """Ensure processor helpers update the builder without leaking state."""

    controller = SteeringController()
    controller.add_processor_to_run_list('Base')
    controller.set_processor_parameter('Base', 'alpha', 1)
    controller.add_replica('Base', 'variant')
    controller.set_replica_inheritance('Base#variant', False)
    controller.set_processor_new_only('Base#variant', True)

    snap = controller.get_processor_snapshot('Base#variant')
    assert snap.inheritance is False
    assert snap.new_only is True


def test_filter_helpers_update_entries() -> None:
    """Ensure filter helpers cover configuration, fields, logic, and removal."""

    controller = SteeringController()
    controller.add_processor_to_run_list('Core')
    controller.set_filter_config('Core', 'ModelX', {'alpha': 1})
    controller.set_filter_field('Core', 'ModelX', 'beta', 2)
    controller.set_filter_logic('Core', 'alpha AND beta')
    controller.remove_filter('Core', 'ModelX')

    filters = controller.get_processor_snapshot('Core').filters
    assert 'ModelX' not in filters


def test_group_helpers_and_missing_group_errors() -> None:
    """Ensure group helpers create snapshots and raise when groups disappear."""

    controller = SteeringController()
    controller.add_processor_to_run_list('Core')
    controller.add_group('Chain', ['Core'], 'stage')
    controller.set_group_attributes('Chain', {'stage': 'pre'})
    snapshot = controller.get_group_snapshot('Chain')
    assert snapshot.attributes['stage'] == 'pre'
    controller.remove_group('Chain')
    with pytest.raises(SteeringControllerError):
        controller.get_group_snapshot('Chain')


def test_missing_processor_snapshot_raises() -> None:
    """Ensure helper propagation raises when a processor is unknown."""

    controller = SteeringController()
    with pytest.raises(SteeringControllerError):
        controller.get_processor_snapshot('Unknown')


def _call_unwrapped(controller: SteeringController, name: str, *args: Any, **kwargs: Any) -> Any:
    """Invoke a decorated helper's original implementation so coverage sees the body."""

    attribute = getattr(controller.__class__, name)
    target = getattr(attribute, '__wrapped__', attribute)
    return target(controller, *args, **kwargs)


def test_unwrapped_helpers_execute_each_statement() -> None:
    """Touch every decorated helper so the original statements appear in coverage."""

    controller = SteeringController()
    controller.add_processor_to_run_list('Core')
    _call_unwrapped(controller, 'set_processors_to_run', ['Core', 'Helper'])
    controller.get_processors_to_run()
    _call_unwrapped(controller, 'remove_processor_from_run_list', 'Helper')
    controller.add_processor_to_run_list('Core')
    _call_unwrapped(controller, 'set_analysis_name', 'run')
    assert controller.get_analysis_name() == 'run'
    _call_unwrapped(controller, 'set_analysis_description', 'desc')
    assert controller.get_analysis_description() == 'desc'
    _call_unwrapped(controller, 'set_new_only', True)
    assert controller.get_new_only() is True
    _call_unwrapped(controller, 'set_create_standard_tables', False)
    assert controller.get_create_standard_tables() is False
    _call_unwrapped(controller, 'set_db_url', 'sqlite:///state.db')
    assert controller.get_db_url() == 'sqlite:///state.db'
    _call_unwrapped(controller, 'set_db_pragmas', {'journal_mode': 'wal'})
    assert controller.get_db_pragmas()['journal_mode'] == 'wal'
    _call_unwrapped(controller, 'set_ui_interface', 'console')
    assert controller.get_ui_interface() == 'console'
    assert controller.list_processors()

    _call_unwrapped(controller, 'set_processor_parameter', 'Core', 'alpha', 1)
    _call_unwrapped(controller, 'add_replica', 'Core', 'variant')
    _call_unwrapped(controller, 'set_replica_inheritance', 'Core#variant', False)
    _call_unwrapped(controller, 'set_processor_new_only', 'Core#variant', True)
    _call_unwrapped(controller, 'set_filter_config', 'Core', 'ModelX', {'alpha': 1})
    _call_unwrapped(controller, 'set_filter_field', 'Core', 'ModelX', 'beta', 2)
    _call_unwrapped(controller, 'set_filter_logic', 'Core', 'alpha AND beta')
    _call_unwrapped(controller, 'remove_filter', 'Core', 'ModelX')

    _call_unwrapped(controller, 'add_group', 'Chain', ['Core'], 'stage')
    _call_unwrapped(controller, 'remove_group', 'Chain')
    with pytest.raises(SteeringControllerError):
        controller.get_group_snapshot('Chain')


def test_load_replaces_builder_state_and_records_history(tmp_path: Path) -> None:
    """Ensure load reads the file, updates the run list, and keeps the prior state undoable."""

    source = tmp_path / 'base.toml'
    builder = SteeringController()
    builder.add_processor_to_run_list('Loaded')
    builder.save_as(source)

    controller = SteeringController()
    controller.add_processor_to_run_list('BeforeLoad')
    controller.load(source)

    assert controller.get_processors_to_run() == ['Loaded']
    assert controller.current_file() == source
    controller.undo()
    assert controller.get_processors_to_run() == ['BeforeLoad']


def test_reset_after_load_clears_processors(tmp_path: Path) -> None:
    """Ensure reset after load returns to an empty builder."""

    source = tmp_path / 'base.toml'
    builder = SteeringController()
    builder.add_processor_to_run_list('Loaded')
    builder.save_as(source)

    controller = SteeringController()
    controller.load(source)
    controller.add_processor_to_run_list('Extra')
    controller.reset()

    assert controller.get_processors_to_run() == []
    assert controller.current_file() is None


class DummyPluginManager:
    """Simple stand-in plugin manager that returns a predictable bundle."""

    def __init__(self) -> None:
        self.requests: list[tuple[str, ...]] = []

    def load_plugins(self, plugin_types: Iterable[str]) -> LoadedPlugins:
        self.requests.append(tuple(plugin_types))
        bundle = LoadedPlugins()
        bundle.processor_list = ['CoreProcessor']
        bundle.db_model_modules = ['mafw.db.core']
        bundle.ui_list = ['rich']
        return bundle


def test_load_plugins_records_available_counts() -> None:
    """Ensure the controller caches plugin results and exposes their counts."""

    manager = DummyPluginManager()
    controller = SteeringController(plugin_manager=manager)

    assert controller.plugins is None
    assert controller.available_processor_count() is None
    bundle = controller.load_plugins()

    assert controller.plugins is bundle
    assert controller.available_processor_count() == 1
    assert controller.available_db_module_count() == 1
    assert controller.available_ui_count() == 1
    assert manager.requests == [('processors', 'db_modules', 'ui')]


def test_available_counts_remain_none_without_plugins() -> None:
    """Ensure the availability helpers stay empty until plugin loading occurs."""

    controller = SteeringController()
    assert controller.available_processor_count() is None
    assert controller.available_db_module_count() is None
    assert controller.available_ui_count() is None


def test_available_processor_names_sorted() -> None:
    """Ensure available processor names are sorted from plugins."""

    controller = SteeringController()
    plugins = LoadedPlugins()
    plugins.processor_dict = {'ZProc': MockProcessor, 'AProc': MockProcessor}
    controller.plugins = plugins

    assert controller.available_processor_names() == ['AProc', 'ZProc']


def test_add_processors_with_defaults_populates_schema_defaults() -> None:
    """Ensure adding processors populates default parameters from schema."""

    controller = SteeringController()
    plugins = LoadedPlugins()
    plugins.processor_dict = {'MockProcessor': MockProcessor}
    controller.plugins = plugins

    controller.add_processors_with_defaults(['MockProcessor'])

    config = controller._builder.get_processor_config('MockProcessor')
    assert config.parameters['alpha'].value == 1
    assert config.parameters['beta'].value == 'x'
    assert config.parameters['alpha'].source == ParameterSource.DEFAULT
    assert config.parameters['beta'].source == ParameterSource.DEFAULT


def test_add_processors_with_defaults_does_not_override_existing() -> None:
    """Ensure defaults do not override existing parameter values."""

    controller = SteeringController()
    plugins = LoadedPlugins()
    plugins.processor_dict = {'MockProcessor': MockProcessor}
    controller.plugins = plugins

    controller.set_processor_parameter('MockProcessor', 'alpha', 42)
    controller.add_processors_with_defaults(['MockProcessor'])

    config = controller._builder.get_processor_config('MockProcessor')
    assert config.parameters['alpha'].value == 42
    assert config.parameters['beta'].value == 'x'


def test_remove_pipeline_entries_removes_from_run_list() -> None:
    """Ensure remove_pipeline_entries drops names from processors_to_run."""

    controller = SteeringController()
    controller.add_processor_to_run_list('ProcA')
    controller.add_processor_to_run_list('ProcB')

    controller.remove_pipeline_entries(['ProcA'])

    assert controller.get_processors_to_run() == ['ProcB']


def test_add_processors_with_defaults_creates_replicas() -> None:
    """Ensure repeated additions generate an automatic replica."""

    controller = SteeringController()
    plugins = LoadedPlugins()
    plugins.processor_dict = {'MockProcessor': MockProcessor}
    controller.plugins = plugins

    controller.add_processors_with_defaults(['MockProcessor'])
    controller.add_processors_with_defaults(['MockProcessor'])

    run_list = controller.get_processors_to_run()
    assert run_list[0] == 'MockProcessor'
    assert run_list[1].startswith('MockProcessor#')


def test_move_pipeline_entry_errors() -> None:
    controller = SteeringController()
    controller.add_processor_to_run_list('Proc1')
    controller.add_group('GroupA', [])
    controller.add_processor_to_run_list('GroupA')

    # Entry not in source list
    with pytest.raises(SteeringControllerError, match='not found in the source list'):
        controller.move_pipeline_entry('NoSuchProc', False, None, None, 0)

    # Group does not exist
    with pytest.raises(SteeringControllerError, match="Group 'NoSuchGroup' does not exist"):
        controller.move_pipeline_entry('Proc1', False, None, 'NoSuchGroup', 0)

    # Moving group into itself (direct cycle)
    with pytest.raises(SteeringControllerError, match='create a cycle'):
        controller.move_pipeline_entry('GroupA', True, None, 'GroupA', 0)

    # Position out of range (same list)
    with pytest.raises(SteeringControllerError, match='out of range'):
        controller.move_pipeline_entry('Proc1', False, None, None, 10)

    # Position out of range (different list)
    with pytest.raises(SteeringControllerError, match='out of range'):
        controller.move_pipeline_entry('Proc1', False, None, 'GroupA', 10)


def test_would_create_group_cycle_complex() -> None:
    controller = SteeringController()
    controller.add_group('A', ['B'])
    controller.add_group('B', ['C'])
    controller.add_group('C', [])

    # A -> B -> C. Move A into C?
    # A is ancestor of C, so moving A into C creates A -> B -> C -> A. CYCLE!
    assert controller._would_create_group_cycle('A', 'C') is True

    # Move C into A?
    # C is descendant of A. Moving C into A is A -> (B -> C, C). NO CYCLE!
    assert controller._would_create_group_cycle('C', 'A') is False

    # Non-existent group in cycle check
    assert controller._would_create_group_cycle('NoSuchGroup', 'A') is False


def test_build_connection_test_task_errors() -> None:
    controller = SteeringController()
    controller.set_db_url(None)
    # No URL raises SteeringControllerError (wrapping ValueError)
    with pytest.raises(SteeringControllerError, match='Database URL is required'):
        controller.build_connection_test_task()


def test_assemble_connection_source_final_url_priority() -> None:
    controller = SteeringController()
    controller.set_db_url('sqlite:///controller.db')

    # Override with 'url'
    source = controller._assemble_connection_source({'url': 'sqlite:///override.db'})
    assert source['URL'] == 'sqlite:///override.db'

    # Override with 'URL'
    source = controller._assemble_connection_source({'URL': 'sqlite:///UPPER.db'})
    assert source['URL'] == 'sqlite:///UPPER.db'


def test_build_pipeline_replica_fallback() -> None:
    controller = SteeringController()
    # Add a replica that doesn't have its own section yet
    controller.add_processor_to_run_list('BaseProcessor', 'replica1')

    pipeline = controller.build_pipeline()
    assert any(item.name() == 'BaseProcessor#replica1' for item in pipeline.items)


def test_build_pipeline_replica_inherits_base_parameters() -> None:
    """Ensure replica configs inherit base parameters when no overrides are set."""

    controller = SteeringController()
    controller.add_processor_to_run_list('BaseProcessor', 'replica1')
    controller.set_processor_parameter('BaseProcessor', 'threshold', 1.5)

    pipeline = controller.build_pipeline()
    replica_item = next(item for item in pipeline.items if item.name() == 'BaseProcessor#replica1')

    assert replica_item.config.parameters['threshold'].value == 1.5
    assert replica_item.config.parameters['threshold'].source == 'inherited'


def test_build_pipeline_replica_overrides_base_parameters() -> None:
    """Ensure replica overrides use CONFIG source while base-only remain INHERITED."""

    controller = SteeringController()
    controller.add_processor_to_run_list('BaseProcessor', 'replica1')
    controller.set_processor_parameter('BaseProcessor', 'alpha', 1)
    controller.set_processor_parameter('BaseProcessor', 'beta', 2)
    controller.set_processor_parameter('BaseProcessor#replica1', 'alpha', 42)

    pipeline = controller.build_pipeline()
    replica_item = next(item for item in pipeline.items if item.name() == 'BaseProcessor#replica1')

    assert replica_item.config.parameters['alpha'].value == 42
    assert replica_item.config.parameters['beta'].value == 2
    assert replica_item.config.parameters['alpha'].source == 'config'
    assert replica_item.config.parameters['beta'].source == 'inherited'


def test_build_pipeline_replica_disables_inheritance() -> None:
    """Ensure __inheritance__=False uses only replica parameters."""

    controller = SteeringController()
    controller.add_processor_to_run_list('BaseProcessor', 'replica1')
    controller.set_processor_parameter('BaseProcessor', 'alpha', 1)
    controller.set_processor_parameter('BaseProcessor#replica1', 'beta', 2)
    controller.set_replica_inheritance('BaseProcessor#replica1', False)

    pipeline = controller.build_pipeline()
    replica_item = next(item for item in pipeline.items if item.name() == 'BaseProcessor#replica1')

    assert list(replica_item.config.parameters.keys()) == ['beta']
    assert replica_item.config.parameters['beta'].value == 2
    assert replica_item.config.parameters['beta'].source == 'config'


def test_set_group_attributes_and_snapshots_errors() -> None:
    controller = SteeringController()
    with pytest.raises(SteeringControllerError, match='is not defined'):
        controller.set_group_attributes('NoSuchGroup', {})

    with pytest.raises(SteeringControllerError, match='is not defined'):
        controller.get_group_attributes('NoSuchGroup')


def test_set_analysis_description_same_value() -> None:
    controller = SteeringController()
    controller.set_analysis_description('test')
    controller.set_analysis_description('test')  # Should return early
    assert controller.get_analysis_description() == 'test'


def test_list_groups_coverage() -> None:
    controller = SteeringController()
    controller.add_group('G1', [])
    assert 'G1' in controller.list_groups()


def test_move_pipeline_entry_group_not_exists() -> None:
    controller = SteeringController()
    controller.add_group('GroupA', [])
    controller.add_processor_to_run_list('P1')
    # P1 is in source list, but entry_is_group=True and P1 is not a group
    with pytest.raises(SteeringControllerError, match="Group 'P1' does not exist"):
        controller.move_pipeline_entry('P1', True, None, 'GroupA', 0)


def test_would_create_group_cycle_visited_branch() -> None:
    controller = SteeringController()
    # Trigger the 'if current in visited: continue' branch
    # Requires multiple paths to a node that is NOT the target
    controller.add_group('A', ['B', 'C'])
    controller.add_group('B', ['E'])
    controller.add_group('C', ['E'])
    controller.add_group('E', [])

    # Searching for D (which is not there) will visit E twice
    assert controller._would_create_group_cycle('A', 'D') is False


def test_schema_filter_models_populated() -> None:
    """Ensure schema_filter_models is populated from the processor schema."""

    class MockManager:
        def load_plugins(self, plugin_types: Iterable[str]) -> LoadedPlugins:
            bundle = LoadedPlugins()
            bundle.processor_dict = {'MockProcessorWithFilter': MockProcessorWithFilter}
            bundle.processor_list = ['MockProcessorWithFilter']
            bundle.db_model_modules = []
            bundle.ui_list = []
            return bundle

    controller = SteeringController(plugin_manager=MockManager())
    controller.load_plugins()
    controller.add_processor_to_run_list('MockProcessorWithFilter')

    # Trigger schema alignment
    pipeline = controller.build_pipeline()

    # Check the config in the pipeline item
    item = pipeline.items[0]
    assert item.config.name == 'MockProcessorWithFilter'
    assert item.config.schema_filter_models == ['ModelA', 'ModelB']
