"""Tests for ScanWorkflow things."""

import itertools
import logging

import pytest
from pydantic import BaseModel

import labthings_fastapi as lt
from labthings_fastapi.testing import create_thing_without_server

from openflexure_microscope_server.scan_planners import SmartSpiral
from openflexure_microscope_server.stitching import StitchingSettings
from openflexure_microscope_server.things.autofocus import SmartStackParams
from openflexure_microscope_server.things.camera_stage_mapping import csm_img_to_stage
from openflexure_microscope_server.things.scan_workflows import (
    HistoScanSettingsModel,
    HistoScanWorkflow,
    ScanWorkflow,
)
from openflexure_microscope_server.ui import (
    Accordion,
    ActionButton,
    HeaderBlock,
    PropertyControl,
    TextBlock,
    UIElementList,
)


def test_partial_base_classes():
    """Create a partial class and check it raises the correct errors."""

    class MinimalSettings(BaseModel):
        """Some minimal settings for a workflow that doesn't work."""

        foo: str = "bar"

    class BadWorkflow(ScanWorkflow[MinimalSettings]):
        """Can initialise. Other properties and methods error."""

        display_name: str = lt.property(default="Bad Workflow", readonly=True)

    bad_workflow = create_thing_without_server(BadWorkflow)

    settings = MinimalSettings()
    with pytest.raises(NotImplementedError):
        bad_workflow.check_before_start(settings)

    with pytest.raises(NotImplementedError):
        bad_workflow.ready

    with pytest.raises(NotImplementedError):
        bad_workflow.all_settings("Fake Dir")

    with pytest.raises(NotImplementedError):
        bad_workflow.pre_scan_routine(settings)

    with pytest.raises(NotImplementedError):
        bad_workflow.new_scan_planner(settings, position={"x": 0, "y": 0, "z": 0})

    with pytest.raises(NotImplementedError):
        bad_workflow.acquisition_routine(settings, xyz_pos=[0, 0, 0])

    with pytest.raises(NotImplementedError):
        bad_workflow.settings_ui()


@pytest.fixture
def histo_workflow():
    """Return a HistoScanWorkflow thing with slots mocked."""
    return create_thing_without_server(HistoScanWorkflow, mock_all_slots=True)


# Use itertools to iterate over every true/false permutation
@pytest.mark.parametrize(
    ("csm_calibrated", "skip_background", "bg_ready"),
    itertools.product([True, False], repeat=3),
)
def test_histo_workflow_ready(
    histo_workflow, csm_calibrated, skip_background, bg_ready, caplog
):
    """Check ready property and the pre-run check work for each permutation."""
    histo_workflow._csm.calibration_required = not csm_calibrated
    histo_workflow.skip_background = skip_background
    histo_workflow._background_detector.ready = bg_ready

    if not csm_calibrated:
        # For all permutations not ready if CSM isn't calibrated.
        assert not histo_workflow.ready
        with pytest.raises(
            RuntimeError, match="Camera Stage Mapping is not calibrated."
        ):
            histo_workflow.check_before_start("scan_name")
    elif not skip_background:
        # CSM is ready and background is not skipped: Always ready, but warns on check
        assert histo_workflow.ready
        with caplog.at_level(logging.WARNING):
            histo_workflow.check_before_start("scan_name")
        assert len(caplog.records) == 1
    elif not bg_ready:
        # Skipping background, but detector not ready. Raises error
        assert not histo_workflow.ready
        with pytest.raises(RuntimeError, match="Background is not set"):
            histo_workflow.check_before_start("scan_name")
    else:
        # Finally CSM calibrated, skipping background, and detector ready: This is
        # ready and should not warn.
        assert histo_workflow.ready
        with caplog.at_level(logging.WARNING):
            histo_workflow.check_before_start("scan_name")
        assert len(caplog.records) == 0


