#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Picks the microscope file fitting depending on the current hardware
# Takes as arguments:
#  --config <microscope file> IDs: the microscope file to run and (all) the IDs
#   (EEPROM or USB) that should be present. Can be sepecified multiple times,
#   the first matching is returned.
#  --fallback <microscope file>: the microscope file to run if nothing else matches
# Output is either:
#  * name of the file to start, and returns 0
#  * Error message on stderr, and returns >= 1
# Example:
# odemis-mic-selector.py --log-level 2 --config /usr/share/test-sun.yaml 238abe69010000c8 1bcf:2880 --config /usr/share/test-key.yaml 413c:2003 --fallback /usr/share/test-none.yaml


from __future__ import division, print_function, absolute_import

import Pyro4
import argparse
import collections
import logging
from logging.handlers import RotatingFileHandler
from odemis import model
from odemis.driver import powerctrl
from odemis.util import driver
import subprocess
import sys


LSUSB_BIN = "/usr/bin/lsusb"

TMCMCONFIG_BIN = "/usr/bin/tmcmconfig"

CLASS_PCU = powerctrl.PowerControlUnit
KWARGS_PCU = {"name": "pcu", "role": "pcu", "port": "/dev/ttyPMT*"}
# KWARGS_PCU_TEST = {"name": "pcu", "role": "pcu", "port": "/dev/fake"}
# KWARGS_PCU = KWARGS_PCU_TEST

class ConfigMatcher(object):
    """
    Structure to hold all the information about a configuration
    """
    def __init__(self, file):
        self.file = file
        self.tmcmconfig = None
        self.eeprom = set()
        self.usb = set()


def count_usb_device(usbid):
    """
    usbid (str): vendor ID:product ID
    return (int): number of USB devices connected 
    """
    cmd = [LSUSB_BIN, "-d", usbid]
    logging.debug("Running command %s", cmd)
    try:
        out = subprocess.check_output(cmd)
    except subprocess.CalledProcessError as ex:
        if ex.returncode == 1 and not ex.output:
            # Just no device
            return 0
        else:
            raise ex
    return out.count(usbid)


def get_eeprom_ids():
    """
    return (set of int): ID of each EEPROM detected
    """
    terminate = False
    try:
        pcu = model.getComponent(role="power-control")
        logging.debug("Using the backend to access the power controller")
    except (Pyro4.errors.CommunicationError, IOError, LookupError):
        logging.debug("Failed to access the backend, will try directly")
        pcu = CLASS_PCU(**KWARGS_PCU)
        terminate = True

    try:
        ids = pcu.memoryIDs.value
    except IOError as ex:
        logging.warning("Failed to get IDs: %s", ex)
        ids = []

    # HACK: it seems the PCU sometimes fails to detect the IDs on the first
    # call to SID => try a few more times.
    # With the old powerhubs (oslo), there should always at least be one ID,
    # but even on the new powerhubs (lund), if we are called it's because the
    # user expects an ID.
    if not ids:
        logging.warning("Failed to find any IDs, trying again")
        try:
            ids = pcu.memoryIDs.value
        except IOError as ex:
            logging.warning("Failed to get IDs: %s", ex)
            ids = []
        if not ids:
            logging.warning("Failed to find any IDs, trying one last time")
            ids = pcu.memoryIDs.value

    if terminate:
        pcu.terminate()
    logging.debug("Found EEPROM IDs %s", ids)
    iids = set(int(i, 16) for i in ids)
    return iids


def guess_hw(config):
    """
    Try to guess which hardware is present
    config (list of ConfigMatcher)
    return (ConfigMatcher): the first that matches
    """
    eids = set()
    if any(c.eeprom for c in config):
        try:
            eids = get_eeprom_ids()  # If backend is running, it will fail
        except Exception:
            logging.warning("Failed to read EEPROM IDs, will pretend no ID is connected", exc_info=True)

    for c in config:
        if not c.eeprom <= eids:
            logging.debug("Skipping config %s due to missing EEPROM ID", c.file)
            continue

        for uid in c.usb:
            if count_usb_device(uid) < 1:
                logging.debug("Skipping config %s due to missing USB ID %s", c.file, uid)
                break
        else:
            return c

    return None


