# -*- python -*-

# This example uses a text based configuration file to make
# it easier for non-programmers to use Blueshift. It is however
# rather limited, the lisp-esque example is a bit more complex
# but do much more. It will # read a file with the same pathname
# just with ‘.conf’ # appended (‘textconf.conf’ in this case.)
# However, if the filename of this file ends with with ‘rc’,
# that part will be removed, for example, if you rename this
# script to ‘~/.blueshiftrc’ it will read ‘~/.blueshift.conf’
# rather than ‘~/.blueshiftrc.conf’.


# 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 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 General Public License for more details.
# 
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import os
import sys
import time
import subprocess


# Get the name of .conf file
conf = '%s.conf' % (config_file[:-2] if config_file.endswith('rc') else config_file)


# Read .conf file
with open(conf, 'r') as file:
    conf = file.read()


# Parse .conf file
sections = {'blueshift' : []}
section = []
sections['blueshift'].append(section)
def remove_comment(text, spawn_aware):
    if (';' in text) or ('#' in text):
        if not spawn_aware:
            return text[:text.replace('#', ';').find(';')]
        buf, cmd, stack = '', 0, 0
        for c in text:
            if cmd > 0:
                if c == '(':
                    stack += 1
                elif c == ')':
                    stack -= 1
                if cmd == 1:
                    cmd = 2 if c == '(' else 0
                elif (c == ')') and (stack == 0):
                    cmd = 0
            elif c == '$':
                cmd = 1
                stack = 0
            elif (c == ';') or (c == '#'):
                break
            buf += c
        return buf
    return text
for line in conf.split('\n'):
    line = line.strip()
    if line.startswith('[') and remove_comment(line, False).rstrip().endswith(']'):
        line = remove_comment(line, False).rstrip()
        section_name = line[1 : -1].strip().lower()
        if section_name not in sections:
            sections[section_name] = []
        section = []
        sections[section_name].append(section)
    elif line.startswith(';') or line.startswith('#'):
        continue
    elif ('=' in line) or (':' in line):
        line = remove_comment(line, True)
        if ('=' in line) or (':' in line):
            eq = len(line) if '=' not in line else line.find('=')
            cl = len(line) if ':' not in line else line.find(':')
            eq = min(eq, cl)
            section.append((line[:eq].strip().lower(), line[eq + 1:].strip()))
        elif len(line.strip()) > 0:
            sys.stderr.buffer.write(('Malformated line: %s\n' % line).encode('utf-8'))
    elif len(line.strip()) > 0:
        sys.stderr.buffer.write(('Malformated line: %s\n' % line).encode('utf-8'))
sys.stderr.buffer.flush()


# Default values
location = None
adjustment_method_x = ['randr']
adjustment_method_tty = ['drm']
points = ['solar', '3', '-6']


# List of adjustments and temporary monitor information
adjustments = []
monitors = []
crtc = None
screen = None
name = None
edid = None
bldev = []
blmin = []


def parse_value(value):
    '''
    Parse a setting value
    
    @param   value:str                       The value to parse
    @return  :(list<str>, bool, bool, bool)  The words in the value string, with commands spawned,
                                             and with 'linear', 'cie' and 'default' filtered out,
                                             and their
                                             existance is put as booleans
    '''
    def spawn(cmd):
        '''
        Spawn an external process and read its output, but only the first line
        
        @param   cmd:str  The command to spawn
        @return  :str?    The first line of the command's output, `None` on failure
        '''
        proc = subprocess.Popen(['sh', '-c', cmd], stdout = subprocess.PIPE, stderr = sys.stderr)
        output = proc.communicate()[0].split('\n')[0]
        if (proc.returncode == 0) and (len(output) > 0):
            return output
        return None
    words, buf, cmd, stack = [], '', None, 0
    for c in value:
        if cmd is not None:
            if c == '(':
                stack += 1
            elif c == ')':
                stack -= 1
            if cmd == '':
                if c == '(':
                    cmd += '('
                else:
                    cmd = None
                    buf += '$'
            else:
                cmd += c
                if (c == ')') and (stack == 0):
                    cmd = cmd[1 : -1]
                    cmd = spawn(cmd)
                    if cmd is not None:
                        buf = cmd
                    cmd = None
        elif c == ' ':
            if not buf == '':
                words.append(buf)
                buf = ''
        elif c == '$':
            cmd = ''
            stack = 0
        else:
            buf += c
    if not buf == '':
        words.append(buf)
    return ([w for w in words if w not in ['linear', 'cie', 'default']],
            'linear' in words, 'cie' in words, 'default' in words)