def test_histo_workflow_settings_generation(histo_workflow, mocker):
    """Check the settings models generate as expected."""
    mocker.patch.object(
        histo_workflow, "_calc_displacement_from_overlap", return_value=(123, 456)
    )
    workflow_settings, stitching_settings = histo_workflow.all_settings("/this/img_dir")
    ## Check type
    assert isinstance(workflow_settings, HistoScanSettingsModel)
    assert isinstance(stitching_settings, StitchingSettings)
    assert isinstance(workflow_settings.smart_stack_params, SmartStackParams)

    # Check stitching defaults
    assert stitching_settings.correlation_resize == 0.5
    assert stitching_settings.overlap == 0.45
    # Check some workflow defaults
    assert workflow_settings.overlap == 0.45
    assert workflow_settings.max_dist == 45000
    assert workflow_settings.skip_background
    assert workflow_settings.smart_stack_params.stack_dz == 50
    assert workflow_settings.smart_stack_params.images_to_save == 1
    assert workflow_settings.smart_stack_params.min_images_to_test == 9
    # Check values from calculating overlap are as expected (from above mock)
    assert workflow_settings.dx == 123
    assert workflow_settings.dy == 456
    # And that the input image dir is passed to stack the stack parameter for saving
    assert workflow_settings.capture_params.images_dir == "/this/img_dir"


def test_histo_workflow_settings_generation_equal_overlap(histo_workflow, mocker):
    """Check that setting x and y equal behaves as expected."""
    mocker.patch.object(
        histo_workflow, "_calc_displacement_from_overlap", return_value=(123, 456)
    )

    # Different when False
    histo_workflow.equal_distances = False
    workflow_settings, _stitching_settings = histo_workflow.all_settings(
        "/this/img_dir"
    )

    assert workflow_settings.dx == 123
    assert workflow_settings.dy == 456

    # Same when set True
    histo_workflow.equal_distances = True
    workflow_settings, _stitching_settings = histo_workflow.all_settings(
        "/this/img_dir"
    )

    assert workflow_settings.dx == 123
    assert workflow_settings.dy == 123


# A CSM that is "normal" changing from camera maxtrix coordinates (y,x) to normal
# (x, y) coordinates
CSM_NORMAL = [
    [0.02, 0.25],
    [0.25, 0.01],
]
# A CSM that is "normal" changing from camera maxtrix coordinates (y,x) to normal
# (x, y) coordinates, but the output y sign is flipped.
CSM_FLIP_Y = [
    [0.02, -0.25],
    [0.25, 0.01],
]
# A CSM that is for a rotated compared to normal camera changing from camera maxtrix
#  thus dx (the x motor) controls y and the y motor controls x
CSM_ROTATED = [
    [0.25, 0.02],
    [0.01, 0.25],
]


@pytest.mark.parametrize(
    ("csm_matrix", "overlap", "expected_steps"),
    [
        (CSM_NORMAL, 0.5, (100, 75)),
        (CSM_NORMAL, 0.25, (150, 112)),
        (CSM_FLIP_Y, 0.5, (100, -75)),
        (CSM_FLIP_Y, 0.25, (150, -112)),
        (CSM_ROTATED, 0.5, (75, 100)),
        (CSM_ROTATED, 0.25, (112, 150)),
    ],
)
def test_histo_calculate_overlap(csm_matrix, overlap, expected_steps, histo_workflow):
    """Check the dx and dy values are as expected for given overlap and matrix.

    Note that the matrices on the dominant axes all have 0.25 magnitude. And the
    mock camera is set to be 800px in x and 600px in y. For 0 overlap the movement
    should be dx of 200 (due to the 0.25 factor).
    """

    def apply_csm(x: float, y: float, **_kwargs: float) -> dict[str, int]:
        """Convert image coordinates to stage coordinates."""
        return csm_img_to_stage(csm_matrix, x=x, y=y)

    histo_workflow._csm.convert_image_to_stage_coordinates.side_effect = apply_csm

    # First check this error if CSM isn't calibrated:
    histo_workflow._csm.calibration_required = True
    histo_workflow._csm.image_resolution = None
    with pytest.raises(RuntimeError, match="CSM not set"):
        histo_workflow._calc_displacement_from_overlap(0.25)

    histo_workflow._csm.calibration_required = False
    # the img resolution is in matrix coords, so (y, x) not (x, y)
    histo_workflow._csm.image_resolution = (600, 800)
    dx, dy = histo_workflow._calc_displacement_from_overlap(overlap)
    assert (dx, dy) == expected_steps


