#!/usr/bin/env python3

# Copyright © 2014  Mattias Andrée (maandree@member.fsf.org)
# 
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# 
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.
# 
# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

# This module implements support for ICC profiles

import os
from subprocess import Popen, PIPE

from curve import *



LIBEXECDIR = 'bin'
'''
:str  Path to executable libraries, '/usr/libexec' is standard
'''



def load_icc(pathname):
    '''
    Load ICC profile from a file
    
    @param   pathname:str  The ICC profile file
    @return  :()→void      Function to invoke, parameterless, to apply the ICC profile to the colour curves
    '''
    with open(pathname, 'rb') as file:
        return parse_icc(file.read())


def get_current_icc(display = None):
    '''
    Get all currently applied ICC profiles as profile applying functions
    
    @param   display:str?                                      The display to use, `None` for the current one
    @return  list<(screen:int, monitor:int, profile:()→void)>  List of used profiles
    '''
    return [(screen, monitor, parse_icc(profile)) for screen, monitor, profile in get_current_icc_raw(display)]


def get_current_icc_raw(display = None):
    '''
    Get all currently applied ICC profiles as raw profile data
    
    @param   display:str?                                      The display to use, `None` for the current one
    @return  list<(screen:int, monitor:int, profile:bytes())>  List of used profiles
    '''
    # Generate command line arguments to execute
    command = [LIBEXECDIR + os.sep + 'blueshift_iccprofile']
    if display is not None:
        command.append(display)
    # Spawn the libexec blueshift_iccprofile
    process = Popen(command, stdout = PIPE)
    # Wait for the child process to exit and gather its output to stdout
    lines = process.communicate()[0].decode('utf-8', 'error').split('\n')
    # Ensure the tha process has exited
    while process.returncode is None:
        process.wait()
    # Throw exception if the child process failed
    if process.returncode != 0:
        raise Exception('blueshift_iccprofile exited with value %i' % process.returncode)
    rc = []
    # Get the screen, output and profile for each monitor with an _ICC_PROFILE(_n) atom set
    for s, m, p in [line.split(': ') for line in lines if not line == '']:
        # Convert the program from hexadecminal encoding to raw octet encoding
        p = bytes([int(p[i : i + 2], 16) for i in range(0, len(p), 2)])
        # List the profile
        rc.append((int(s), int(m), p))
    return rc


def parse_icc(content):
    '''
    Parse ICC profile from raw data
    
    @param   content:bytes  The ICC profile data
    @return  :()→void       Function to invoke, parameterless, to apply the ICC profile to the colour curves
    '''
    # Magic number for dual-byte precision lookup table based profiles
    MLUT_TAG = 0x6d4c5554
    # Magic number for gamma–brightness–contrast based profiles
    # and for variable precision lookup table profiles
    VCGT_TAG = 0x76636774
    
    def fcurve(R_curve, G_curve, B_curve):
        '''
        Apply an ICC profile mapping
        
        @param  R_curve:list<float>  Lookup table for the red channel
        @param  G_curve:list<float>  Lookup table for the green channel
        @param  B_curve:list<float>  Lookup table for the blue channel
        '''
        for curve, icc in curves(R_curve, G_curve, B_curve):
            for i in range(i_size):
                # Nearest neighbour
                y = int(curve[i] * (len(icc) - 1) + 0.5)
                # Trunction to actual neighbour
                y = min(max(0, y), len(icc) - 1)
                # Apply mapping
                curve[i] = icc[y]
    
    # Integers in ICC profiles are encoded with the most significant byte first
    int_ = lambda bs : sum([v << (i * 8) for i, v in enumerate(reversed(bs))])
    
    def read(n):
        '''
        Read a set of bytes for the encoded ICC profile
        
        @param   n:int       The number of bytes to read
        @return  :list<int>  The next `n` bytes of the profile
        '''
        if len(content) < n:
            raise Exception('Premature end of file: %s' % pathname)
        rc, content[:] = content[:n], content[n:]
        return rc
    
    # Convert profile encoding format for bytes to integer list
    content = list(content)
    # Skip the first 128 bytes
    read(128)
    # Get the number of bytes
    n_tags, ptr = int_(read(4)), 128 + 4
    # Create array for the lookup tables to creat
    R_curve, G_curve, B_curve = [], [], []
    
    for i_tag in range(n_tags):
        # Get profile encoding type, offset to the profile and the encoding size of its data
        (tag_name, tag_offset, tag_size), ptr = [int_(read(4)) for _ in range(3)], ptr + 3 * 4
        # XXX should I not jump to the data now instead of inside the if statements' bodies?
        if tag_name == MLUT_TAG:
            ## The profile is encododed as an dual-byte precision lookup table
            # Jump to the profile data
            read(tag_offset - ptr)
            # Get the lookup table for the red channel,
            for _ in range(256):  R_curve.append(int_(read(2)) / 65535)
            # for the green channel
            for _ in range(256):  G_curve.append(int_(read(2)) / 65535)
            # and for the blue channel.
            for _ in range(256):  B_curve.append(int_(read(2)) / 65535)
            return lambda : fcurve(R_curve, G_curve, B_curve)
        elif tag_name == VCGT_TAG:
            ## The profile is encoded as with gamma, brightness and contrast values
            # or as a variable precision lookup table profile
            # Jump to the profile data
            read(tag_offset - ptr)
            # VCGT profiles starts where their magic number
            tag_name = int_(read(4))
            if not tag_name == VCGT_TAG:
                break
            # Skip four bytes
            read(4)
            # and get the actual encoding type
            gamma_type = int_(read(4))
            if gamma_type == 0:
                ## The profile is encoded as a variable precision lookup table
                (n_channels, n_entries, entry_size) = [int_(read(2)) for _ in range(3)]
                if tag_size == 1584:
                    (n_channels, n_entries, entry_size) = 3, 256, 2
                if not n_channels == 3:
                    # Assuming sRGB, can only be an correct assumption if there are exactly three channels
                    break
                # Calculate the divisor for mapping to [0, 1]
                divisor = (256 ** entry_size) - 1
                # Values are encoded in integer form with `entry_size` bytes
                int__ = lambda : int_(read(entry_size)) / divisor
                # Get the lookup table for the red channel,
                for _ in range(n_entries):  R_curve.append(int__())
                # for the green channel
                for _ in range(n_entries):  G_curve.append(int__())
                # and for the blue channel.
                for _ in range(n_entries):  B_curve.append(int__())
                return lambda : fcurve(R_curve, G_curve, B_curve)
            elif gamma_type == 1:
                ## The profile is encoded with gamma, brightness and contrast values
                # Get the gamma, brightness and contrast for the red channel,
                (r_gamma, r_min, r_max) = [int_(read(4)) / 65535 for _ in range(3)]
                # green channel
                (g_gamma, g_min, g_max) = [int_(read(4)) / 65535 for _ in range(3)]
                # and blue channel.
                (b_gamma, b_min, b_max) = [int_(read(4)) / 65535 for _ in range(3)]
                def f():
                    '''
                    Apply the gamma, brightness and contrast
                    '''
                    # Apply gamma
                    gamma(r_gamma, g_gamma, b_gamma)
                    # before brightness and contrast
                    rgb_limits(r_min, r_max, g_min, g_max, b_min, b_max)
                return f
            break
        # XXX should I not jump to (tag_offset + tag_size - ptr) here
        #     and not break the loops when unkown?
    
    raise Exception('Unsupported ICC profile file')


