"""Test the background detection algorithms.

This tests both the base class an individual algorithms.
"""

import re

import numpy as np
import pytest

import labthings_fastapi as lt
from labthings_fastapi.testing import create_thing_without_server

from openflexure_microscope_server.things.background_detect import (
    BackgroundDetectAlgorithm,
    ChannelBlankError,
    ChannelDeviationLUV,
    ColourChannelDetectLUV,
    MissingBackgroundDataError,
    _chunked_stds,
)
from openflexure_microscope_server.ui import PropertyControl

RNG = np.random.default_rng()
IMG_SHAPE = (820, 616, 3)
BG_COLOR = [220, 215, 217]

PERC_REGEX = re.compile(r"(\d+\.+\d)+%")


@pytest.fixture
def background_image():
    """Generate a numpy array that simulates a background image."""
    image = np.ones(IMG_SHAPE, dtype=np.int16)
    image[:, :, 0] *= BG_COLOR[0]
    image[:, :, 1] *= BG_COLOR[1]
    image[:, :, 2] *= BG_COLOR[2]
    image += RNG.normal(scale=3, size=IMG_SHAPE).astype("int16")
    image[image < 0] = 0
    image[image > 255] = 255
    return image.astype("uint8")


@pytest.fixture
def sample_image(background_image):
    """Generate a numpy array of a simulated image where 50% is a block of red."""
    image = background_image.copy()
    x_cent = IMG_SHAPE[0] // 2
    image[:x_cent, :, 0] -= 180
    return image


def test_bg_detect_base_class():
    """Test the base class for background detect.

    If initialised as is it should raise not implemented error.
    """
    with pytest.raises(NotImplementedError):
        create_thing_without_server(BackgroundDetectAlgorithm)


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

    class BadAlgo(BackgroundDetectAlgorithm):
        """Can initialise. Other properties and methods error."""

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

    bad_algo = create_thing_without_server(BadAlgo)

    with pytest.raises(NotImplementedError):
        bad_algo.ready

    with pytest.raises(NotImplementedError):
        bad_algo.settings_ui

    with pytest.raises(NotImplementedError):
        bad_algo.set_background(background_image)

    with pytest.raises(NotImplementedError):
        bad_algo.image_is_sample(background_image)


def test_colour_channel_luv(background_image, sample_image):
    """Test measuring if a sample is background."""
    cc_luv = create_thing_without_server(ColourChannelDetectLUV)

    # No background data so it is not ready and will error if image_is_sample is called.
    assert not cc_luv.ready
    with pytest.raises(MissingBackgroundDataError):
        cc_luv.image_is_sample(background_image)

    # Set the background
    cc_luv.set_background(background_image)
    # Now it is ready
    assert cc_luv.ready
    sample, message = cc_luv.image_is_sample(background_image)
    assert not sample
    assert "0.0%" in message
    sample, message = cc_luv.image_is_sample(sample_image)
    assert sample
    match = PERC_REGEX.search(message)
    assert match is not None
    # Should be 50% background. Allowing 49.8-50.2% due to noise.
    assert 49.8 < float(match.group(1)) < 50.2

    # Require 75% coverage
    cc_luv.min_sample_coverage = 75.0

    sample, message = cc_luv.image_is_sample(sample_image)
    # No longer detected as a sample.
    assert not sample
    match = PERC_REGEX.search(message)
    assert match is not None
    # Still 50% background.
    assert 49.8 < float(match.group(1)) < 50.2


def create_patchwork_image(magnitude=3, blank_channels=None):
    """Create a patchwork image, with known stds per chunk.

    :return: The image, and the (8,8,3) array of stds
    """
    if blank_channels is None:
        blank_channels = []
    n_rows = 8
    n_cols = 8
    chunk_h = 10
    chunk_w = 10

    # Precompute chunk stds and construct the final image
    expected_stds = np.zeros((n_rows, n_cols, 3), dtype=float)
    img = np.zeros((n_rows * chunk_h, n_cols * chunk_w, 3), dtype=np.uint8)

    for i in range(n_rows):
        for j in range(n_cols):
            for channel in range(3):
                if channel in blank_channels:
                    chunk = np.zeros((chunk_h, chunk_w), dtype=np.uint8)
                else:
                    chunk = 9.8 * np.ones((chunk_h, chunk_w))
                    chunk += RNG.normal(scale=magnitude, size=(chunk_h, chunk_w))
                    chunk = chunk.astype(np.uint8)

                # Store its std
                expected_stds[i, j, channel] = np.std(chunk)

                # Insert chunk into the final large image
                y0, y1 = i * chunk_h, (i + 1) * chunk_h
                x0, x1 = j * chunk_w, (j + 1) * chunk_w
                img[y0:y1, x0:x1, channel] = chunk
    return img, expected_stds