def test_histo_pre_scan_routine(histo_workflow, mocker):
    """Check the pre-scan routine does autofocusses."""
    # Rather than create a whole Setting class, just create a mock with the value
    # we need set
    mock_settings = mocker.Mock()
    mock_settings.autofocus_params.dz = 1234
    # Run the function
    histo_workflow.pre_scan_routine(mock_settings)
    # Check the autofocus was run using the mocked slot.
    assert histo_workflow._autofocus.looping_autofocus.call_count == 1
    call_kwargs = histo_workflow._autofocus.looping_autofocus.call_args.kwargs
    assert call_kwargs["dz"] == 1234
    assert call_kwargs["start"] == "centre"


def test_histo_new_scan_planner(histo_workflow, mocker):
    """Check the pre-scan routine does autofocusses."""
    # Rather than create a whole Setting class, just create a mock with the 3 values
    # we need set
    mock_settings = mocker.Mock()
    mock_settings.dx = 666
    mock_settings.dy = 999
    mock_settings.max_dist = 54321

    planner = histo_workflow.new_scan_planner(
        mock_settings, position={"x": 1, "y": 2, "z": 3}
    )
    assert isinstance(planner, SmartSpiral)
    # Check the settings were read
    assert planner._dx == 666
    assert planner._dy == 999
    assert planner._max_dist == 54321


def test_histo_acquisition_with_background_detected(histo_workflow, mocker, caplog):
    """If background is detected it should return without a stack."""
    # Mocking for settings as above
    mock_settings = mocker.Mock()
    mock_settings.skip_background = True

    histo_workflow._background_detector.image_is_sample.return_value = (
        False,
        "totally empty",
    )

    with caplog.at_level(logging.INFO):
        imaged, focus_height = histo_workflow.acquisition_routine(
            mock_settings, xyz_pos=(1, 2, 3)
        )

    # Check returns
    assert imaged is False
    assert focus_height is None  # None meaning not focussed
    # Check there is a log explaining what happened
    assert len(caplog.records) == 1
    assert caplog.records[0].message == "Skipping (1, 2, 3) as it is totally empty."
    assert caplog.records[0].levelname == "INFO"

    # And that smart stack never ran
    assert histo_workflow._autofocus.run_smart_stack.call_count == 0


def test_histo_acquisition_not_skipping_background(histo_workflow, mocker, caplog):
    """Check when not skipping background an image is always saved."""
    # Mocking for settings as above
    mock_settings = mocker.Mock()
    mock_settings.skip_background = False
    mock_settings.smart_stack_params = SmartStackParams(
        stack_dz=5,
        images_to_save=3,
        min_images_to_test=3,
        save_on_failure=True,
        check_turning_points=True,
    )

    # Set up background detector to say it is empty, this shouldn't stop stacking.
    histo_workflow._background_detector.image_is_sample.return_value = (
        False,
        "totally empty",
    )

    with caplog.at_level(logging.INFO):
        # First set smart stack to report failure
        histo_workflow._autofocus.run_smart_stack.return_value = (False, 123)
        imaged, focus_height = histo_workflow.acquisition_routine(
            mock_settings, xyz_pos=(1, 2, 3)
        )
        # Check return
        assert imaged is True  # Always images if not skipping background
        assert focus_height is None  # None meaning didn't focus

        # And again reporting a successful stack
        histo_workflow._autofocus.run_smart_stack.return_value = (True, 123)
        imaged, focus_height = histo_workflow.acquisition_routine(
            mock_settings, xyz_pos=(1, 2, 3)
        )
        # Check return
        assert imaged is True  # Always images if not skipping background
        assert focus_height == 123

    # Should never warn
    assert len(caplog.records) == 0

    # And that smart stack never ran
    assert histo_workflow._autofocus.run_smart_stack.call_count == 2


