import logging
import pprint
from pathlib import Path
from typing import Any, Tuple, Union
from unittest.mock import patch

import pytest
from PIL import Image

from psd_tools.api.layers import Group
from psd_tools.api.psd_image import PSDImage
from psd_tools.api.utils import get_transparency_index, has_transparency
from psd_tools.constants import BlendMode, ColorMode, Compression

from ..utils import full_name

logger = logging.getLogger(__name__)


@pytest.fixture
def fixture() -> PSDImage:
    return PSDImage.open(full_name("colormodes/4x4_8bit_rgb.psd"))


@pytest.mark.parametrize(
    "args",
    [
        ("L", (16, 24), (0,)),
        ("LA", (16, 24), (0, 255)),
        ("RGB", (16, 24), (255, 128, 64)),
        ("RGBA", (16, 24), (255, 128, 64, 255)),
        ("CMYK", (16, 24), (255, 128, 64, 128)),
    ],
)
def test_new(args: Tuple[str, Tuple[int, int], Tuple[int, ...]]) -> None:
    PSDImage.new(*args)  # type: ignore[arg-type]


def test_frompil_psb() -> None:
    image = Image.new("RGB", (30001, 24))
    psb = PSDImage.frompil(image)
    assert psb.version == 2


@pytest.mark.parametrize(
    "filename",
    [
        "colormodes/4x4_8bit_rgb.psd",
        Path("colormodes/4x4_8bit_rgb.psd"),
    ],
)
def test_open(filename: Union[str, Path]) -> None:
    input_path = full_name(str(filename))
    PSDImage.open(input_path)
    with open(input_path, "rb") as f:
        PSDImage.open(f)


def test_save(fixture: PSDImage, tmp_path: Path) -> None:
    output_path = tmp_path / "output.psd"
    fixture.save(str(output_path))
    fixture.save(output_path)
    with open(output_path, "wb") as f:
        fixture.save(f)


def test_pilio(fixture: PSDImage) -> None:
    image = fixture.topil()
    for i in range(fixture.channels):
        fixture.topil(channel=i)
    assert image is not None
    psd = PSDImage.frompil(image, compression=Compression.RAW)
    assert psd._record.header == fixture._record.header
    assert psd._record.image_data == fixture._record.image_data


def test_properties(fixture: PSDImage) -> None:
    assert fixture.name == "Root"
    assert fixture.kind == "psdimage"
    assert fixture.visible is True
    assert fixture.parent is None
    assert fixture.left == 0
    assert fixture.top == 0
    assert fixture.right == 4
    assert fixture.bottom == 4
    assert fixture.width == 4
    assert fixture.height == 4
    assert fixture.size == (4, 4)
    assert fixture.bbox == (0, 0, 4, 4)
    assert fixture.viewbox == (0, 0, 4, 4)
    assert fixture.image_resources
    assert fixture.tagged_blocks
    assert fixture.color_mode == ColorMode.RGB
    assert fixture.version == 1


def test_version() -> None:
    assert PSDImage.open(full_name("gray0.psb")).version == 2


def test_is_visible(fixture: PSDImage) -> None:
    assert fixture.is_visible() is True


def test_is_group(fixture: PSDImage) -> None:
    assert fixture.is_group() is True


def test_has_preview(fixture: PSDImage) -> None:
    assert fixture.has_preview() is True


def test_thumnail(fixture: PSDImage) -> None:
    assert fixture.has_thumbnail() is True
    assert fixture.thumbnail()


def test_repr_pretty(fixture: PSDImage) -> None:
    fixture.__repr__()
    pprint.pprint(fixture)


@pytest.mark.parametrize(
    "filename",
    [str(Path("third-party-psds") / "cactus_top.psd")],
)
def test_open2(filename: str) -> None:
    assert isinstance(PSDImage.open(full_name(filename)), PSDImage)


def test_create_pixel_layer() -> None:
    psdimage = PSDImage.new(mode="RGB", size=(100, 100))
    layer = psdimage.create_pixel_layer(
        Image.new("RGB", (50, 50), (255, 0, 0)),
        name="Red Layer",
        top=10,
        left=20,
        compression=Compression.RLE,
        opacity=128,
        blend_mode=BlendMode.MULTIPLY,
    )
    assert layer.name == "Red Layer"
    assert layer.top == 10
    assert layer.left == 20
    assert layer.opacity == 128
    assert layer.blend_mode == BlendMode.MULTIPLY
    assert len(psdimage) == 1
    assert psdimage[0] is layer