# Evaluate .conf file
def make_f(f, value, default):
    '''
    Make an adjustment function
    
    @param  f:(*¿V??)→void      The function that makes the adjustment
    @param  value:list<¿V??>    The values for each time point
    @param  default:list<¿V??>  The default value
    '''
    ff = None
    value_ = []
    for val in value:
        value_ += val
    if any(map(lambda v : v is None, value_ + default)):
        def ff(t, a):
            val0 = value[(int(t) + 0) % len(value)]
            val1 = value[(int(t) + 1) % len(value)]
            t %= 1
            val = zip(val0, val1, default)
            def interpol(v0, v1, d):
                if (v0 is None) or (v1 is None) or (d is None):
                    if ( d is None) and a == 0:  return None
                    if (v0 is None) and t == 0:  return None
                    if (v1 is None) and t == 1:  return None
                v0 = v0 * (1 - t) if v0 is not None else 0
                v1 = v1 * t       if v1 is not None else 0
                return v0 + v1
            val = [interpol(v0, v1, d) for v0, v1, d in val]
            f(*val)
    else:
        def ff(t, a):
            val0 = value[(int(t) + 0) % len(value)]
            val1 = value[(int(t) + 1) % len(value)]
            t %= 1
            val = zip(val0, val1, default)
            val = [(v0 * (1 - t) + v1 * t) * a + (1 - a) * d for v0, v1, d in val]
            f(*val)
    return ff

def float3(value):
    '''
    Parse a string representation of a float trio
    
    @param   value:str                  The float trio as a string
    @return  :[float?, float?, float?]  The float trio as a float list
    '''
    value = [None if v == 'none' else float(v) for v in value.split(':')]
    if len(value) < 3:
        value *= 3
    return value[:3]

def float6(value):
    '''
    Parse a string representation of a float pair-trio
    
    @param   value:str    The float pair-trio as a string
    @return  :[float?]*6  The float pair-trio as a float list
    '''
    (part1, part2) = [[float(v) for v in val.split(':')] for val in value.split('..')]
    if len(part1) < 3:  part1 *= 3
    if len(part2) < 3:  part2 *= 3
    part1 = part1[:3]
    part2 = part2[:3]
    value = []
    for p, q in zip(part1, part2):
        value.append(p)
        value.append(q)
    return value

backlight_value = 1
def add_adjustments(adjsections, adjustments):
    '''
    Add adjustions from a section to a list
    
    @param  adjsections:list<list<(str, str)>>     The sections
    @param  adjustments:list<(float, float)→void>  The list to fill with adjustments
    '''
    global location, points, adjustment_method_x, adjustment_method_tty, crtc, screen, bldev, blmin, name, edid
    for section in adjsections:
        for (setting, value) in section:
            (value, linear, cie, default) = parse_value(value)
            new_adjustment = None
            if linear:
                adjustments.append(lambda _t, _a: linearise())
            if   setting == 'location':               location = value
            elif setting == 'points':                 points = value
            elif setting == 'adjustment-method-x':    adjustment_method_x = value
            elif setting == 'adjustment-method-tty':  adjustment_method_tty = value
            elif setting == 'crtc':                   crtc = value
            elif setting == 'screen':                 screen = value
            elif setting == 'card':                   screen = value
            elif setting == 'name':                   name = value
            elif setting == 'edid':                   edid = value
            elif setting == 'backlight-device':       bldev = value
            elif setting == 'backlight-minimum':      blmin = [int(v) for v in value]
            elif setting == 'backlight':
                def f(x):
                    global backlight_value
                    backlight_value *= f
                new_adjustment = make_f(f, [[float(v)] for v in value], [1])
            elif setting == 'temperature':
                f_ = cie_temperature if cie else temperature
                f = lambda x : f_(x, lambda t : divide_by_maximum(cmf_10deg(t)))
                new_adjustment = make_f(f, [[float(v)] for v in value], [6500])
            elif setting == 'contrast':
                f = cie_contrast if cie else rgb_contrast
                new_adjustment = make_f(f, [float3(v) for v in value], 3 * [1])
            elif setting == 'brightness':
                f = cie_brightness if cie else rgb_brightness
                new_adjustment = make_f(f, [float3(v) for v in value], 3 * [1])
            elif setting == 'gamma':
                def f(*levels):
                    clip()
                    gamma(*levels)
                new_adjustment = make_f(f, [float3(v) for v in value], 3 * [1])
            elif setting == 'negative':
                def f(*values):
                    negative(*[not v == 0 for v in values])
                new_adjustment = make_f(f, [float3(v) for v in value], 3 * [0])
            elif setting == 'invert':
                def f(*values):
                    (cie_invert if cie else rgb_invert)(*[not v == 0 for v in values])
                new_adjustment = make_f(f, [float3(v) for v in value], 3 * [0])
            elif setting == 'sigmoid':
                new_adjustment = make_f(sigmoid, [float3(v) for v in value], 3 * [None])
            elif setting == 'limits':
                f = cie_limits if cie else rgb_limits
                new_adjustment = make_f(f, [float6(v) for v in value], 3 * [0, 1])
            elif setting == 'icc':
                def noop():
                    pass
                profiles = [noop if val == 'none' else load_load(val) for val in value]
                new_adjustment = make_icc_interpolation(profiles)
            elif setting == 'monitor':
                add_adjustments(sections[' '.join(['monitor'] + value)], adjustments)
            else:
                sys.stderr.buffer.write(('Setting not recognised: %s\n' % setting).encode('utf-8'))
                sys.stderr.buffer.flush()
            if new_adjustment is not None:
                if default:
                    new_adjustment_ = new_adjustment
                    def f(t, a):
                        new_adjustment_(t, 1)
                    new_adjustment = f
                adjustments.append(new_adjustment)
            if linear:
                adjustments.append(lambda _t, _a: standardise())

