//! PDF colors and color spaces.

use crate::WarningSinkFn;
use crate::cache::{Cache, CacheKey};
use crate::function::Function;
use crate::util::decode_or_warn;
use log::warn;
use moxcms::{
    ColorProfile, DataColorSpace, Layout, RenderingIntent, Transform8BitExecutor,
    TransformF32BitExecutor, TransformOptions, Xyzd,
};
use pdf_syntax::object;
use pdf_syntax::object::Array;
use pdf_syntax::object::Dict;
use pdf_syntax::object::Name;
use pdf_syntax::object::Object;
use pdf_syntax::object::Stream;
use pdf_syntax::object::dict::keys::*;
use smallvec::{SmallVec, ToSmallVec, smallvec};
use std::fmt::{Debug, Formatter};
use std::ops::Deref;
use std::sync::{Arc, OnceLock};

/// Default DeviceCMYK → sRGB profile (CGATS001Compat-v2-micro, same family as MuPDF's built-in).
/// Loaded once on first use; falls back to the PDF spec formula if loading fails.
static DEFAULT_CMYK_PROFILE: OnceLock<Option<ICCProfile>> = OnceLock::new();

fn default_cmyk_profile() -> Option<&'static ICCProfile> {
    DEFAULT_CMYK_PROFILE
        .get_or_init(|| ICCProfile::new(include_bytes!("../assets/CGATS001Compat-v2-micro.icc"), 4))
        .as_ref()
}

/// A storage for the components of colors.
pub type ColorComponents = SmallVec<[f32; 4]>;

/// An RGB color with an alpha channel.
#[derive(Debug, Copy, Clone)]
pub struct AlphaColor {
    components: [f32; 4],
}

impl AlphaColor {
    /// A black color.
    pub const BLACK: Self = Self::new([0., 0., 0., 1.]);

    /// A transparent color.
    pub const TRANSPARENT: Self = Self::new([0., 0., 0., 0.]);

    /// A white color.
    pub const WHITE: Self = Self::new([1., 1., 1., 1.]);

    /// Create a new color from the given components.
    pub const fn new(components: [f32; 4]) -> Self {
        Self { components }
    }

    /// Create a new color from RGB8 values.
    pub const fn from_rgb8(r: u8, g: u8, b: u8) -> Self {
        let components = [u8_to_f32(r), u8_to_f32(g), u8_to_f32(b), 1.];
        Self::new(components)
    }

    /// Return the color as premulitplied RGBF32.
    pub fn premultiplied(&self) -> [f32; 4] {
        [
            self.components[0] * self.components[3],
            self.components[1] * self.components[3],
            self.components[2] * self.components[3],
            self.components[3],
        ]
    }

    /// Create a new color from RGBA8 values.
    pub const fn from_rgba8(r: u8, g: u8, b: u8, a: u8) -> Self {
        let components = [u8_to_f32(r), u8_to_f32(g), u8_to_f32(b), u8_to_f32(a)];
        Self::new(components)
    }

    /// Return the color as RGBA8.
    pub fn to_rgba8(&self) -> [u8; 4] {
        [
            (self.components[0] * 255.0 + 0.5) as u8,
            (self.components[1] * 255.0 + 0.5) as u8,
            (self.components[2] * 255.0 + 0.5) as u8,
            (self.components[3] * 255.0 + 0.5) as u8,
        ]
    }

    /// Return the components of the color as RGBF32.
    pub fn components(&self) -> [f32; 4] {
        self.components
    }
}

const fn u8_to_f32(x: u8) -> f32 {
    x as f32 * (1.0 / 255.0)
}

#[derive(Debug, Clone)]
pub(crate) enum ColorSpaceType {
    DeviceCmyk,
    DeviceGray,
    DeviceRgb,
    Pattern(ColorSpace),
    Indexed(Indexed),
    ICCBased(ICCProfile),
    CalGray(CalGray),
    CalRgb(CalRgb),
    Lab(Lab),
    Separation(Separation),
    DeviceN(DeviceN),
}

impl ColorSpaceType {
    fn new(object: Object<'_>, cache: &Cache, warning_sink: &WarningSinkFn) -> Option<Self> {
        Self::new_inner(object, cache, warning_sink)
    }

    fn new_inner(object: Object<'_>, cache: &Cache, warning_sink: &WarningSinkFn) -> Option<Self> {
        if let Some(name) = object.clone().into_name() {
            return Self::new_from_name(name.clone());
        } else if let Some(color_array) = object.clone().into_array() {
            let mut iter = color_array.clone().flex_iter();
            let name = iter.next::<Name>()?;

            match name.deref() {
                ICC_BASED => {
                    let icc_stream = iter.next::<Stream<'_>>()?;
                    let dict = icc_stream.dict();
                    // `N` is the declared component count. PDF 2.0 §8.6.5.5 says
                    // it shall be 1, 3 or 4. If the entry is missing, negative,
                    // or otherwise unusable, the spec requires falling through
                    // to /Alternate rather than giving up on the color space.
                    let num_components = dict.get::<usize>(N);

                    return cache.get_or_insert_with(icc_stream.cache_key(), || {
                        // Only try to parse the embedded ICC profile when N is
                        // valid — without it we can't know how many channels
                        // to decode.
                        let from_icc = num_components.and_then(|n| {
                            decode_or_warn(&icc_stream, warning_sink)
                                .as_ref()
                                .and_then(|decoded| {
                                    ICCProfile::new(decoded, n).map(|icc| {
                                        // TODO: For SVG and PNG we can assume that the output color space is
                                        // sRGB. If we ever implement PDF-to-PDF, we probably want to
                                        // let the user pass the native color type and don't make this optimization
                                        // if it's not sRGB.
                                        if icc.is_srgb() {
                                            Self::DeviceRgb
                                        } else {
                                            Self::ICCBased(icc)
                                        }
                                    })
                                })
                        });

                        from_icc
                            .or_else(|| {
                                dict.get::<Object<'_>>(ALTERNATE)
                                    .and_then(|o| Self::new(o, cache, warning_sink))
                            })
                            .or_else(|| match num_components {
                                Some(1) => Some(Self::DeviceGray),
                                Some(3) => Some(Self::DeviceRgb),
                                Some(4) => Some(Self::DeviceCmyk),
                                _ => None,
                            })
                    });
                }
                CALCMYK => return Some(Self::DeviceCmyk),
                CALGRAY => {
                    let cal_dict = iter.next::<Dict<'_>>()?;
                    return Some(Self::CalGray(CalGray::new(&cal_dict)?));
                }
                CALRGB => {
                    let cal_dict = iter.next::<Dict<'_>>()?;
                    return Some(Self::CalRgb(CalRgb::new(&cal_dict)?));
                }
                DEVICE_RGB | RGB => return Some(Self::DeviceRgb),
                DEVICE_GRAY | G => return Some(Self::DeviceGray),
                DEVICE_CMYK | CMYK => return Some(Self::DeviceCmyk),
                LAB => {
                    let lab_dict = iter.next::<Dict<'_>>()?;
                    return Some(Self::Lab(Lab::new(&lab_dict)?));
                }
                INDEXED | I => {
                    return Some(Self::Indexed(Indexed::new(
                        &color_array,
                        cache,
                        warning_sink,
                    )?));
                }
                SEPARATION => {
                    return Some(Self::Separation(Separation::new(
                        &color_array,
                        cache,
                        warning_sink,
                    )?));
                }
                DEVICE_N => {
                    return Some(Self::DeviceN(DeviceN::new(
                        &color_array,
                        cache,
                        warning_sink,
                    )?));
                }
                PATTERN => {
                    // Base colorspace is the next element: [/Pattern /DeviceCMYK] or
                    // [/Pattern [/ICCBased ...]] etc. Do NOT skip an extra element here.
                    let cs = iter
                        .next::<Object<'_>>()
                        .and_then(|o| ColorSpace::new(o, cache, warning_sink))
                        .unwrap_or(ColorSpace::device_rgb());
                    return Some(Self::Pattern(cs));
                }
                _ => {
                    warn!("unsupported color space: {}", name.as_str());
                    return None;
                }
            }
        }

        None
    }