def update_tmcm(config, address):
    """
    Update the TMCM board EEPROM
    config (str): path to the config file
    address (int): address of the board
    """
    status = driver.get_backend_status()
    if status != driver.BACKEND_STOPPED:
        logging.warning("Cannot update TMCM as Odemis backend is already running")
        return
        # Note: It cannot raise an error. That's needed
        # because odemis-start will call this script before checking whether
        # to (re)start the backend or not.
        # TODO: make the update also work when the backend is running?

    logging.info("Updating TMCM board...")
    cmd = [TMCMCONFIG_BIN, "--write", config, "--address", "%d" % address]
    logging.debug("Running command %s", cmd)
    subprocess.check_call(cmd)


def main(args):
    """
    Handles the command line arguments
    args is the list of arguments passed
    return (int): value to return to the OS as program exit code
    """

    # arguments handling
    parser = argparse.ArgumentParser(prog="odemis-mic-selector",
                        description="Picks the right microscope file based on the hardware present")

    parser.add_argument("--log-level", dest="loglev", metavar="<level>", type=int,
                        default=1, help="set verbosity level (0-2, default = 1)")
    parser.add_argument("--log-target", dest="logtarget", metavar="{stderr,filename}",
                        default="stderr", help="Specify the log target (stderr, filename)")
    parser.add_argument("--config", "-c", dest="config", nargs="+", action='append',
                        required=True,
                        metavar="<configfile>",  # , "[EEPROM ID]", "[USB:ID]"),
                        help="Microscope file and EEPROM and/or USB IDs to select it (all of them must match). "
                             "Can also have one .tmcm.tsv file, in which case it will be passed to tmcmconfig.")
    parser.add_argument("--fallback", "-f", dest="fallback", type=str,
                        help="Microscope file to use if no hardware is detected")
    parser.add_argument("--tmcm-address", dest="tmcmadd", type=int, default=2,
                        help="TMCM address to be used in tmcmconfig.")

    # TODO: make it more flexible. eg a pattern for file, and conditions to add
    # a keyword. For each keyword, possible to run extra command

    options = parser.parse_args(args[1:])

    # Set up logging before everything else
    if options.loglev < 0:
        logging.error("Log-level must be positive.")
        return 127
    loglev_names = (logging.WARNING, logging.INFO, logging.DEBUG)
    loglev = loglev_names[min(len(loglev_names) - 1, options.loglev)]

    if options.logtarget == "stderr":
        handler = logging.StreamHandler()
    else:
        # Rotate the log, with max 5*50Mb used.
        handler = RotatingFileHandler(options.logtarget, maxBytes=50 * (2 ** 20), backupCount=5)
    logging.getLogger().setLevel(loglev)
    handler.setFormatter(logging.Formatter('%(asctime)s (%(module)s) %(levelname)s: %(message)s'))
    logging.getLogger().addHandler(handler)

    # Parse the config arguments
    config = []
    for carg in options.config:
        if len(carg) == 1:
            raise ValueError("Config argument must at least have one ID specified, "
                             "but got none for %s" % (carg[0],))
        c = ConfigMatcher(carg[0])
        for idc in carg[1:]:
            if idc.endswith("tmcm.tsv"):
                if c.tmcmconfig is not None:
                    raise ValueError("Multiple tmcm files specified for config %s", c.file)
                c.tmcmconfig = idc
            elif ":" in idc:  # USB
                c.usb.add(idc)
            else:  # Must be EEPROM ID then
                # Convert IDs as hexadecimal to numbers
                c.eeprom.add(int(idc, 16))
        config.append(c)
    try:
        c = guess_hw(config)
        if c is None:
            if options.fallback:
                logging.info("No hardware detected, will use fallback microscope file")
                micf = options.fallback
            else:
                raise ValueError("No hardware fitting detected")
        else:
            micf = c.file
            if c.tmcmconfig:
                try:
                    update_tmcm(c.tmcmconfig, options.tmcmadd)
                except Exception:
                    logging.exception("Failed to update the TMCM board at address %d", options.tmcmadd)

        print(micf)
    except KeyboardInterrupt:
        logging.info("Interrupted before the end of the execution")
        return 1
    except ValueError as exp:
        logging.error("%s", exp)
        print(exp)  # Error message to be displayed to the user
        return 127
    except IOError as exp:
        logging.error("%s", exp)
        print(exp)  # Error message to be displayed to the user
        return 129
    except Exception as exp:
        logging.exception("Unexpected error while performing action")
        print(exp)  # Error message to be displayed to the user
        return 130

    return 0


if __name__ == '__main__':
    ret = main(sys.argv)
    exit(ret)