add_adjustments(sections['blueshift'], adjustments)

adjustment_method = adjustment_method_tty if ttymode else adjustment_method_x
adjustment_method = adjustment_method[0]
list_method = 'randr' if adjustment_method == 'vidmode' else adjustment_method
screen_list = None

for section in sections[adjustment_method]:
    output_adjustments = []
    crtc, screen, name, edid, bldev, blmin = None, None, None, None, [], []
    add_adjustments([section], output_adjustments)
    crtc_screen = (crtc is None) or (screen is None)
    name_edid = (name is not None) or (edid is not None)
    if (screen_list is None) and (crtc_screen or name_edid):
        screen_list = list_screens(list_method)
    if screen is None:
        screen = list(range(len(screen_list)))
    else:
        screen = [int(s) for s in screen]
    crtcs = {}
    for s in screen:
        crtcs[s] = []
        if crtc is not None:
            crtcs[s] += [int(c) for c in crtc]
        elif (name is None) and (edid is None):
            crtcs[s] += list(range(screen_list[s].crtc_count))
        if name is not None:
            s = crtcs[s]
            crtcs[s] += [(d.crtc for d in screen_list.find_by_name(n) if d.crtc not in s) for n in name]
        if edid is not None:
            s = crtcs[s]
            crtcs[s] += [(d.crtc for d in screen_list.find_by_edid(e) if d.crtc not in s) for e in edid]
    monitors.append((crtcs, screen, bldev, blmin, output_adjustments))


# Get gamma adjustment/reader functions
get_method = {'randr' : randr_get, 'vidmode' : vidmode_get, 'drm' : drm_get}
set_method = {'randr' : randr,     'vidmode' : vidmode,     'drm' : drm    }
get_method = get_method[adjustment_method]
set_method = set_method[adjustment_method]


# Save gamma ramps
saved = {}
for crtcs, screens, _bldev, _blmin, _adj in monitors:
    for screen in screens:
        if screen not in saved:
            saved[screen] = {}
        saved_ = saved[screen]
        for crtc in crtcs[screen]:
            saved_[crtc] = get_method(crtc, screen)


# Evaluate location
latitude, longitude = None, None
if 'solar' in points:
    if (location is None) or (len(location) == 0):
        sys.stderr.buffer.write(('Location missing\n').encode('utf-8'))
        sys.stderr.buffer.flush()
        sys.exit(1)
    try:
        if not len(location) == 2:
            raise Exception()
        location = [float(c) for c in location]
    except:
        sys.stderr.buffer.write(('Malformation location\n').encode('utf-8'))
        sys.stderr.buffer.flush()
        sys.exit(1)
    if not ((-90 <= location[0] <= 90) and (-180 <= location[0] <= 180)):
        sys.stderr.buffer.write(('Invalid location\n').encode('utf-8'))
        sys.stderr.buffer.flush()
        sys.exit(1)
    (latitude, longitude) = location


# Evaluate point ## TODO Make this a standard part of Blueshift
if ('solar' not in points) and ('time' not in points):
    sys.stderr.buffer.write(('Invalid points settings\n').encode('utf-8'))
    sys.stderr.buffer.flush()
    sys.exit(1)
reduce_points = 'reduce' in points
solar_points = 'solar' in points
# TODO support brackets (see textconf.conf)
def t(point):
    point = [float(p) for p in point.split(':')]
    while len(point) > 3:
        point.append(0)
    v = sum([v * 60 ** (2 - i) for i, v in enumerate(point)])
    return v % (24 * 60 * 60)
points = [float(p) if solar_points else t(p) for p in points if p not in ['solar', 'time', 'reduce']]
points = list(enumerate(points))
if reduce_points:
    n = len(points) - 1
    points = [(r / n, v) for r, v in points]