    fn new_from_name(name: Name) -> Option<Self> {
        match name.deref() {
            DEVICE_RGB | RGB => Some(Self::DeviceRgb),
            DEVICE_GRAY | G => Some(Self::DeviceGray),
            DEVICE_CMYK | CMYK => Some(Self::DeviceCmyk),
            CALCMYK => Some(Self::DeviceCmyk),
            PATTERN => Some(Self::Pattern(ColorSpace::device_rgb())),
            _ => None,
        }
    }
}

/// A PDF color space.
#[derive(Debug, Clone)]
pub struct ColorSpace(Arc<ColorSpaceType>);

impl ColorSpace {
    /// Create a new color space from the given object.
    pub(crate) fn new(
        object: Object<'_>,
        cache: &Cache,
        warning_sink: &WarningSinkFn,
    ) -> Option<Self> {
        Some(Self(Arc::new(ColorSpaceType::new(
            object,
            cache,
            warning_sink,
        )?)))
    }

    /// Create a new color space from the name.
    pub(crate) fn new_from_name(name: Name) -> Option<Self> {
        ColorSpaceType::new_from_name(name).map(|c| Self(Arc::new(c)))
    }

    /// Return the device gray color space.
    pub(crate) fn device_gray() -> Self {
        Self(Arc::new(ColorSpaceType::DeviceGray))
    }

    /// Return the device RGB color space.
    pub(crate) fn device_rgb() -> Self {
        Self(Arc::new(ColorSpaceType::DeviceRgb))
    }

    /// Return the device CMYK color space.
    pub(crate) fn device_cmyk() -> Self {
        Self(Arc::new(ColorSpaceType::DeviceCmyk))
    }

    /// Return `true` if the current color space is DeviceRGB.
    pub fn is_device_rgb(&self) -> bool {
        matches!(*self.0, ColorSpaceType::DeviceRgb)
    }

    /// Return `true` if the current color space is DeviceCMYK.
    pub fn is_device_cmyk(&self) -> bool {
        matches!(*self.0, ColorSpaceType::DeviceCmyk)
    }

    /// Return the pattern color space.
    pub(crate) fn pattern() -> Self {
        Self(Arc::new(ColorSpaceType::Pattern(Self::device_gray())))
    }

    pub(crate) fn pattern_cs(&self) -> Option<Self> {
        match self.0.as_ref() {
            ColorSpaceType::Pattern(cs) => Some(cs.clone()),
            _ => None,
        }
    }

    /// Return `true` if the current color space is the pattern color space.
    pub(crate) fn is_pattern(&self) -> bool {
        matches!(self.0.as_ref(), ColorSpaceType::Pattern(_))
    }

    /// Return `true` if the current color space is an indexed color space.
    pub(crate) fn is_indexed(&self) -> bool {
        matches!(self.0.as_ref(), ColorSpaceType::Indexed(_))
    }

    /// Get the default decode array for the color space.
    pub(crate) fn default_decode_arr(&self, n: f32) -> SmallVec<[(f32, f32); 4]> {
        match self.0.as_ref() {
            ColorSpaceType::DeviceCmyk => smallvec![(0.0, 1.0), (0.0, 1.0), (0.0, 1.0), (0.0, 1.0)],
            ColorSpaceType::DeviceGray => smallvec![(0.0, 1.0)],
            ColorSpaceType::DeviceRgb => smallvec![(0.0, 1.0), (0.0, 1.0), (0.0, 1.0)],
            ColorSpaceType::ICCBased(i) => smallvec![(0.0, 1.0); i.0.number_components],
            ColorSpaceType::CalGray(_) => smallvec![(0.0, 1.0)],
            ColorSpaceType::CalRgb(_) => smallvec![(0.0, 1.0), (0.0, 1.0), (0.0, 1.0)],
            ColorSpaceType::Lab(l) => smallvec![
                (0.0, 100.0),
                (l.range[0], l.range[1]),
                (l.range[2], l.range[3]),
            ],
            ColorSpaceType::Indexed(_) => smallvec![(0.0, 2.0_f32.powf(n) - 1.0)],
            ColorSpaceType::Separation(_) => smallvec![(0.0, 1.0)],
            ColorSpaceType::DeviceN(d) => smallvec![(0.0, 1.0); d.num_components as usize],
            // Not a valid image color space.
            ColorSpaceType::Pattern(_) => smallvec![(0.0, 1.0)],
        }
    }