def make_icc_interpolation(profiles):
    '''
    An interpolation function for ICC profiles
    
    @param   profiles:list<()→void>                  Profile applying functions
    @return  :(timepoint:float, alpha:float)→void()  A function that applies an interpolation of the profiles,
                                                     it takes to arguments: the timepoint and the filter
                                                     alpha. The timepoint is normally a [0, 1] floating point
                                                     of the dayness level, this means that you only have two
                                                     ICC profiles, but you have multiple profiles, in such
                                                     case profile #⌊timepoint⌋ and profile #(⌊timepoint⌋ + 1)
                                                     (modulus the number of profiles) are interpolated with
                                                     (timepoint - ⌊timepoint⌋) weight to the second profile.
                                                     The filter alpha is a [0, 1] floating point of the degree
                                                     to which the profile should be applied.
    '''
    def f(t, a):
        # Get floor and ceiling profiles and weight
        (pro0, pro1), t = [profiles[(int(t) + 0) % len(profiles)] for i in range(2)], t % 1
        if (pro0 is pro1) and (a == 1):
            # If the floor and ceiling are the same profile,
            # and the alpha is 1, than we can just simple apply
            # one without any interpolation
            pro0()
            return
        # But otherwise, we will need to save the current curves
        r_, g_, b_ = r_curve[:], g_curve[:], b_curve[:]
        # reset the curves
        start_over()
        # and apply on of the profiles
        pro0()
        # so that we can get the mapping of one of the profiles.
        r0, g0, b0 = r_curve[:], g_curve[:], b_curve[:]
        # After which we can get the last encoding value
        n = len(r0) - 1
        rgb = None
        if pro0 is pro1:
            # Now, if the floor and ceiling profiles are than same profile,
            # then we can use just one of then interpolat between it and a clean adjustment.
            rgb = [[v * a + i * (1 - a) / n for i, v in enumerate(c0)] for c0 in (r0, g0, b0)]
        else:
            # Otherwise we need to clear the curves from the floor profile's adjustments
            start_over()
            # and apply the ceiling profile
            pro1()
            # so that we can that profile's adjustments.
            # Than we pair the floor and ceilings profile for each channel.
            r01, g01, b01 = (r0, r_curve[:]), (g0, g_curve[:]), (b0, b_curve[:])
            # Now that we have two profiles, when we interpolate between them and a clean
            # state, we first interpolate the profiles and the interpolate between that
            # interpolation and a clean adjustment.
            interpol = lambda i, v0, v1 : (v0 * (1 - t) + v1 * t) * a + i * (1 - a) / n
            rgb = [[interpol(i, v0, v1) for i, (v0, v1) in enumerate(zip(*c01))] for c01 in (r01, g01, b01)]
        # Now that we have read the profiles, it is time to restore the curves to the
        # state they were in,
        r_curve[:], g_curve[:], b_curve[:] = r_, g_, b_
        # and apply the interpolated profile adjustments on top of it.
        for curve, icc in curves(*rgb):
            for i in range(i_size):
                # Nearest neighbour
                y = int(curve[i] * (len(icc) - 1) + 0.5)
                # Trunction to actual neighbour
                y = min(max(0, y), len(icc) - 1)
                # Apply mapping
                curve[i] = icc[y]
    return f