get_timepoint = None
points.sort(key = lambda x : x[1])
if not solar_points: # TODO does these really handle `reduce` correctly?
    one_day = 24 * 60 * 60
    points.append((points[0][0], points[0][1] + one_day))
    points = [(points[-2][0], points[-2][1] - one_day)] + points
    def get_timepoint():
        v = time.time() % one_day
        for i in range(len(points) - 1):
            a, b = points[i][1], points[i + 1][1]
            if a <= v <= b:
                a_, b_ = points[i][0], points[i + 1][0]
                v = (v - a) / (b - a)
                if (a_ + 1 == b_) or (b_ == 0):
                    return v + points[i][0]
                else:
                    return points[i][1] - v
        return 1 # should never happen
if solar_points:
    def get_timepoint():
        v = solar_elevation(latitude, longitude)
        for i in range(len(points) - 1):
            a, b = points[i][1], points[i + 1][1]
            if a <= v <= b:
                a_, b_ = points[i][0], points[i + 1][0]
                v = (v - a) / (b - a)
                if (a_ + 1 == b_) or (b_ == 0):
                    return v + points[i][0]
                else:
                    return points[i][1] - v
        if v < points[0][1]:
            return points[0][0]
        return points[-1][0]
    


wait_period = 5
'''
:float  The number of seconds to wait before invoking `periodically` again
'''


# Create backlight device connection
adjbl = False
if 'PATH' in os.environ:
    path = os.environ['PATH'].split(os.path.pathsep)
    sep = os.path.sep
    for p in path:
        f = p + sep + 'adjbacklight'
        if os.path.exists(f):
            if os.access(f, os.X_OK):
                adjbl = True
                break
makebl = lambda dev, blmin : Backlight(dev, adjbacklight = adjbl, minimum = blmin)
makebls = lambda dev, blmin : [makebl(d, b) for d, b in zip(dev, blmin) if not d == 'None']
monitors = [(crtcs, screens, makebls(dev, blmin), adj) for crtcs, screens, dev, blmin, adj in monitors]

# Save backlight settings
saved_backlight_ = [[(b, b.brightness) for b in bl] for _c, _s, bl, _a in monitors if bl is not None]
saved_backlight = []
for sb in saved_backlight_:
    saved_backlight += sb


def periodically(year, month, day, hour, minute, second, weekday, fade):
    '''
    Invoked periodically
    
    If you want to control at what to invoke this function next time
    you can set the value of the global variable `wait_period` to the
    number of seconds to wait before invoking this function again.
    The value does not need to be an integer.
    
    @param  year:int     The year
    @param  month:int    The month, 1 = January, 12 = December
    @param  day:int      The day, minimum value is 1, probable maximum value is 31 (*)
    @param  hour:int     The hour, minimum value is 0, maximum value is 23
    @param  minute:int   The minute, minimum value is 0, maximum value is 59
    @param  second:int   The second, minimum value is 0, probable maximum value is 60 (**)
    @param  weekday:int  The weekday, 1 = Monday, 7 = Sunday
    @param  fade:float?  Blueshift can use this function to fade into a state when it start
                         or exits. `fade` can either be negative, zero or positive or `None`,
                         but the magnitude of value cannot exceed 1. When Blueshift starts,
                         this function will be invoked multiple with the time parameters
                         of the time it is invoked and each time `fade` will increase towards
                         1, starting at 0, when the value is 1, the settings should be applied
                         to 100 %. After this this function will be invoked once again with
                         `fade` being `None`. When Blueshift exits the same behaviour is used
                         except, `fade` decrease towards -1 but start slightly below 0, when
                         -1 is reached all settings should be normal. Then Blueshift will NOT
                         invoke this function with `fade` being `None`, instead it will by
                         itself revert all settings and quit.
    
    (*)  Can be exceeded if the calendar system is changed, like in 1712-(02)Feb-30
    (**) See https://en.wikipedia.org/wiki/Leap_second
    '''
    global backlight_value
    start_over()
    
    alpha = 1 if fade is None else abs(fade)
    timepoint = get_timepoint()
    backlight_value = 1
    
    for adjustment in adjustments:
        adjustment(timepoint, alpha)
    
    stored = store()
    stored_backlight_value = backlight_value
    for crtcs, screens, bldevs, output_adjustments in monitors:
        restore(stored)
        backlight_value = stored_backlight_value
        for adjustment in output_adjustments:
            adjustment(timepoint, alpha)
        
        for screen in screens:
            set_method(*(crtcs[screen]), screen = screen)
        for bldev in bldevs:
            bldev.brightness = backlight_value * bldev.maximum


def reset():
    '''
    Invoked to reset the displays
    '''
    for crtcs, screens, _bldevs, _adj in monitors:
        for screen in screens:
            saved_ = saved[screen]
            for crtc in crtcs[screen]:
                start_over()
                saved_[crtc]()
                set_method(crtc, screen = screen)
    for dev, lvl in saved_backlight:
        dev.brightness = lvl