    /// Get the initial color of the color space.
    pub(crate) fn initial_color(&self) -> ColorComponents {
        match self.0.as_ref() {
            ColorSpaceType::DeviceCmyk => smallvec![0.0, 0.0, 0.0, 1.0],
            ColorSpaceType::DeviceGray => smallvec![0.0],
            ColorSpaceType::DeviceRgb => smallvec![0.0, 0.0, 0.0],
            ColorSpaceType::ICCBased(icc) => match icc.0.number_components {
                1 => smallvec![0.0],
                3 => smallvec![0.0, 0.0, 0.0],
                4 => smallvec![0.0, 0.0, 0.0, 1.0],
                _ => unreachable!(),
            },
            ColorSpaceType::CalGray(_) => smallvec![0.0],
            ColorSpaceType::CalRgb(_) => smallvec![0.0, 0.0, 0.0],
            ColorSpaceType::Lab(_) => smallvec![0.0, 0.0, 0.0],
            ColorSpaceType::Indexed(_) => smallvec![0.0],
            ColorSpaceType::Separation(_) => smallvec![1.0],
            ColorSpaceType::Pattern(c) => c.initial_color(),
            ColorSpaceType::DeviceN(d) => smallvec![1.0; d.num_components as usize],
        }
    }

    /// Get the number of components of the color space.
    pub(crate) fn num_components(&self) -> u8 {
        match self.0.as_ref() {
            ColorSpaceType::DeviceCmyk => 4,
            ColorSpaceType::DeviceGray => 1,
            ColorSpaceType::DeviceRgb => 3,
            ColorSpaceType::ICCBased(icc) => icc.0.number_components as u8,
            ColorSpaceType::CalGray(_) => 1,
            ColorSpaceType::CalRgb(_) => 3,
            ColorSpaceType::Lab(_) => 3,
            ColorSpaceType::Indexed(_) => 1,
            ColorSpaceType::Separation(_) => 1,
            ColorSpaceType::Pattern(p) => p.num_components(),
            ColorSpaceType::DeviceN(d) => d.num_components,
        }
    }

    /// Turn the given component values and opacity into an RGBA color.
    pub fn to_rgba(&self, c: &[f32], opacity: f32, manual_scale: bool) -> AlphaColor {
        self.to_alpha_color(c, opacity, manual_scale)
            .unwrap_or(AlphaColor::BLACK)
    }
}

impl ToRgb for ColorSpace {
    fn convert_f32(&self, input: &[f32], output: &mut [u8], manual_scale: bool) -> Option<()> {
        match self.0.as_ref() {
            ColorSpaceType::DeviceCmyk => {
                // Use the CGATS001Compat ICC profile (same family as MuPDF's built-in DeviceCMYK
                // profile) to match MuPDF rendering.  The PDF spec §10.3.5 algebraic formula
                // R=1−min(1,C+K) hard-clamps to black for rich-black CMYK values (e.g.
                // C=0.72 K=0.66 → 0), while the ICC gives the perceptually correct dark
                // charcoal (~RGB 41,42,43) that MuPDF renders.
                if let Some(profile) = default_cmyk_profile() {
                    return profile.convert_f32(input, output, manual_scale);
                }
                // Fallback (profile load failed): PDF spec formula.
                for (input, output) in input.chunks_exact(4).zip(output.chunks_exact_mut(3)) {
                    let (c, m, y, k) = (input[0], input[1], input[2], input[3]);
                    output[0] = f32_to_u8(1.0 - (c + k).min(1.0));
                    output[1] = f32_to_u8(1.0 - (m + k).min(1.0));
                    output[2] = f32_to_u8(1.0 - (y + k).min(1.0));
                }
                Some(())
            }
            ColorSpaceType::DeviceGray => {
                let converted = input.iter().copied().map(f32_to_u8).collect::<Vec<_>>();

                for (input, output) in converted.iter().zip(output.chunks_exact_mut(3)) {
                    output.copy_from_slice(&[*input, *input, *input]);
                }

                Some(())
            }
            ColorSpaceType::DeviceRgb => {
                for (input, output) in input.iter().copied().zip(output) {
                    *output = f32_to_u8(input);
                }

                Some(())
            }
            ColorSpaceType::Pattern(i) => i.convert_f32(input, output, manual_scale),
            ColorSpaceType::Indexed(i) => i.convert_f32(input, output, manual_scale),
            ColorSpaceType::ICCBased(i) => i.convert_f32(input, output, manual_scale),
            ColorSpaceType::CalGray(i) => i.convert_f32(input, output, manual_scale),
            ColorSpaceType::CalRgb(i) => i.convert_f32(input, output, manual_scale),
            ColorSpaceType::Lab(i) => i.convert_f32(input, output, manual_scale),
            ColorSpaceType::Separation(i) => i.convert_f32(input, output, manual_scale),
            ColorSpaceType::DeviceN(i) => i.convert_f32(input, output, manual_scale),
        }
    }

    fn supports_u8(&self) -> bool {
        match self.0.as_ref() {
            ColorSpaceType::DeviceCmyk => true,
            ColorSpaceType::DeviceGray => true,
            ColorSpaceType::DeviceRgb => true,
            ColorSpaceType::Pattern(i) => i.supports_u8(),
            ColorSpaceType::Indexed(i) => i.supports_u8(),
            ColorSpaceType::ICCBased(i) => i.supports_u8(),
            ColorSpaceType::CalGray(i) => i.supports_u8(),
            ColorSpaceType::CalRgb(i) => i.supports_u8(),
            ColorSpaceType::Lab(i) => i.supports_u8(),
            ColorSpaceType::Separation(i) => i.supports_u8(),
            ColorSpaceType::DeviceN(i) => i.supports_u8(),
        }
    }