def test_create_group() -> None:
    psdimage = PSDImage.new(mode="RGB", size=(100, 100))
    layer_list = [
        psdimage.create_pixel_layer(
            Image.new("RGB", (50, 50), (0, 255, 0)),
            name="Green Layer",
        ),
    ]
    group = psdimage.create_group(
        layer_list,
        name="My Group",
        open_folder=False,
        opacity=128,
        blend_mode=BlendMode.SCREEN,
    )
    assert group.name == "My Group"
    assert group.open_folder is False
    assert len(psdimage) == 1
    assert psdimage[0] is group
    assert len(group) == 1
    assert group[0] is layer_list[0]
    assert group.opacity == 128
    assert group.blend_mode == BlendMode.SCREEN


def test_update_record(fixture: PSDImage) -> None:
    pixel_layer = PSDImage.open(full_name("layers/pixel-layer.psd"))[0]
    fill_layer = PSDImage.open(full_name("layers/solid-color-fill.psd"))[0]
    shape_layer = PSDImage.open(full_name("layers/shape-layer.psd"))[0]
    smart_layer = PSDImage.open(full_name("layers/smartobject-layer.psd"))[0]
    type_layer = PSDImage.open(full_name("layers/type-layer.psd"))[0]
    group_layer = PSDImage.open(full_name("layers/group.psd"))[0]
    assert isinstance(group_layer, Group)

    group_layer.extend([pixel_layer, fill_layer, shape_layer, smart_layer, type_layer])
    fixture.append(group_layer)
    layer_info = fixture._record.layer_and_mask_information.layer_info
    assert layer_info is not None

    assert layer_info.layer_count == 9

    assert layer_info.layer_records[0] is fixture[0]._record
    assert layer_info.layer_records[1] is fixture[1]._record

    assert layer_info.layer_records[2] is group_layer._bounding_record
    assert layer_info.layer_records[3] is pixel_layer._record
    assert layer_info.layer_records[4] is fill_layer._record
    assert layer_info.layer_records[5] is shape_layer._record
    assert layer_info.layer_records[6] is smart_layer._record
    assert layer_info.layer_records[7] is type_layer._record
    assert layer_info.layer_records[8] is group_layer._record

    assert layer_info.channel_image_data[0] is fixture[0]._channels
    assert layer_info.channel_image_data[1] is fixture[1]._channels

    assert layer_info.channel_image_data[2] is group_layer._bounding_channels
    assert layer_info.channel_image_data[3] is pixel_layer._channels
    assert layer_info.channel_image_data[4] is fill_layer._channels
    assert layer_info.channel_image_data[5] is shape_layer._channels
    assert layer_info.channel_image_data[6] is smart_layer._channels
    assert layer_info.channel_image_data[7] is type_layer._channels
    assert layer_info.channel_image_data[8] is group_layer._channels


def test_is_updated() -> None:
    psd = PSDImage.open(full_name("hidden-groups.psd"))
    assert not psd.is_updated()
    psd[0].visible = False
    assert psd.is_updated()

    psd = PSDImage.open(full_name("clipping-mask.psd"))
    assert not psd.is_updated()
    layer_1 = psd[1]
    assert isinstance(layer_1, Group)
    layer_1[2].clipping = False
    assert psd.is_updated()


def test_save_without_composite_dependencies(tmp_path: Path, caplog: Any) -> None:
    """Test that save works gracefully without composite dependencies."""
    # Create a simple PSD and modify it
    psdimage = PSDImage.new(mode="RGB", size=(100, 100))
    psdimage.create_pixel_layer(
        Image.new("RGB", (50, 50), (255, 0, 0)),
        name="Test Layer",
    )

    # Mark as updated
    assert psdimage.is_updated()

    output_path = tmp_path / "test_no_composite.psd"

    # Mock composite() to raise ImportError (simulating missing dependencies)
    def mock_composite(*args: Any, **kwargs: Any) -> None:
        raise ImportError("No module named 'scipy'")

    with patch.object(psdimage, "composite", side_effect=mock_composite):
        # Should not raise, should log warning
        with caplog.at_level(logging.WARNING):
            psdimage.save(str(output_path))

    # Verify file was saved
    assert output_path.exists()

    # Verify warning was logged
    assert "Failed to update preview image" in caplog.text
    assert "pip install 'psd-tools[composite]'" in caplog.text

    # Verify saved file can be read back
    loaded = PSDImage.open(output_path)
    assert len(loaded) == 1
    assert loaded[0].name == "Test Layer"


def test_save_rgba_pixel_layer(tmp_path: Path):
    # create an RGBA PSD with a layer
    psd = PSDImage.new("RGBA", (32, 48))
    psd.create_pixel_layer(Image.linear_gradient("L").resize(psd.size), "layer1")
    psd.save(tmp_path / "tmp.psd")

    # check that the updated preview image has the expected number of channels
    assert len(psd._record.image_data.get_data(psd._record.header)) == 4