def test_chunked_stds_with_precomputed_chunk_stds():
    """Test _chunked_stds returns the answer calculated when making a patchwork image."""
    img, expected_stds = create_patchwork_image()

    # Run the function under test
    result = _chunked_stds(img, n_rows=8, n_cols=8)

    # Compare
    np.testing.assert_allclose(result, expected_stds, rtol=1e-6, atol=1e-12)


def test_channel_deviation_luv_set_background(mocker):
    """Test set_background takes the median of each channel, and errors for blank channels."""
    cd_luv = create_thing_without_server(ChannelDeviationLUV)

    # Patch RGB to LUV so or we don't know what the STDs should be
    mocker.patch("cv2.cvtColor", side_effect=lambda img, _method: img)

    for magnitude in [0.2, 1, 10]:
        # Create an image and set it as background
        img, expected_stds = create_patchwork_image(magnitude=magnitude)
        cd_luv.set_background(img)

        # Do a somewhat verbose checking for clarity
        for channel in range(3):
            # Saved std
            channel_std = cd_luv.background_stds[channel]
            # Expected median
            channel_median = np.median(expected_stds[:, :, channel])
            # If the median is above the minimum allowed then it should be returned
            if channel_median > cd_luv.min_stds[channel]:
                assert np.isclose(channel_std, channel_median, rtol=1e-6, atol=1e-12)
            else:
                # If not the minimum is returned.
                assert channel_std == cd_luv.min_stds[channel]

    # Also check that if channels are blank then an error is thrown
    img, _expected_stds = create_patchwork_image(magnitude=1, blank_channels=[1, 2])
    with pytest.raises(ChannelBlankError):
        cd_luv.set_background(img)


def test_channel_deviation_luv_image_is_sample(background_image, mocker):
    """Check image_is_sample reports the result from get_sample_coverage."""
    cd_luv = create_thing_without_server(ChannelDeviationLUV)

    # No background data so it is not ready and will error if image_is_sample is called.
    assert not cd_luv.ready
    with pytest.raises(MissingBackgroundDataError):
        cd_luv.image_is_sample(background_image)

    cd_luv.min_sample_coverage = 20
    cd_luv.get_sample_coverage = mocker.Mock(return_value=10)
    is_sample, message = cd_luv.image_is_sample(background_image)
    assert not is_sample
    assert message == r"only 10.0% sample"

    # Reduce the min coverage
    cd_luv.min_sample_coverage = 9

    is_sample, message = cd_luv.image_is_sample(background_image)
    assert is_sample
    assert message == r"10.0% sample"


def test_channel_deviation_luv_get_sample_coverage(background_image, mocker):
    """Check _get_sample_coverage returns the values expected."""
    cd_luv = create_thing_without_server(ChannelDeviationLUV)

    # Create fake chunked STD data where each channel is the numbers 0 -> 31.5 in 0.5
    # steps
    grid = np.arange(0, 32, 0.5).reshape(8, 8)
    fake_stds = np.stack([grid, grid, grid], axis=-1)
    mocker.patch(
        "openflexure_microscope_server.things.background_detect._chunked_stds",
        return_value=fake_stds,
    )

    # Create fake background
    cd_luv.background_stds = [1.1, 1.1, 1.1]

    # Get sample coverage with channel tolerance of 7. Checking each channel for the
    # numbers below 7.7. There are 16 out of 64. So 75% should be sample
    cd_luv.channel_tolerance = 7
    assert cd_luv.get_sample_coverage(background_image) == 75
    # This is unchanged if two channels have larger background values.
    cd_luv.background_stds = [1.6, 1.6, 1.1]
    assert cd_luv.get_sample_coverage(background_image) == 75
    # But coverage increases if any channels has a lower background value.
    cd_luv.background_stds = [1.6, 0.6, 1.1]
    assert cd_luv.get_sample_coverage(background_image) == 85.9375
    # Returns to 75% if that channel is empty
    fake_stds[:, :, 1] = 0
    assert cd_luv.get_sample_coverage(background_image) == 75


@pytest.mark.parametrize("detector_cls", [ColourChannelDetectLUV, ChannelDeviationLUV])
def test_background_detect_settings_ui(detector_cls):
    """Check that both background detectors provide the expected UI to the webapp.

    As both have identical settings they can be tested together
    """
    detector = create_thing_without_server(detector_cls)
    ui = detector.settings_ui

    assert len(ui) == 2
    assert isinstance(ui[0], PropertyControl)
    assert ui[0].property_name == "channel_tolerance"
    assert ui[0].label == "Channel Tolerance"
    assert isinstance(ui[1], PropertyControl)
    assert ui[1].property_name == "min_sample_coverage"
    assert ui[1].label == "Sample Coverage Required (%)"