    fn convert_u8(&self, input: &[u8], output: &mut [u8]) -> Option<()> {
        match self.0.as_ref() {
            ColorSpaceType::DeviceCmyk => {
                // Use ICC profile (see convert_f32 comment above).
                if let Some(profile) = default_cmyk_profile() {
                    return profile.convert_u8(input, output);
                }
                // Fallback: PDF spec §10.3.5 formula (u8 domain).
                for (input, output) in input.chunks_exact(4).zip(output.chunks_exact_mut(3)) {
                    let (c, m, y, k) = (
                        input[0] as u16,
                        input[1] as u16,
                        input[2] as u16,
                        input[3] as u16,
                    );
                    output[0] = (255u16.saturating_sub(c + k)) as u8;
                    output[1] = (255u16.saturating_sub(m + k)) as u8;
                    output[2] = (255u16.saturating_sub(y + k)) as u8;
                }
                Some(())
            }
            ColorSpaceType::DeviceGray => {
                for (input, output) in input.iter().zip(output.chunks_exact_mut(3)) {
                    output.copy_from_slice(&[*input, *input, *input]);
                }

                Some(())
            }
            ColorSpaceType::DeviceRgb => {
                output.copy_from_slice(input);

                Some(())
            }
            ColorSpaceType::Pattern(i) => i.convert_u8(input, output),
            ColorSpaceType::Indexed(i) => i.convert_u8(input, output),
            ColorSpaceType::ICCBased(i) => i.convert_u8(input, output),
            ColorSpaceType::CalGray(i) => i.convert_u8(input, output),
            ColorSpaceType::CalRgb(i) => i.convert_u8(input, output),
            ColorSpaceType::Lab(i) => i.convert_u8(input, output),
            ColorSpaceType::Separation(i) => i.convert_u8(input, output),
            ColorSpaceType::DeviceN(i) => i.convert_u8(input, output),
        }
    }

    fn is_none(&self) -> bool {
        match self.0.as_ref() {
            ColorSpaceType::Separation(s) => s.is_none(),
            ColorSpaceType::DeviceN(d) => d.is_none(),
            _ => false,
        }
    }
}

#[derive(Debug, Clone)]
pub(crate) struct CalGray {
    white_point: [f32; 3],
    black_point: [f32; 3],
    gamma: f32,
}

// See <https://github.com/mozilla/pdf.js/blob/06f44916c8936b92f464d337fe3a0a6b2b78d5b4/src/core/colorspace.js#L752>
impl CalGray {
    fn new(dict: &Dict<'_>) -> Option<Self> {
        let white_point = dict.get::<[f32; 3]>(WHITE_POINT).unwrap_or([1.0, 1.0, 1.0]);
        let black_point = dict.get::<[f32; 3]>(BLACK_POINT).unwrap_or([0.0, 0.0, 0.0]);
        let gamma = dict.get::<f32>(GAMMA).unwrap_or(1.0);

        Some(Self {
            white_point,
            black_point,
            gamma,
        })
    }
}

impl ToRgb for CalGray {
    fn convert_f32(&self, input: &[f32], output: &mut [u8], _: bool) -> Option<()> {
        for (input, output) in input.iter().copied().zip(output.chunks_exact_mut(3)) {
            let g = self.gamma;
            let (_xw, yw, _zw) = {
                let wp = self.white_point;
                (wp[0], wp[1], wp[2])
            };
            let (_xb, _yb, _zb) = {
                let bp = self.black_point;
                (bp[0], bp[1], bp[2])
            };

            let a = input;
            let ag = a.powf(g);
            let l = yw * ag;
            let val = (0.0_f32.max(295.8 * l.powf(0.333_333_34) - 40.8) + 0.5) as u8;

            output.copy_from_slice(&[val, val, val]);
        }

        Some(())
    }
}

#[derive(Debug, Clone)]
pub(crate) struct CalRgb {
    white_point: [f32; 3],
    black_point: [f32; 3],
    matrix: [f32; 9],
    gamma: [f32; 3],
}

// See <https://github.com/mozilla/pdf.js/blob/06f44916c8936b92f464d337fe3a0a6b2b78d5b4/src/core/colorspace.js#L846>
// Completely copied from there without really understanding the logic, but we get the same results as Firefox
// which should be good enough (and by viewing the `calrgb.pdf` test file in different viewers you will
// see that in many cases each viewer does whatever it wants, even Acrobat), so this is good enough for us.
impl CalRgb {
    fn new(dict: &Dict<'_>) -> Option<Self> {
        let white_point = dict.get::<[f32; 3]>(WHITE_POINT).unwrap_or([1.0, 1.0, 1.0]);
        let black_point = dict.get::<[f32; 3]>(BLACK_POINT).unwrap_or([0.0, 0.0, 0.0]);
        let matrix = dict
            .get::<[f32; 9]>(MATRIX)
            .unwrap_or([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]);
        let gamma = dict.get::<[f32; 3]>(GAMMA).unwrap_or([1.0, 1.0, 1.0]);

        Some(Self {
            white_point,
            black_point,
            matrix,
            gamma,
        })
    }

    const BRADFORD_SCALE_MATRIX: [f32; 9] = [
        0.8951, 0.2664, -0.1614, -0.7502, 1.7135, 0.0367, 0.0389, -0.0685, 1.0296,
    ];

    const BRADFORD_SCALE_INVERSE_MATRIX: [f32; 9] = [
        0.9869929, -0.1470543, 0.1599627, 0.4323053, 0.5183603, 0.0492912, -0.0085287, 0.0400428,
        0.9684867,
    ];

    const SRGB_D65_XYZ_TO_RGB_MATRIX: [f32; 9] = [
        3.2404542, -1.5371385, -0.4985314, -0.969_266, 1.8760108, 0.0415560, 0.0556434, -0.2040259,
        1.0572252,
    ];

    const FLAT_WHITEPOINT: [f32; 3] = [1.0, 1.0, 1.0];
    const D65_WHITEPOINT: [f32; 3] = [0.95047, 1.0, 1.08883];

    fn decode_l_constant() -> f32 {
        ((8.0_f32 + 16.0) / 116.0).powi(3) / 8.0
    }

    fn srgb_transfer_function(color: f32) -> f32 {
        if color <= 0.0031308 {
            (12.92 * color).clamp(0.0, 1.0)
        } else if color >= 0.99554525 {
            1.0
        } else {
            ((1.0 + 0.055) * color.powf(1.0 / 2.4) - 0.055).clamp(0.0, 1.0)
        }
    }

    fn matrix_product(a: &[f32; 9], b: &[f32; 3]) -> [f32; 3] {
        [
            a[0] * b[0] + a[1] * b[1] + a[2] * b[2],
            a[3] * b[0] + a[4] * b[1] + a[5] * b[2],
            a[6] * b[0] + a[7] * b[1] + a[8] * b[2],
        ]
    }

