from inkycal import Settings, Layout
from inkycal.custom import *

#from os.path import exists
import os
import traceback
import logging
import arrow
import time

try:
  from PIL import Image
except ImportError:
  print('Pillow is not installed! Please install with:')
  print('pip3 install Pillow')

try:
  import numpy
except ImportError:
  print('numpy is not installed! Please install with:')
  print('pip3 install numpy')

logger = logging.getLogger('inkycal')
logger.setLevel(level=logging.ERROR)

class Inkycal:
  """Inkycal main class"""

  def __init__(self, settings_path, render=True):
    """Initialise Inkycal
    settings_path = str -> location/folder of settings file
    render = bool -> show something on the ePaper?
    """
    self._release = '2.0.0beta'

    # Check if render is boolean
    if not isinstance(render, bool):
      raise Exception('render must be True or False, not "{}"'.format(render))
    self.render = render

    # Init settings class
    self.Settings = Settings(settings_path)

    # Check if display support colour
    self.supports_colour = self.Settings.Layout.supports_colour

    # Option to flip image upside down
    if self.Settings.display_orientation == 'normal':
      self.upside_down = False

    elif self.Settings.display_orientation == 'upside_down':
      self.upside_down = True

    # Option to use epaper image optimisation
    self.optimize = True

    # Load drivers if image should be rendered
    if self.render == True:

      # Get model and check if colour can be rendered
      model= self.Settings.model

      # Init Display class
      from inkycal.display import Display
      self.Display = Display(model)

      # get calibration hours
      self._calibration_hours = self.Settings.calibration_hours

      # set a check for calibration
      self._calibration_state = False

    # load+validate settings file. Import and setup specified modules
    self.active_modules = self.Settings.active_modules()
    for module in self.active_modules:
      try:
        loader = 'from inkycal.modules import {0}'.format(module)
        module_data = self.Settings.get_config(module)
        size, conf = module_data['size'], module_data['config']
        setup = 'self.{} = {}(size, conf)'.format(module, module)
        exec(loader)
        exec(setup)
        logger.debug(('{}: size: {}, config: {}'.format(module, size, conf)))

      # If a module was not found, print an error message
      except ImportError:
        print(
          'Could not find module: "{}". Please try to import manually.'.format(
          module))

    # Give an OK message
    print('loaded inkycal')

  def countdown(self, interval_mins=None):
    """Returns the remaining time in seconds until next display update"""

    # Validate update interval
    allowed_intervals = [10, 15, 20, 30, 60]

    # Check if empty, if empty, use value from settings file
    if interval_mins == None:
      interval_mins = self.Settings.update_interval

    # Check if integer
    if not isinstance(interval_mins, int):
      raise Exception('Update interval must be an integer -> 60')

    # Check if value is supported
    if interval_mins not in allowed_intervals:
      raise Exception('Update interval is {}, but should be one of: {}'.format(
        interval_mins, allowed_intervals))

    # Find out at which minutes the update should happen
    now = arrow.now()
    update_timings = [(60 - int(interval_mins)*updates) for updates in
                      range(60//int(interval_mins))][::-1]

    # Calculate time in mins until next update
    minutes = [_ for _ in update_timings if _>= now.minute][0] - now.minute

    # Print the remaining time in mins until next update
    print('{0} Minutes left until next refresh'.format(minutes))

    # Calculate time in seconds until next update
    remaining_time = minutes*60 + (60 - now.second)

    # Return seconds until next update
    return remaining_time

  def test(self):
    """Inkycal test run.
    Generates images for each module, one by one and prints OK if no
    problems were found."""
    print('You are running inkycal v{}'.format(self._release))


    print('Running inkycal test-run for {} ePaper'.format(
      self.Settings.model))

    if self.upside_down == True:
      print('upside-down mode active')

    for module in self.active_modules:
      generate_im = 'self.{0}.generate_image()'.format(module)
      print('generating image for {} module...'.format(module), end = '')
      try:
        exec(generate_im)
        print('OK!')
      except Exception as Error:
        print('Error!')
        print(traceback.format_exc())

  def run(self):
    """Runs the main inykcal program nonstop (cannot be stopped anymore!)
    Will show something on the display if render was set to True"""

    # TODO: printing traceback on display (or at least a smaller message?)
    # Calibration

    # Get the time of initial run
    runtime = arrow.now()

    # Function to flip images upside down
    upside_down = lambda image: image.rotate(180, expand=True)

    # Count the number of times without any errors
    counter = 1

    # Calculate the max. fontsize for info-section
    if self.Settings.info_section == True:
      info_section_height = round(self.Settings.Layout.display_height* (1/95) )
      self.font = auto_fontsize(ImageFont.truetype(
        fonts['NotoSans-SemiCondensed']), info_section_height)

    while True:
      print('Generating images for all modules...')
      for module in self.active_modules:
        generate_im = 'self.{0}.generate_image()'.format(module)
        try:
          exec(generate_im)
        except Exception as Error:
          print('Error!')
          message = traceback.format_exc()
          print(message)
          counter = 0
      print('OK')

      # Assemble image from each module
      self._assemble()

      # Check if image should be rendered
      if self.render == True:
        Display = self.Display

        self._calibration_check()

        if self.supports_colour == True:
          im_black = Image.open(images+'canvas.png')
          im_colour = Image.open(images+'canvas_colour.png')

          # Flip the image by 180° if required
          if self.upside_down == True:
            im_black = upside_down(im_black)
            im_colour = upside_down(im_colour)

          # render the image on the display
          Display.render(im_black, im_colour)

        # Part for black-white ePapers
        elif self.supports_colour == False:

          im_black = self._merge_bands()

          # Flip the image by 180° if required
          if self.upside_down == True:
            im_black = upside_down(im_black)

          Display.render(im_black)

      print('\ninkycal has been running without any errors for', end = ' ')
      print('{} display updates'.format(counter))
      print('Programm started {}'.format(runtime.humanize()))

      counter += 1

      sleep_time = self.countdown()
      time.sleep(sleep_time)

  def _merge_bands(self):
    """Merges black and coloured bands for black-white ePapers
    returns the merged image
    """

    im_path = images

    im1_path, im2_path = images+'canvas.png', images+'canvas_colour.png'

    # If there is an image for black and colour, merge them
    if os.path.exists(im1_path) and os.path.exists(im2_path):

      im1 = Image.open(im1_path).convert('RGBA')
      im2 = Image.open(im2_path).convert('RGBA')

      def clear_white(img):
        """Replace all white pixels from image with transparent pixels
        """
        x = numpy.asarray(img.convert('RGBA')).copy()
        x[:, :, 3] = (255 * (x[:, :, :3] != 255).any(axis=2)).astype(numpy.uint8)
        return Image.fromarray(x)

      im2 = clear_white(im2)
      im1.paste(im2, (0,0), im2)

    # If there is no image for the coloured-band, return the bw-image
    elif os.path.exists(im1_path) and not os.path.exists(im2_path):
      im1 = Image.open(im1_name).convert('RGBA')

    return im1


  def _assemble(self):
    """Assmebles all sub-images to a single image"""

    # Create an empty canvas with the size of the display
    width, height = self.Settings.Layout.display_size
    
    if self.Settings.info_section == True:
      height = round(height * ((1/95)*100) )

    im_black = Image.new('RGB', (width, height), color = 'white')
    im_colour = Image.new('RGB', (width ,height), color = 'white')

    # Set cursor for y-axis
    im1_cursor = 0
    im2_cursor = 0

    for module in self.active_modules:

      im1_path = images+module+'.png'
      im2_path = images+module+'_colour.png'

      # Check if there is an image for the black band
      if os.path.exists(im1_path):

        # Get actual size of image
        im1 = Image.open(im1_path).convert('RGBA')
        im1_size = im1.size

        # Get the size of the section
        section_size = self.Settings.get_config(module)['size']
        # Calculate coordinates to center the image
        x = int( (section_size[0] - im1_size[0]) /2)

        # If this is the first module, use the y-offset
        if im1_cursor == 0:
          y = int( (section_size[1]-im1_size[1]) /2)
        else:
          y = im1_cursor + int( (section_size[1]-im1_size[1]) /2)

        # center the image in the section space
        im_black.paste(im1, (x,y), im1)

        # Shift the y-axis cursor at the beginning of next section
        im1_cursor += section_size[1]

      # Check if there is an image for the coloured band
      if os.path.exists(im2_path):

        # Get actual size of image
        im2 = Image.open(im2_path).convert('RGBA')
        im2_size = im2.size

        # Get the size of the section
        section_size = self.Settings.get_config(module)['size']

        # Calculate coordinates to center the image
        x = int( (section_size[0]-im2_size[0]) /2)

        # If this is the first module, use the y-offset
        if im2_cursor == 0:
          y = int( (section_size[1]-im2_size[1]) /2)
        else:
          y = im2_cursor + int( (section_size[1]-im2_size[1]) /2)

        # center the image in the section space
        im_colour.paste(im2, (x,y), im2)

        # Shift the y-axis cursor at the beginning of next section
        im2_cursor += section_size[1]

    # Show an info section if specified by the settings file
    now = arrow.now()
    stamp = 'last update: {}'.format(now.format('D MMM @ HH:mm', locale =
                                                self.Settings.language))
    if self.Settings.info_section == True:
      write(im_black, (0, im1_cursor), (width, height-im1_cursor),
            stamp, font = self.font)


    # optimize the image by mapping colours to pure black and white
    if self.optimize == True:
      self._optimize_im(im_black).save(images+'canvas.png', 'PNG')
      self._optimize_im(im_colour).save(images+'canvas_colour.png', 'PNG')
    else:
      im_black.save(images+'canvas.png', 'PNG')
      im_colour.save(images+'canvas_colour.png', 'PNG')

  def _optimize_im(self, image, threshold=220):
    """Optimize the image for rendering on ePaper displays"""

    buffer = numpy.array(image.convert('RGB'))
    red, green = buffer[:, :, 0], buffer[:, :, 1]
    # grey->black
    buffer[numpy.logical_and(red <= threshold, green <= threshold)] = [0,0,0]
    image = Image.fromarray(buffer)
    return image

  def calibrate(self):
    """Calibrate the ePaper display to prevent burn-ins (ghosting)
    use this command to manually calibrate the display"""

    self.Display.calibrate()

  def _calibration_check(self):
    """Calibration sheduler
    uses calibration hours from settings file to check if calibration is due"""
    now = arrow.now()
    print('hour:', now.hour, 'hours:', self._calibration_hours)
    print('state:', self._calibration_state)
    if now.hour in self._calibration_hours and self._calibration_state == False:
      self.calibrate()
      self._calibration_state = True
    else:
      self._calibration_state = False


  def _check_for_updates(self):
    """Check if a new update is available for inkycal"""

    raise NotImplementedError('Tha developer were too lazy to implement this..')


  @staticmethod
  def _add_module(filepath_module, classname):
    """Add a third party module to inkycal
    filepath_module = the full path of your module. The file should be in /modules!
    classname = the name of your class inside the module
    """

    # Path for modules
    _module_path = 'inkycal/modules/'

    # Check if the filepath is a string
    if not isinstance(filepath_module, str):
      raise ValueError('filepath has to be a string!')

    # Check if the classname is a string
    if not isinstance(classname, str):
      raise ValueError('classname has to be a string!')

    # TODO:
    # Ensure only third-party modules are deleted as built-in modules
    # should not be deleted

    # Check if module is inside the modules folder
    if not _module_path in filepath_module:
      raise Exception('Your module should be in', _module_path)

    # Get the name of the third-party module file without extension (.py)
    filename = filepath_module.split('.py')[0].split('/')[-1]

    # Check if filename or classname is in the current module init file
    with open('modules/__init__.py', mode ='r') as module_init:
      content = module_init.read().splitlines()

    for line in content:
      if (filename or clasname) in line:
        raise Exception(
          'A module with this filename or classname already exists')

    # Check if filename or classname is in the current inkycal init file
    with open('__init__.py', mode ='r') as inkycal_init:
      content = inkycal_init.read().splitlines()

    for line in content:
      if (filename or clasname) in line:
        raise Exception(
          'A module with this filename or classname already exists')

    # If all checks have passed, add the module in the module init file
    with open('modules/__init__.py', mode='a') as module_init:
      module_init.write('from .{} import {}'.format(filename, classname))

    # If all checks have passed, add the module in the inkycal init file
    with open('__init__.py', mode ='a') as inkycal_init:
      inkycal_init.write('# Added by module adder \n')
      inkycal_init.write('import inkycal.modules.{}'.format(filename))

    print('Your module {} has been added successfully! Hooray!'.format(
      classname))

  @staticmethod
  def _remove_module(classname, remove_file = True):
    """Removes a third-party module from inkycal
    Input the classname of the file you want to remove 
    """

    # Check if filename or classname is in the current module init file
    with open('modules/__init__.py', mode ='r') as module_init:
      content = module_init.read().splitlines()

    with open('modules/__init__.py', mode ='w') as module_init:
      for line in content:
        if not classname in line:
          module_init.write(line+'\n')
        else:
          filename = line.split(' ')[1].split('.')[1]

    # Check if filename or classname is in the current inkycal init file
    with open('__init__.py', mode ='r') as inkycal_init:
      content = inkycal_init.read().splitlines()

    with open('__init__.py', mode ='w') as inkycal_init:
      for line in content:
        if not filename in line:
          inkycal_init.write(line+'\n')

    # remove the file of the third party module if it exists and remove_file
    # was set to True (default)
    if os.path.exists('modules/{}.py'.format(filename)) and remove_file == True:
      os.remove('modules/{}.py'.format(filename))

    print('The module {} has been removed successfully'.format(classname))