"""Test the functionality specific to the simulated camera."""

import logging
import time

import numpy as np
import pytest
from hypothesis import given
from hypothesis import strategies as st

import labthings_fastapi as lt

from openflexure_microscope_server.things.background_detect import ChannelDeviationLUV
from openflexure_microscope_server.things.camera import simulation
from openflexure_microscope_server.things.camera.simulation import SimulatedCamera
from openflexure_microscope_server.things.stage.dummy import DummyStage

from ..shared_utils.lt_test_utils import LabThingsTestEnv


@pytest.fixture
def test_env() -> LabThingsTestEnv:
    """Yield a test environment with the Simulated Camera and Dummy Stage."""
    thing_conf = {
        "camera": SimulatedCamera,
        "stage": DummyStage,
        "bg_channel_deviations_luv": ChannelDeviationLUV,
    }
    with LabThingsTestEnv(things=thing_conf) as env:
        yield env


@pytest.fixture
def camera(test_env) -> lt.Thing:
    """Return the SimulatedCamera Thing set up in the test environment."""
    return test_env.get_thing_by_type(SimulatedCamera)


@pytest.fixture
def stage(test_env) -> lt.Thing:
    """Return the DummyStage Thing set up in the test environment."""
    return test_env.get_thing_by_type(DummyStage)