    fn to_flat(source_white_point: &[f32; 3], lms: &[f32; 3]) -> [f32; 3] {
        [
            lms[0] / source_white_point[0],
            lms[1] / source_white_point[1],
            lms[2] / source_white_point[2],
        ]
    }

    fn to_d65(source_white_point: &[f32; 3], lms: &[f32; 3]) -> [f32; 3] {
        [
            lms[0] * Self::D65_WHITEPOINT[0] / source_white_point[0],
            lms[1] * Self::D65_WHITEPOINT[1] / source_white_point[1],
            lms[2] * Self::D65_WHITEPOINT[2] / source_white_point[2],
        ]
    }

    fn decode_l(l: f32) -> f32 {
        if l < 0.0 {
            -Self::decode_l(-l)
        } else if l > 8.0 {
            ((l + 16.0) / 116.0).powi(3)
        } else {
            l * Self::decode_l_constant()
        }
    }

    fn compensate_black_point(source_bp: &[f32; 3], xyz_flat: &[f32; 3]) -> [f32; 3] {
        if source_bp == &[0.0, 0.0, 0.0] {
            return *xyz_flat;
        }

        let zero_decode_l = Self::decode_l(0.0);

        let mut out = [0.0; 3];
        for i in 0..3 {
            let src = Self::decode_l(source_bp[i]);
            let scale = (1.0 - zero_decode_l) / (1.0 - src);
            let offset = 1.0 - scale;
            out[i] = xyz_flat[i] * scale + offset;
        }

        out
    }

    fn normalize_white_point_to_flat(
        &self,
        source_white_point: &[f32; 3],
        xyz: &[f32; 3],
    ) -> [f32; 3] {
        if source_white_point[0] == 1.0 && source_white_point[2] == 1.0 {
            return *xyz;
        }
        let lms = Self::matrix_product(&Self::BRADFORD_SCALE_MATRIX, xyz);
        let lms_flat = Self::to_flat(source_white_point, &lms);
        Self::matrix_product(&Self::BRADFORD_SCALE_INVERSE_MATRIX, &lms_flat)
    }

    fn normalize_white_point_to_d65(
        &self,
        source_white_point: &[f32; 3],
        xyz: &[f32; 3],
    ) -> [f32; 3] {
        let lms = Self::matrix_product(&Self::BRADFORD_SCALE_MATRIX, xyz);
        let lms_d65 = Self::to_d65(source_white_point, &lms);
        Self::matrix_product(&Self::BRADFORD_SCALE_INVERSE_MATRIX, &lms_d65)
    }
}

impl ToRgb for CalRgb {
    fn convert_f32(&self, input: &[f32], output: &mut [u8], _: bool) -> Option<()> {
        for (input, output) in input.chunks_exact(3).zip(output.chunks_exact_mut(3)) {
            let input = [
                input[0].clamp(0.0, 1.0),
                input[1].clamp(0.0, 1.0),
                input[2].clamp(0.0, 1.0),
            ];

            let [r, g, b] = input;
            let [gr, gg, gb] = self.gamma;
            let [agr, bgg, cgb] = [
                if r == 1.0 { 1.0 } else { r.powf(gr) },
                if g == 1.0 { 1.0 } else { g.powf(gg) },
                if b == 1.0 { 1.0 } else { b.powf(gb) },
            ];

            let m = &self.matrix;
            let x = m[0] * agr + m[3] * bgg + m[6] * cgb;
            let y = m[1] * agr + m[4] * bgg + m[7] * cgb;
            let z = m[2] * agr + m[5] * bgg + m[8] * cgb;
            let xyz = [x, y, z];

            let xyz_flat = self.normalize_white_point_to_flat(&self.white_point, &xyz);
            let xyz_black = Self::compensate_black_point(&self.black_point, &xyz_flat);
            let xyz_d65 = self.normalize_white_point_to_d65(&Self::FLAT_WHITEPOINT, &xyz_black);
            let srgb_xyz = Self::matrix_product(&Self::SRGB_D65_XYZ_TO_RGB_MATRIX, &xyz_d65);

            output.copy_from_slice(&[
                (Self::srgb_transfer_function(srgb_xyz[0]) * 255.0 + 0.5) as u8,
                (Self::srgb_transfer_function(srgb_xyz[1]) * 255.0 + 0.5) as u8,
                (Self::srgb_transfer_function(srgb_xyz[2]) * 255.0 + 0.5) as u8,
            ]);
        }

        Some(())
    }
}

#[derive(Debug, Clone)]
pub(crate) struct Lab {
    range: [f32; 4],
    profile: ICCProfile,
}

impl Lab {
    fn new(dict: &Dict<'_>) -> Option<Self> {
        let white_point = dict.get::<[f32; 3]>(WHITE_POINT).unwrap_or([1.0, 1.0, 1.0]);
        // Not sure how this should be used.
        let _black_point = dict.get::<[f32; 3]>(BLACK_POINT).unwrap_or([0.0, 0.0, 0.0]);
        let range = dict
            .get::<[f32; 4]>(RANGE)
            .unwrap_or([-100.0, 100.0, -100.0, 100.0]);

        let mut profile = ColorProfile::new_from_slice(include_bytes!("../assets/LAB.icc")).ok()?;
        profile.white_point = Xyzd::new(
            white_point[0] as f64,
            white_point[1] as f64,
            white_point[2] as f64,
        );

        let profile = ICCProfile::new_from_src_profile(
            profile, false,
            // This flag is only used to scale the values to [0.0, 1.0], but
            // we already take care of this in the `convert_f32` method.
            // Therefore, leave this as false, even though this is a LAB profile.
            false, 3,
        )?;

        Some(Self { range, profile })
    }
}

impl ToRgb for Lab {
    fn convert_f32(&self, input: &[f32], output: &mut [u8], manual_scale: bool) -> Option<()> {
        if !manual_scale {
            // moxcms expects values between 0.0 and 1.0, so we need to undo
            // the scaling.

            let input = input
                .chunks_exact(3)
                .flat_map(|i| {
                    let l = i[0] / 100.0;
                    let a = (i[1] + 128.0) / 255.0;
                    let b = (i[2] + 128.0) / 255.0;

                    [l, a, b]
                })
                .collect::<Vec<_>>();

            self.profile.convert_f32(&input, output, manual_scale)
        } else {
            self.profile.convert_f32(input, output, manual_scale)
        }
    }
}