def test_histo_acquisition_on_sample(histo_workflow, mocker, caplog):
    """Check when skipping background, but background not detected."""
    # Mocking for settings as above
    mock_settings = mocker.Mock()
    mock_settings.skip_background = True
    mock_settings.smart_stack_params = SmartStackParams(
        stack_dz=5,
        images_to_save=3,
        min_images_to_test=3,
        save_on_failure=False,
        check_turning_points=True,
    )

    # Set up background detector to say there is sample.
    histo_workflow._background_detector.image_is_sample.return_value = (True, None)

    with caplog.at_level(logging.INFO):
        # First set smart stack to report failure
        histo_workflow._autofocus.run_smart_stack.return_value = (False, 123)
        imaged, focus_height = histo_workflow.acquisition_routine(
            mock_settings, xyz_pos=(1, 2, 3)
        )
        # Check return:
        # Don't save failed stack when skipping background, assume it is actually
        # background
        assert imaged is False
        assert focus_height is None  # None meaning didn't focus

        # And again reporting a successful stack
        histo_workflow._autofocus.run_smart_stack.return_value = (True, 123)
        imaged, focus_height = histo_workflow.acquisition_routine(
            mock_settings, xyz_pos=(1, 2, 3)
        )
        # Check return
        assert imaged is True  # Always images if not skipping background
        assert focus_height == 123

    # Should never warn
    assert len(caplog.records) == 1
    expected_message = "Stack failed at (1, 2, 3). Treating as background."
    assert caplog.records[0].message == expected_message
    assert caplog.records[0].levelname == "INFO"

    # And that smart stack never ran
    assert histo_workflow._autofocus.run_smart_stack.call_count == 2


def test_histo_workflow_settings_ui(histo_workflow, mocker):
    """Check that the workflow specifies the expected controls."""
    mock_prop_control = mocker.Mock(spec=PropertyControl)
    histo_workflow._background_detector.settings_ui.return_value = UIElementList(
        [mock_prop_control]
    )

    ui = histo_workflow.settings_ui().root

    # The UI is in 4 blocks
    assert len(ui) == 4
    # The top is a header
    assert isinstance(ui[0], HeaderBlock)
    assert ui[0].text == "Histo Scan"
    # The then some text (note the & is escaped)
    assert isinstance(ui[1], TextBlock)
    fragment = "This scan workflow is optimised for scanning H&amp;E stained biopsies."
    assert fragment in ui[1].text
    # Followed by a background detect accordion
    assert isinstance(ui[2], Accordion)
    assert ui[2].title == "Background Detect"
    # And a Scan Settings accordion
    assert isinstance(ui[3], Accordion)
    assert ui[3].title == "Scan Settings"

    # Background UI is ...
    background_ui = ui[2].children.root
    # what the detector specifies as it settings ...
    assert background_ui[0] is mock_prop_control
    # plus 2 action buttons
    assert len(background_ui) == 3
    assert isinstance(background_ui[1], ActionButton)
    assert background_ui[1].action == "set_background"
    assert isinstance(background_ui[2], ActionButton)
    assert background_ui[2].action == "check_background"

    # Finally check the contents of the scan settings accordion
    scan_settings = ui[3].children.root
    assert len(scan_settings) == 8
    for element in scan_settings:
        assert isinstance(element, PropertyControl)

    names = [el.property_name for el in scan_settings]
    expected_names = [
        "overlap",
        "stack_images_to_save",
        "stack_min_images_to_test",
        "stack_dz",
        "autofocus_dz",
        "max_range",
        "skip_background",
        "equal_distances",
    ]
    assert names == expected_names


@pytest.mark.parametrize(
    ("save_res", "expected_resize"),
    [
        ((1400, 1400), 1 / 2),
        ((1640, 1232), 1 / 2),
        ((760, 750), 1 / 1),
        ((1499, 1000), 1 / 2),
        ((2250, 1800), 1 / 3),
        ((700, 700), 1 / 1),
    ],
)
def test_correlation_resize(histo_workflow, save_res, expected_resize):
    """Test that scan_workflows chooses a suitable correlation_resize factor.

    correlation_resize is always a unit fraction (1/N, N integer) so that the
    downsampled image has an area roughly equal to TARGET_STITCHING_DIMENSION**2.
    This ensures stitching correlations are fast, robust, and avoid artefacts.

    The value is found by taking the image area (width * height), dividing by the
    target area, taking the square root, and rounding to the nearest integer N. Then
    correlation_resize = 1 / N.
    """
    histo_workflow.save_resolution = save_res
    histo_workflow.overlap = 0.1
    settings = histo_workflow._get_stitching_settings_model()

    assert isinstance(settings, StitchingSettings)
    assert settings.correlation_resize == expected_resize
    assert 0 < settings.correlation_resize <= 1