# --- background_color tests ---


def test_background_color_default() -> None:
    """background_color defaults to 0.0 (black) matching the default color=0."""
    psdimage = PSDImage.new("RGB", (16, 16))
    assert psdimage.background_color == 0.0


def test_background_color_setter() -> None:
    """background_color setter accepts float, tuple, int, and None."""
    psdimage = PSDImage.new("RGB", (16, 16))

    psdimage.background_color = 1.0
    assert psdimage.background_color == 1.0

    psdimage.background_color = (1.0, 0.5, 0.0)
    assert psdimage.background_color == (1.0, 0.5, 0.0)

    psdimage.background_color = None
    assert psdimage.background_color is None

    # int is normalized as pixel value via document depth (8-bit: /255)
    psdimage.background_color = 255
    assert psdimage.background_color == 1.0

    psdimage.background_color = 0
    assert psdimage.background_color == 0.0

    psdimage.background_color = 128
    assert abs(psdimage.background_color - 128 / 255) < 1e-9  # type: ignore[operator]

    # Mixed int/float sequence: each element dispatched by type
    psdimage.background_color = (1.0, 128, 0.5)
    expected = (1.0, 128 / 255, 0.5)
    assert psdimage.background_color is not None
    for a, b in zip(psdimage.background_color, expected):  # type: ignore[arg-type]
        assert abs(a - b) < 1e-9

    # list input accepted (any Sequence works)
    psdimage.background_color = [1.0, 0.5, 0.0]
    assert psdimage.background_color == (1.0, 0.5, 0.0)


def test_background_color_setter_invalid() -> None:
    """background_color setter rejects invalid types and out-of-range values."""
    psdimage = PSDImage.new("RGB", (16, 16))
    with pytest.raises(TypeError):
        psdimage.background_color = "white"  # type: ignore[assignment]
    # Out-of-range float
    with pytest.raises(ValueError):
        psdimage.background_color = 2.0
    # Out-of-range int for 8-bit depth
    with pytest.raises(ValueError):
        psdimage.background_color = 256  # type: ignore[assignment]
    # Out-of-range tuple component
    with pytest.raises(ValueError):
        psdimage.background_color = (1.0, 1.5, 0.0)


def test_background_color_setter_invalid_channel_count() -> None:
    """background_color setter rejects sequences with wrong channel count."""
    rgb = PSDImage.new("RGB", (16, 16))
    # Too few channels for RGB (expects 3)
    with pytest.raises(ValueError, match="Expected 3 color channel"):
        rgb.background_color = (1.0, 0.5)
    # Too many channels for RGB
    with pytest.raises(ValueError, match="Expected 3 color channel"):
        rgb.background_color = (1.0, 0.5, 0.3, 0.2)

    cmyk = PSDImage.new("CMYK", (16, 16))
    # Wrong count for CMYK (expects 4)
    with pytest.raises(ValueError, match="Expected 4 color channel"):
        cmyk.background_color = (0.0, 0.0, 0.0)

    gray = PSDImage.new("L", (16, 16))
    # Wrong count for Grayscale (expects 1)
    with pytest.raises(ValueError, match="Expected 1 color channel"):
        gray.background_color = (0.5, 0.5)

    # Scalar input is always valid regardless of color mode
    rgb.background_color = 1.0
    assert rgb.background_color == 1.0

    # Correct channel counts are accepted
    rgb.background_color = (1.0, 0.5, 0.0)
    assert rgb.background_color == (1.0, 0.5, 0.0)
    cmyk.background_color = (0.0, 0.0, 0.0, 0.0)
    assert cmyk.background_color == (0.0, 0.0, 0.0, 0.0)
    gray.background_color = (0.5,)
    assert gray.background_color == (0.5,)


def test_new_with_float_color() -> None:
    """new() with float color fills ImageData and sets background_color."""
    psdimage = PSDImage.new("RGB", (4, 4), color=1.0)
    assert psdimage.background_color == 1.0
    channels = psdimage._record.image_data.get_data(psdimage._record.header)
    assert isinstance(channels, list)
    # All 3 RGB channels should be filled with 255 (white)
    for ch in channels:
        assert all(b == 255 for b in ch)