#[derive(Debug, Clone)]
pub(crate) struct Indexed {
    values: Vec<Vec<f32>>,
    hival: u8,
    base: Box<ColorSpace>,
}

impl Indexed {
    fn new(array: &Array<'_>, cache: &Cache, warning_sink: &WarningSinkFn) -> Option<Self> {
        let mut iter = array.flex_iter();
        // Skip name
        let _ = iter.next::<Name>()?;
        let base_color_space = ColorSpace::new(iter.next::<Object<'_>>()?, cache, warning_sink)?;
        let hival = iter.next::<u8>()?;

        let values = {
            let data = iter
                .next::<Stream<'_>>()
                .and_then(|s| decode_or_warn(&s, warning_sink))
                .or_else(|| iter.next::<object::String>().map(|s| s.to_vec()))?;

            let num_components = base_color_space.num_components();

            let mut byte_iter = data.iter().copied();

            let mut vals = vec![];
            for _ in 0..=hival {
                let mut temp = vec![];

                for _ in 0..num_components {
                    temp.push(byte_iter.next()? as f32 / 255.0);
                }

                vals.push(temp);
            }

            vals
        };

        Some(Self {
            values,
            hival,
            base: Box::new(base_color_space),
        })
    }
}

impl ToRgb for Indexed {
    fn convert_f32(&self, input: &[f32], output: &mut [u8], _: bool) -> Option<()> {
        let mut indexed = vec![0.0; input.len() * self.base.num_components() as usize];

        for (input, output) in input
            .iter()
            .copied()
            .zip(indexed.chunks_exact_mut(self.base.num_components() as usize))
        {
            let idx = (input.clamp(0.0, self.hival as f32) + 0.5) as usize;
            output.copy_from_slice(&self.values[idx]);
        }

        self.base.convert_f32(&indexed, output, true)
    }
}

#[derive(Debug, Clone)]
pub(crate) struct Separation {
    alternate_space: ColorSpace,
    tint_transform: Function,
    is_none_separation: bool,
}

impl Separation {
    fn new(array: &Array<'_>, cache: &Cache, warning_sink: &WarningSinkFn) -> Option<Self> {
        let mut iter = array.flex_iter();
        // Skip `/Separation`
        let _ = iter.next::<Name>()?;
        let name = iter.next::<Name>()?;
        let alternate_space = ColorSpace::new(iter.next::<Object<'_>>()?, cache, warning_sink)?;
        let tint_transform = Function::new(&iter.next::<Object<'_>>()?)?;
        // PDF spec §8.6.6.4: the special colourant name "None" means no ink —
        // painting in this colour space has no effect on the page.
        // Only the literal string "None" triggers this; named inks such as
        // "PANTONE 123 CVC" or "All" are regular colourants and must NOT be
        // suppressed here.  ("All" is left to the tint transform; other viewers
        // treat it as a pass-through as well.)
        let is_none_separation = name.as_str() == "None";

        Some(Self {
            alternate_space,
            tint_transform,
            is_none_separation,
        })
    }
}

impl ToRgb for Separation {
    fn convert_f32(&self, input: &[f32], output: &mut [u8], _: bool) -> Option<()> {
        let evaluated = input
            .iter()
            .flat_map(|n| {
                self.tint_transform
                    .eval(smallvec![*n])
                    .unwrap_or(self.alternate_space.initial_color())
            })
            .collect::<Vec<_>>();
        self.alternate_space.convert_f32(&evaluated, output, false)
    }

    fn is_none(&self) -> bool {
        self.is_none_separation
    }
}

#[derive(Debug, Clone)]
pub(crate) struct DeviceN {
    alternate_space: ColorSpace,
    num_components: u8,
    tint_transform: Function,
    is_none: bool,
}

impl DeviceN {
    fn new(array: &Array<'_>, cache: &Cache, warning_sink: &WarningSinkFn) -> Option<Self> {
        let mut iter = array.flex_iter();
        // Skip `/DeviceN`
        let _ = iter.next::<Name>()?;
        let names = iter.next::<Array<'_>>()?.iter::<Name>().collect::<Vec<_>>();
        let num_components = u8::try_from(names.len()).ok()?;
        // PDF spec §8.6.6.5: suppress paint only when every component is named
        // "None".  A DeviceN that mixes real inks with "None" (e.g. a spot
        // colour channel alongside a filler "None" channel) must still paint,
        // because the tint transform maps all components to the alternate space
        // simultaneously.
        let all_none = names.iter().all(|n| n.as_str() == "None");
        let alternate_space = ColorSpace::new(iter.next::<Object<'_>>()?, cache, warning_sink)?;
        let tint_transform = Function::new(&iter.next::<Object<'_>>()?)?;

        if num_components == 0 {
            return None;
        }

        Some(Self {
            alternate_space,
            num_components,
            tint_transform,
            is_none: all_none,
        })
    }
}

impl ToRgb for DeviceN {
    fn convert_f32(&self, input: &[f32], output: &mut [u8], _: bool) -> Option<()> {
        let evaluated = input
            .chunks_exact(self.num_components as usize)
            .flat_map(|n| {
                self.tint_transform
                    .eval(n.to_smallvec())
                    .unwrap_or(self.alternate_space.initial_color())
            })
            .collect::<Vec<_>>();
        self.alternate_space.convert_f32(&evaluated, output, false)
    }

    fn is_none(&self) -> bool {
        self.is_none
    }
}

struct ICCColorRepr {
    transform_u8: Box<Transform8BitExecutor>,
    transform_f32: Box<TransformF32BitExecutor>,
    number_components: usize,
    is_srgb: bool,
    is_lab: bool,
}

#[derive(Clone)]
pub(crate) struct ICCProfile(Arc<ICCColorRepr>);

impl Debug for ICCProfile {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        write!(f, "ICCColor {{..}}")
    }
}

impl ICCProfile {
    fn new(profile: &[u8], number_components: usize) -> Option<Self> {
        let src_profile = ColorProfile::new_from_slice(profile).ok()?;

        // Determine the component count declared by the embedded ICC profile.
        // Producers occasionally emit /N=3 together with a CMYK profile (or
        // vice versa), which would otherwise cause moxcms to interpret the
        // pixel bytes through a wrong layout and silently return garbage
        // colours. Trust the profile's own color-space marker and fall back
        // gracefully when the /N count disagrees. Cherry-picked surgically
        // from reverted #920 overhaul (commit 23b7007ba).
        let profile_components = match src_profile.color_space {
            DataColorSpace::Gray => 1,
            DataColorSpace::Rgb
            | DataColorSpace::Lab
            | DataColorSpace::Luv
            | DataColorSpace::Xyz
            | DataColorSpace::YCbr
            | DataColorSpace::Yxy
            | DataColorSpace::Hsv
            | DataColorSpace::Hls
            | DataColorSpace::Cmy
            | DataColorSpace::Color3 => 3,
            DataColorSpace::Cmyk | DataColorSpace::Color4 => 4,
            _ => {
                warn!(
                    "unsupported ICC profile color space {:?}",
                    src_profile.color_space
                );
                return None;
            }
        };

        if number_components != profile_components {
            warn!(
                "ICCBased /N={} does not match embedded ICC profile component count {}; using profile",
                number_components, profile_components
            );
        }

        const SRGB_MARKER: &[u8] = b"sRGB";

        let is_srgb = profile
            .get(52..56)
            .map(|device_model| device_model == SRGB_MARKER)
            .unwrap_or(false);
        let is_lab = src_profile.color_space == DataColorSpace::Lab;

        Self::new_from_src_profile(src_profile, is_srgb, is_lab, profile_components)
    }

    fn new_from_src_profile(
        src_profile: ColorProfile,
        is_srgb: bool,
        is_lab: bool,
        number_components: usize,
    ) -> Option<Self> {
        let dest_profile = ColorProfile::new_srgb();

        // PDF spec §8.6.5.5 (GL-QA41): ICCBased colorspaces with N=4 components
        // are CMYK profiles. moxcms uses Layout::Rgba as its 4-channel layout
        // (documented: "Cmyk8 uses the same layout as Rgba8"), so Layout::Rgba
        // is the correct choice for 4-component (CMYK) ICC source profiles.
        // The full ICC transform is applied — no naive CMYK formula fallback —
        // which correctly handles embedded ICC CMYK profiles that override the
        // default DeviceCMYK-to-sRGB conversion.
        let src_layout = match number_components {
            1 => Layout::Gray,
            3 => Layout::Rgb,
            4 => Layout::Rgba, // 4-channel CMYK; moxcms Rgba layout == CMYK layout
            _ => {
                warn!("unsupported number of components {number_components} for ICC profile");

                return None;
            }
        };

        // PDF spec §8.6.5.8: the default rendering intent is RelativeColorimetric.
        // moxcms defaults to Perceptual, which compresses in-gamut colors and
        // produces subtle hue shifts on CMYK→sRGB conversions. Prefer
        // RelativeColorimetric to match the PDF spec default and the behaviour
        // of Acrobat/MuPDF.
        //
        // BUT: many bundled/embedded ICC profiles — including our own built-in
        // CGATS001Compat-v2-micro CMYK profile — ship only a Perceptual A2B
        // LUT. moxcms returns UnsupportedLutRenderingIntent in that case,
        // which silently poisoned the entire CMYK path (profile load returns
        // None → default_cmyk_profile() returns None → DeviceCMYK falls back
        // to the PDF §10.3.5 algebraic formula, which hard-clamps rich-black
        // CMYK to zero and produces near-black output where Acrobat/PDFium
        // render colour correctly). Fall back from RelativeColorimetric to
        // Perceptual to Saturation on missing-LUT so the profile still works.
        // (#1002)
        let intents_to_try = [
            RenderingIntent::RelativeColorimetric,
            RenderingIntent::Perceptual,
            RenderingIntent::Saturation,
        ];
        let mut u8_transform = None;
        let mut f32_transform = None;
        for intent in intents_to_try {
            let options = TransformOptions {
                rendering_intent: intent,
                ..TransformOptions::default()
            };
            let u8_ok = src_profile
                .create_transform_8bit(src_layout, &dest_profile, Layout::Rgb, options)
                .ok();
            let f32_ok = src_profile
                .create_transform_f32(src_layout, &dest_profile, Layout::Rgb, options)
                .ok();
            if let (Some(u), Some(f)) = (u8_ok, f32_ok) {
                u8_transform = Some(u);
                f32_transform = Some(f);
                break;
            }
        }
        let u8_transform = u8_transform?;
        let f32_transform = f32_transform?;

        Some(Self(Arc::new(ICCColorRepr {
            transform_u8: u8_transform,
            transform_f32: f32_transform,
            number_components,
            is_srgb,
            is_lab,
        })))
    }

    fn is_srgb(&self) -> bool {
        self.0.is_srgb
    }

    fn is_lab(&self) -> bool {
        self.0.is_lab
    }
}

impl ToRgb for ICCProfile {
    fn convert_f32(&self, input: &[f32], output: &mut [u8], _: bool) -> Option<()> {
        let mut temp = vec![0.0_f32; output.len()];

        if self.is_lab() {
            // moxcms expects normalized values.
            let scaled = input
                .chunks_exact(3)
                .flat_map(|i| {
                    [
                        i[0] * (1.0 / 100.0),
                        (i[1] + 128.0) * (1.0 / 255.0),
                        (i[2] + 128.0) * (1.0 / 255.0),
                    ]
                })
                .collect::<Vec<_>>();
            self.0.transform_f32.transform(&scaled, &mut temp).ok()?;
        } else {
            self.0.transform_f32.transform(input, &mut temp).ok()?;
        };

        for (input, output) in temp.iter().zip(output.iter_mut()) {
            *output = (input * 255.0 + 0.5) as u8;
        }

        Some(())
    }

    fn supports_u8(&self) -> bool {
        true
    }

    fn convert_u8(&self, input: &[u8], output: &mut [u8]) -> Option<()> {
        // sRGB fast path: only skip the transform when input and output
        // buffers have matching lengths. Otherwise copy_from_slice would
        // panic — callers can still legitimately pass mismatched slices
        // (e.g. grayscale → RGB triple-up) and we must fall through to the
        // moxcms transform in that case. Cherry-picked surgically from
        // reverted #920 overhaul (commit 23b7007ba).
        if self.is_srgb() && input.len() == output.len() {
            output.copy_from_slice(input);
        } else {
            self.0.transform_u8.transform(input, output).ok()?;
        }

        Some(())
    }
}

#[inline(always)]
fn f32_to_u8(val: f32) -> u8 {
    (val * 255.0 + 0.5) as u8
}

#[derive(Debug, Clone)]
/// A color.
pub struct Color {
    color_space: ColorSpace,
    components: ColorComponents,
    opacity: f32,
}

impl Color {
    pub(crate) fn new(color_space: ColorSpace, components: ColorComponents, opacity: f32) -> Self {
        Self {
            color_space,
            components,
            opacity,
        }
    }

    /// Return the color as an RGBA color.
    pub fn to_rgba(&self) -> AlphaColor {
        self.color_space
            .to_rgba(&self.components, self.opacity, false)
    }

    /// Create a color from RGBA.
    pub fn from_rgba(rgba: AlphaColor) -> Self {
        let c = rgba.components();
        Self {
            color_space: ColorSpace::device_rgb(),
            components: smallvec![c[0], c[1], c[2]],
            opacity: c[3],
        }
    }

    /// Create a color in the DeviceRGB color space.
    pub fn from_device_rgb(r: f32, g: f32, b: f32) -> Self {
        Self {
            color_space: ColorSpace::device_rgb(),
            components: smallvec![r, g, b],
            opacity: 1.0,
        }
    }

    /// Create a color in the DeviceRGB color space with custom opacity.
    pub fn from_device_rgb_with_opacity(r: f32, g: f32, b: f32, opacity: f32) -> Self {
        Self {
            color_space: ColorSpace::device_rgb(),
            components: smallvec![r, g, b],
            opacity,
        }
    }

    /// Returns the opacity of this color (0.0 = fully transparent, 1.0 = fully opaque).
    pub fn opacity(&self) -> f32 {
        self.opacity
    }

    /// Return `true` if this color is expressed in the DeviceCMYK color space.
    pub fn is_device_cmyk(&self) -> bool {
        self.color_space.is_device_cmyk()
    }

    /// Return the raw DeviceCMYK components, if this color is DeviceCMYK.
    pub fn device_cmyk_components(&self) -> Option<[f32; 4]> {
        if !self.color_space.is_device_cmyk() || self.components.len() != 4 {
            return None;
        }

        Some([
            self.components[0],
            self.components[1],
            self.components[2],
            self.components[3],
        ])
    }
}

pub(crate) trait ToRgb {
    fn convert_f32(&self, input: &[f32], output: &mut [u8], manual_scale: bool) -> Option<()>;
    fn supports_u8(&self) -> bool {
        false
    }
    fn convert_u8(&self, _: &[u8], _: &mut [u8]) -> Option<()> {
        unimplemented!();
    }
    fn is_none(&self) -> bool {
        false
    }
    fn to_alpha_color(
        &self,
        input: &[f32],
        mut opacity: f32,
        manual_scale: bool,
    ) -> Option<AlphaColor> {
        let mut output = [0; 3];
        self.convert_f32(input, &mut output, manual_scale)?;

        // For separation color spaces:
        // "The special colourant name None shall not produce any visible output.
        // Painting operations in a Separation space with this colourant name
        // shall have no effect on the current page."
        if self.is_none() {
            opacity = 0.0;
        }

        Some(AlphaColor::from_rgba8(
            output[0],
            output[1],
            output[2],
            (opacity * 255.0 + 0.5) as u8,
        ))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use pdf_syntax::object::{Array, FromBytes};

    /// Minimal PDF bytes for a Separation colorspace array:
    ///   [/Separation <name> /DeviceGray <tint-function>]
    /// The tint function is a Type 2 exponential mapping 1 input → 1 output.
    fn separation_array(ink_name: &str) -> Vec<u8> {
        format!(
            "[/Separation /{ink_name} /DeviceGray \
             << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >> ]",
            ink_name = ink_name
        )
        .into_bytes()
    }

    fn make_separation(ink_name: &str) -> Option<Separation> {
        let bytes = separation_array(ink_name);
        let array = Array::from_bytes(&bytes)?;
        let cache = Cache::new();
        {
            let sink: WarningSinkFn = std::sync::Arc::new(|_: crate::InterpreterWarning| {});
            Separation::new(&array, &cache, &sink)
        }
    }

    /// PDF spec §8.6.6.4: only the literal name "None" suppresses paint.
    #[test]
    fn none_ink_is_suppressed() {
        let sep = make_separation("None").expect("should parse");
        assert!(sep.is_none(), "ink name 'None' must be suppressed");
    }

    /// PANTONE spot colours must NOT be suppressed — they are real inks.
    #[test]
    fn pantone_ink_is_not_suppressed() {
        // PDF name encoding: spaces become #20
        let sep = make_separation("PANTONE#20123#20CVC").expect("should parse");
        assert!(!sep.is_none(), "PANTONE spot colour must not be suppressed");
    }

    /// "All" is a special name meaning every device colourant, not silence.
    #[test]
    fn all_ink_is_not_suppressed() {
        let sep = make_separation("All").expect("should parse");
        assert!(!sep.is_none(), "'All' separation must not be suppressed");
    }

    /// A non-None Separation must produce visible output (opacity > 0).
    #[test]
    fn pantone_produces_visible_color() {
        let sep = make_separation("PANTONE#20123#20CVC").expect("should parse");
        let cs = ColorSpace(Arc::new(ColorSpaceType::Separation(sep)));
        // tint = 1.0 (full ink) → tint transform maps to grayscale 0 (black)
        let color = cs.to_rgba(&[1.0], 1.0, false);
        assert!(
            color.to_rgba8()[3] > 0,
            "PANTONE ink at tint=1.0 must be opaque"
        );
    }

    /// The "None" separation must produce a fully transparent pixel.
    #[test]
    fn none_produces_transparent_color() {
        let sep = make_separation("None").expect("should parse");
        let cs = ColorSpace(Arc::new(ColorSpaceType::Separation(sep)));
        let color = cs.to_rgba(&[1.0], 1.0, false);
        assert_eq!(
            color.to_rgba8()[3],
            0,
            "Separation/None ink must be fully transparent"
        );
    }
}