def test_downsample_shape_2d():
    """Test downsampling for 2D array."""
    shape_2d = (100, 80)
    result_2d = simulation._downsample_shape(shape_2d, simulation.DOWNSAMPLE)
    assert len(result_2d) == 2
    assert result_2d == (100 // simulation.DOWNSAMPLE, 80 // simulation.DOWNSAMPLE)


def test_downsample_shape_3d():
    """Test downsampling for 3D array, should not affect 3rd axis or shape."""
    shape_3d = (120, 60, 3)
    result_3d = simulation._downsample_shape(shape_3d, simulation.DOWNSAMPLE)
    assert len(result_3d) == 3
    assert result_3d == (120 // simulation.DOWNSAMPLE, 60 // simulation.DOWNSAMPLE, 3)


def test_downsample_shape_invalid_length():
    """Shapes that are not length 2 or 3 should raise ValueError."""
    with pytest.raises(ValueError, match="Shape should be a 2 or 3 element tuple."):
        simulation._downsample_shape((1,), simulation.DOWNSAMPLE)

    with pytest.raises(ValueError, match="Shape should be a 2 or 3 element tuple."):
        simulation._downsample_shape((1, 2, 3, 4), simulation.DOWNSAMPLE)


def all_colours_present(
    col_str: str, colours: list[tuple[int, int, int]], tries: int = 100
) -> bool:
    """Check that for a given colour string that all listed colours are returned.

    A helper function for testing simulation.colour_str_to_colour. As the result is
    randomised the test just tries multiple times. In theory could fail, so pick
    tries high enough if there are lots of colours in col_str.
    """
    found = [False for _c in colours]
    for _i in range(tries):
        col_tuple = simulation.colour_str_to_colour(col_str)
        if col_tuple not in colours:
            raise ValueError("Unexpected colour returned.")
        found[colours.index(col_tuple)] = True
        # Don't exit early or we don't confirm that extra colours are not returned.
    return all(found)


def test_colour_str_to_colour():
    """Test colour_str_to_colour with some basic predefined test cases."""
    # A basic test to convert a single str to the expected colour
    assert simulation.colour_str_to_colour("#123456") == (0x12, 0x34, 0x56)
    # A basic test with a trailing semicolon and surrounding spacing
    assert simulation.colour_str_to_colour("  #123456 ; ") == (0x12, 0x34, 0x56)
    # A 2 colour test
    assert all_colours_present(
        "#123456; #654321", [(0x12, 0x34, 0x56), (0x65, 0x43, 0x21)]
    )
    # A failure_test (to check the helper function works!)
    with pytest.raises(ValueError, match="Unexpected colour returned."):
        assert all_colours_present(
            "#123456; #654321", [(0x12, 0x34, 0x56), (0x11, 0x11, 0x11)]
        )
    # And some incorrect strings that should fire an error in colour_str_to_colour
    bad_colours = ["foobar", "pink", "#123", "#123456, #654321"]
    for colour_str in bad_colours:
        with pytest.raises(
            ValueError, match=r".*not a valid colour. Please use HTML hex notation."
        ):
            simulation.colour_str_to_colour(colour_str)


@given(st.from_regex(simulation.COLOUR_LIST_REGEX, fullmatch=True))
def test_colour_list_regex(colour_str):
    """Check that anything matching the regex doesn't error when generating colours.

    This will error if splitting colour_str into individual colours produces
    an incorrect colour. Trying 100 times for each colour_str as the returned colour
    is randomised. Hypothesis will try to create the strings that match the regex but
    break the test.
    """
    for _ in range(100):
        simulation.colour_str_to_colour(colour_str)


def test_canvas_regeneration(camera, caplog):
    """Check canvas is regenerated if blob density or colour are changed."""
    cached_canvas = camera.canvas
    original_colour = camera.colour

    # First try a bad colour string
    with caplog.at_level(logging.WARNING):
        camera.colour = "foobar"
    assert len(caplog.messages) == 1
    assert caplog.messages[0] == "foobar is not a valid colour string."
    # Value and canvas unchanged
    assert camera.colour == original_colour
    assert camera.canvas is cached_canvas

    # Set a valid colour
    camera.colour = "#123456"
    assert camera.colour == "#123456"
    # canvas updated
    assert camera.canvas is not cached_canvas

    # Cache again
    cached_canvas = camera.canvas
    camera.blob_density = 321
    assert camera.blob_density == 321
    # Canvas updated again
    assert camera.canvas is not cached_canvas


def test_infinite_sample(camera, stage):
    """Check that setting camera.repeating makes the sample infinite."""
    # Turn off noise to make comparison easier
    camera.noise_level = 0
    assert not camera.repeating
    cached_canvas = camera.canvas
    array_not_repeating = camera.capture_array()
    camera.repeating = True
    time.sleep(0.2)  # Ensure frame regenerates
    # Canvas shouldn't regenerate
    assert camera.canvas is cached_canvas
    array_repeating = camera.capture_array()
    # Images are identical whether or not repeating
    assert np.array_equal(array_not_repeating, array_repeating)

    # Move outside the non-repeating sample area
    stage._hardware_position["x"] = 100_000_000

    camera.repeating = False
    time.sleep(0.2)  # Ensure frame regenerates
    # If not repeating the array is just background
    assert np.all(camera.capture_array() == simulation.BG_COLOR)

    # Turn on repeating
    camera.repeating = True
    time.sleep(0.2)  # Ensure frame regenerates
    # Sample is now infinite, so not all background
    assert not np.all(camera.capture_array() == simulation.BG_COLOR)


def test_simulation_cam_calibration(camera):
    """Test that the simulated camera can be calibrated and reports calibration correctly."""
    assert camera.calibration_required
    camera.full_auto_calibrate()
    assert not camera.calibration_required
    assert camera.background_detector.ready


def test_objective_getter_setter(camera):
    """Verify that the objective property can be set and read.

    - Defaults to 40x.
    - Accepts only valid magnification values (4, 10, 20, 40, 60, 100).
    - Raises ValueError for invalid magnifications.
    """
    # Default value
    assert camera.objective == 40

    # Valid values
    for val in (4, 10, 20, 40, 60, 100):
        camera.objective = val
        assert camera.objective == val

    err_msg = "Objective must be one of 4, 10, 20, 40, 60, 100."
    # Invalid values should raise
    with pytest.raises(ValueError, match=err_msg):
        camera.objective = 15
    with pytest.raises(ValueError, match=err_msg):
        camera.objective = 0
    with pytest.raises(ValueError, match=err_msg):
        camera.objective = "twenty"


def test_generate_image_changes_with_objective(camera):
    """Changing the objective should change the generated image.

    Higher magnification should produce a more zoomed-in image
    (different pixel content compared to lower magnification).
    """
    pos = (0, 0, 0)
    camera.noise_level = 0  # eliminate randomness

    # Generate images at 3 magnifications
    camera.objective = 10
    img_10 = np.array(camera.generate_image(pos))

    camera.objective = 40
    img_40 = np.array(camera.generate_image(pos))

    camera.objective = 100
    img_100 = np.array(camera.generate_image(pos))

    # Images at different objectives should not be identical
    assert not np.array_equal(img_10, img_40)
    assert not np.array_equal(img_40, img_100)

    # Generate 3 more images at these 3 magnifications
    camera.objective = 10
    img_10_im2 = np.array(camera.generate_image(pos))

    camera.objective = 40
    img_40_im2 = np.array(camera.generate_image(pos))

    camera.objective = 100
    img_100_im2 = np.array(camera.generate_image(pos))

    # Image should return to an identical value
    assert np.array_equal(img_10, img_10_im2)
    assert np.array_equal(img_40, img_40_im2)
    assert np.array_equal(img_100, img_100_im2)


def test_generate_image_output_size(camera):
    """Generated image doesn't change with objective."""
    pos = (0, 0, 0)

    for objective in (4, 10, 20, 40, 60, 100):
        camera.objective = objective
        img = camera.generate_image(pos)

        assert img.size == (camera.shape[1], camera.shape[0])