def test_new_with_float_color_tuple() -> None:
    """new() with float tuple color fills per-channel and sets background_color."""
    psdimage = PSDImage.new("RGB", (4, 4), color=(1.0, 0.0, 0.0))
    assert psdimage.background_color == (1.0, 0.0, 0.0)
    channels = psdimage._record.image_data.get_data(psdimage._record.header)
    assert isinstance(channels, list)
    assert all(b == 255 for b in channels[0])  # R = 255
    assert all(b == 0 for b in channels[1])  # G = 0
    assert all(b == 0 for b in channels[2])  # B = 0


def test_new_with_int_color() -> None:
    """new() with int color sets background_color (normalized to float)."""
    psdimage = PSDImage.new("RGB", (4, 4), color=128)
    assert abs(psdimage.background_color - 128 / 255) < 1e-9  # type: ignore[operator]
    channels = psdimage._record.image_data.get_data(psdimage._record.header)
    assert isinstance(channels, list)
    for ch in channels:
        assert all(b == 128 for b in ch)


def test_frompil_with_color(tmp_path: Path) -> None:
    """frompil() with color stores background_color and affects save() composite."""
    image = Image.new("RGB", (16, 16), (128, 128, 128))
    psdimage = PSDImage.frompil(image, color=0.5)
    assert psdimage.background_color == 0.5

    # Round-trip: fully transparent RGBA image with white backdrop.
    rgba = Image.new("RGBA", (4, 4), (0, 0, 0, 0))
    psdimage = PSDImage.frompil(rgba, color=1.0)
    output = tmp_path / "frompil_bg.psd"
    psdimage.save(str(output))
    reloaded = PSDImage.open(output)
    channels = reloaded._record.image_data.get_data(reloaded._record.header)
    assert len(channels) == 4  # RGBA
    # Transparent pixels blended over white backdrop -> all white, fully opaque.
    for ch in channels:
        assert isinstance(ch, bytes)
        assert ch[0] == 255


def test_save_with_float_color_rgb(tmp_path: Path) -> None:
    """Save RGB PSD with white float color produces correct output."""
    psdimage = PSDImage.new("RGB", (32, 32), color=1.0)
    psdimage.create_pixel_layer(
        Image.new("RGBA", (16, 16), (255, 0, 0, 128)),
        name="Red Layer",
    )

    output = tmp_path / "bg_white_rgb.psd"
    psdimage.save(str(output))

    loaded = PSDImage.open(output)
    assert loaded.channels == 3
    channels = loaded._record.image_data.get_data(loaded._record.header)
    assert isinstance(channels, list)
    assert len(channels) == 3
    # Corner pixel (31,31) is outside the 16x16 layer → pure white background
    corner = 31 * 32 + 31
    for ch in channels:
        assert isinstance(ch, bytes)
        assert ch[corner] == 255


def test_save_with_float_color_grayscale(tmp_path: Path) -> None:
    """Save Grayscale PSD with white float color."""
    psdimage = PSDImage.new("L", (32, 32), color=1.0)
    psdimage.create_pixel_layer(
        Image.new("LA", (16, 16), (0, 128)),
        name="Dark Layer",
    )

    output = tmp_path / "bg_white_gray.psd"
    psdimage.save(str(output))

    loaded = PSDImage.open(output)
    assert loaded.channels == 1
    channels = loaded._record.image_data.get_data(loaded._record.header)
    assert isinstance(channels, list)
    assert len(channels) == 1
    # Corner pixel (31,31) is outside the 16x16 layer → pure white background
    corner = 31 * 32 + 31
    assert isinstance(channels[0], bytes)
    assert channels[0][corner] == 255


def test_save_with_float_color_cmyk(tmp_path: Path) -> None:
    """Save CMYK PSD with white background (no ink)."""
    psdimage = PSDImage.new("CMYK", (32, 32), color=(0.0, 0.0, 0.0, 0.0))
    psdimage.create_pixel_layer(
        Image.new("CMYK", (16, 16), (255, 0, 0, 0)),
        name="Cyan Layer",
    )

    output = tmp_path / "bg_white_cmyk.psd"
    psdimage.save(str(output))

    loaded = PSDImage.open(output)
    assert loaded.channels == 4
    assert loaded.color_mode == ColorMode.CMYK
    channels = loaded._record.image_data.get_data(loaded._record.header)
    assert isinstance(channels, list)
    assert len(channels) == 4
    # Corner pixel (31,31) is outside the 16x16 layer → white backdrop.
    # PSD stores CMYK inverted: 255 = no ink = white.
    corner = 31 * 32 + 31
    for ch in channels:
        assert isinstance(ch, bytes)
        assert ch[corner] == 255


def test_save_with_float_color_rgba(tmp_path: Path) -> None:
    """Save RGBA PSD with float color produces all-opaque alpha."""
    psdimage = PSDImage.new("RGBA", (32, 32), color=1.0)
    psdimage.create_pixel_layer(
        Image.new("RGBA", (16, 16), (255, 0, 0, 128)),
        name="Red Layer",
    )

    output = tmp_path / "bg_white_rgba.psd"
    psdimage.save(str(output))

    loaded = PSDImage.open(output)
    assert loaded.channels == 4
    channels = loaded._record.image_data.get_data(loaded._record.header)
    assert isinstance(channels, list)
    assert len(channels) == 4
    # Alpha channel (index 3) should be all 255 (fully opaque)
    assert all(b == 255 for b in channels[3])


def test_composite_preserves_alpha_for_transparent_psd(tmp_path: Path) -> None:
    """composite() should return RGBA when re-compositing a transparent PSD.

    Regression test for https://github.com/psd-tools/psd-tools/issues/592
    """
    psd = PSDImage.new("RGBA", (16, 16))
    psd.create_pixel_layer(
        Image.new("RGBA", (16, 16), (255, 0, 0, 128)),
        name="Red Semi-transparent",
    )
    output = tmp_path / "transparent.psd"
    psd.save(str(output))

    loaded = PSDImage.open(output)
    # ignore_preview forces compositing from layers instead of using the
    # stored merged preview, which may lack alpha.
    result = loaded.composite(ignore_preview=True)
    assert result.mode == "RGBA"

    # force=True should also produce RGBA
    result_forced = loaded.composite(force=True)
    assert result_forced.mode == "RGBA"


def _save_psd_with_negative_layer_count(
    tmp_path: Path,
    size: tuple[int, int] = (16, 16),
) -> Path:
    """Create an RGBA PSD with negative layer_count (Photoshop transparency signal).

    Per the PSD spec, a negative layer_count means the first alpha channel
    in the merged image data contains transparency for the composite.
    """
    psd = PSDImage.new("RGBA", size)
    layer_img = Image.new("RGBA", (8, 8), (255, 0, 0, 128))
    psd.create_pixel_layer(layer_img, name="partial", top=4, left=4)
    li = psd._record.layer_and_mask_information.layer_info
    assert li is not None
    li.layer_count = -abs(li.layer_count)
    output = tmp_path / "negative_layer_count.psd"
    psd.save(str(output))
    return output


def test_has_transparency_negative_layer_count(tmp_path: Path) -> None:
    """has_transparency() returns True when layer_count is negative."""
    output = _save_psd_with_negative_layer_count(tmp_path)
    loaded = PSDImage.open(output)
    assert has_transparency(loaded) is True


def test_has_transparency_positive_layer_count(tmp_path: Path) -> None:
    """has_transparency() returns False for a normal PSD without transparency tags."""
    psd = PSDImage.new("RGBA", (16, 16))
    psd.create_pixel_layer(Image.new("RGBA", (16, 16), (255, 0, 0, 255)), name="opaque")
    output = tmp_path / "positive_layer_count.psd"
    psd.save(str(output))

    loaded = PSDImage.open(output)
    li = loaded._record.layer_and_mask_information.layer_info
    assert li is not None and li.layer_count > 0
    assert has_transparency(loaded) is False


def test_get_transparency_index_negative_layer_count(tmp_path: Path) -> None:
    """get_transparency_index() returns the first alpha channel index
    when layer_count is negative."""
    output = _save_psd_with_negative_layer_count(tmp_path)
    loaded = PSDImage.open(output)
    # RGB has 3 expected channels, so the transparency channel is at index 3
    assert get_transparency_index(loaded) == 3


def test_composite_preview_rgba_with_negative_layer_count(tmp_path: Path) -> None:
    """Default composite() returns RGBA when layer_count is negative.

    This is the user-facing bug from #592: composite() used the stored preview
    but dropped the alpha channel because has_transparency() returned False.
    """
    output = _save_psd_with_negative_layer_count(tmp_path)
    loaded = PSDImage.open(output)

    # Default path (uses stored preview)
    result = loaded.composite()
    assert result.mode == "RGBA"

    # ignore_preview path (re-composites from layers)
    result_recomp = loaded.composite(ignore_preview=True)
    assert result_recomp.mode == "RGBA"


def test_composite_preview_rgb_with_positive_layer_count(tmp_path: Path) -> None:
    """Default composite() returns RGB for a normal PSD without transparency."""
    psd = PSDImage.new("RGBA", (16, 16))
    psd.create_pixel_layer(Image.new("RGBA", (16, 16), (255, 0, 0, 255)), name="opaque")
    output = tmp_path / "positive_layer_count.psd"
    psd.save(str(output))

    loaded = PSDImage.open(output)
    result = loaded.composite()
    assert result.mode == "RGB"
