#!/usr/bin/env python
from __future__ import with_statement
import optparse
import sys
import os
import re
import struct

import iguanaIR

#output "constants"
LOG_FATAL  = 0
LOG_ERROR  = 1
LOG_WARN   = 2
LOG_ALWAYS = 2.5
LOG_NORMAL = 3
LOG_INFO   = 4
LOG_DEBUG  = 5

msgPrefixes = [
    "FATAL: ",
    "ERROR: ",
    "WARNING: ",
    "",
    "INFO: ",
    "DEBUG: "
]

#local variables
null = open(os.devnull, 'r+')
parser = optparse.OptionParser()
options = None

def dieCleanly(level = None):
    """Exit the application with proper cleanup."""

    if level == None:
        level = LOG_ERROR

    #exit with appropriate value
    if level == LOG_FATAL:
        sys.exit(1)
    sys.exit(0)


def message(level, msg):
    global options
    """Print a message to a certain debug level"""
    retval = None

    if level <= options.logLevel or level == LOG_ALWAYS:
        out = sys.stdout

        # if logfile is open print to it instead
        if options.logFile == "-":
            out = sys.log
        elif level <= LOG_WARN:
            out = sys.stderr

        retval = msgPrefixes[int(level + 0.5)] + msg
        out.write(retval)
        retval = len(retval)

    if level <= LOG_FATAL:
        dieCleanly(level)

    return retval

def printUsage(msg = None):
    global parser
    if msg != None:
        message(LOG_FATAL, msg + parser.get_usage())
    message(LOG_ALWAYS, usage)
    dieCleanly(LOG_ALWAYS)

def parseOptions():
    global parser, options
    parser.add_option('-l', '--log-file',
                      metavar = 'FILE', dest = 'logFile',
                      help = 'Specify a log to receive all messages.')
    parser.add_option('--collect',
                      action = 'store_true',
                      help = 'Connect to a device and listen for signals.')
    parser.add_option('-d', '--device',
                      metavar = 'ID', default = '0',
                      help = 'Only useful with the --connect option to connect to a specific device.')
    parser.add_option('--no-raw-signals',
                      action = 'store_false',
                      default = True, dest = 'raw_signals',
                      help = 'Do not print the signals as they are collected.')
    parser.add_option('--clean',
                      action = 'store_true',
                      default = False,
                      help = 'Attempt to clean up the signal before printing for better compression.  Mainly useful for long space encoded signals from air conditioners.')
    parser.add_option('--cleaner',
                      action = 'store_true',
                      default = False,
                      help = 'Clean up the spaces as well as the pulses even though that does not actually help compression.')
    parser.add_option('--keep-one',
                      action = 'store_true', dest = 'one',
                      help = 'Just print a single signal in --send format.')
    parser.add_option('-i', '--input',
                      metavar = 'FILE', dest = 'srcFile',
                      help = 'Specify a file containing igclient output.')
    parser.add_option('-o', '--output',
                      metavar = 'FILE', dest = 'outFile',
                      help = 'Specify a file to receive the signal.  Specify - to ask the user for a name.')
    parser.add_option('--loop',
                      action='store_true',
                      help = 'Loop to allow the user to collect multiple signals.  Primarily useful when used with "--collect --output -"')
    parser.add_option('-g', '--gap',
                      type = 'int', dest = 'gap',
                      help = 'Length of the gap in microseconds.')
    parser.add_option('-q', '--quiet',
                      action = 'count',
                      help = 'Decrease verbosity.')
    parser.add_option('-v', '--verbose',
                      action = 'count',
                      help = 'Increase verbosity.')
    (options, leftover) = parser.parse_args()
    options.logLevel = LOG_NORMAL;
    if options.verbose:
        options.logLevel += options.verbose
    if options.quiet:
        options.logLevel -= options.quiet
        if options.logLevel <= LOG_FATAL:
            options.logLevel = LOG_FATAL
    if options.logFile == '-':
        options.logFile = None
    if leftover:
        printUsage("Unknown argument: " + leftover[0] + "\n")

def collectLiveSignals(device):
    results = []

    conn = iguanaIR.connect(device)
    if conn == -1:
        message(LOG_FATAL, "Failed to connect to the device.\n")

    req = iguanaIR.createRequest(iguanaIR.IG_DEV_RECVON)
    if not iguanaIR.writeRequest(req, conn):
        message(LOG_FATAL, "Failed to turn on the device's receiver.\n")
    response = iguanaIR.readResponse(conn, 1000);
    if iguanaIR.responseIsError(response):
        message(LOG_ERROR, "Error reading response from device.\n")

    try:
        message(LOG_NORMAL, "This program will now collect signals from USB device %s.  Press ctrl-c to stop and reformat these signals.\n" % device)

        while True:
            response = iguanaIR.readResponse(conn, 1000);
            if iguanaIR.responseIsError(response):
                message(LOG_ERROR, "Error reading signals from device.\n")
                break

            data = iguanaIR.removeData(response)
            if options.raw_signals:
                message(LOG_NORMAL,
                        'received %d signal(s):\n' % (len(data) / 4))
            for signal in struct.unpack('I' * (len(data) / 4), data):
                kind = 'space'
                if signal & iguanaIR.IG_PULSE_BIT:
                    kind = 'pulse'
                result = '%s: %d\n' % (kind, signal & iguanaIR.IG_PULSE_MASK)
                if options.raw_signals:
                    message(LOG_NORMAL, result)
                results.append(result)
    except KeyboardInterrupt:
        message(LOG_NORMAL, 'Formatting collected signals....\n')
    iguanaIR.close(conn)

    return results

def parseSignals(input):
    signals = [[]]
    prevType = None
    prevLen = 0
    for line in input:
        line = line.strip()
        if re.match('received \d+ signal\(s\):', line) or \
           line == 'receiver on: success' or \
           not line:
            continue

        (stype, length) = line.split(': ')
        if prevType == stype:
            prevLen += int(length)
        else:
            # record the signals discarding leading spaces
            if prevLen and \
               (len(signals[-1]) > 0 or prevType != 'space'):
                message(LOG_DEBUG, '%s %s\n' % (prevType, prevLen))
                if options.gap and \
                   prevType == 'space' and prevLen > options.gap:
                    signals.append([])
                else:
                    signals[-1].append(prevLen)
            prevLen = int(length)
        prevType = stype

    if prevType != 'space':
        signals[-1].append(prevLen)
        message(LOG_DEBUG, '%s %s\n' % (prevType, prevLen))

    # attempt to identify the gap length
    if options.gap == 0:
        # assume the header is the largest real signal so look for
        # something at least twice that length.
        gap = max(signals[0][:4]) * 2
        chopped = [[]]
        for sig in signals[0]:
            if sig > gap:
                chopped.append([])
            else:
                chopped[-1].append(sig)
        signals = chopped

    return signals

def closeEnough(a, b):
    if float(abs(a - b)) / (a + b) < 0.1:
        return True
    return False

def cleanSignals(lengths):
    # count the occurances of each length
    bins = {}
    for length in lengths:
        if length not in bins:
            bins[length] = 0
        bins[length] += 1

    # find peaks in the histogram
    peaks = []
    prevLen = None
    rising = True
    for length in sorted(bins):
        if prevLen is not None:
            if rising:
                if bins[length] < bins[prevLen]:
                    peaks.append(prevLen)
                    rising = False
            elif bins[length] > bins[prevLen]:
                rising = True
        prevLen = length
#        print length, bins[length]

#    print peaks

    # see if any peaks are too close together
    while True:
        for pos in range(1, len(peaks)):
            # check for a difference of < 20%
            if closeEnough(peaks[pos] , peaks[pos - 1]):
                # pick the more common peak and discard the other
                if bins[peaks[pos]] < bins[peaks[pos - 1]]:
                    peaks.remove(peaks[pos])
                else:
                    peaks.remove(peaks[pos - 1])
                break
        else:
            # once we stop merging break out
            break

    # see what lengths will end up replaced by what peaks
    map = {}
    for length in sorted(bins):
        closest = None
        for peak in peaks:
            if closest is None or abs(peak - length) < abs(closest - length):
                closest = peak

        if closeEnough(length, closest):
            if closest not in map:
                map[closest] = []
            map[closest].append(length)

    # find the median value length being merged to use as the replacement
    finalMap = {}
    for peak in map:
        medianList = []
        for length in map[peak]:
            medianList += [length] * bins[length]
        median = medianList[len(medianList) / 2]
#        print peak, map[peak], medianList, median

        # create the final replacement map
        for length in map[peak]:
            finalMap[length] = median

    # create a new series of cleaned lengths
    final = []
    for length in lengths:
        if length in finalMap:
            final.append(finalMap[length])
        else:
            final.append(length)
            
    return final

parseOptions()
# open the log file if specified
if options.logFile != None:
    sys.log = open(options.logFile, "a", 1)
    options.logFile = "-"

if not options.srcFile and not options.collect:
    message(LOG_NORMAL, "No source file specified on the command line.  Defaulting to live collection\n")
    options.collect = True

while True:
    if options.collect:
        input = collectLiveSignals(options.device)
    else:
        input = open(options.srcFile, 'r')

    signals = parseSignals(input)

    # some people just want the first signal in --send format, others get 10
    if options.one:
        signals[1:] = []
    signals[10:] = []

    # clean the signal if requested
    if options.clean or options.cleaner:
        # split the pulses from the spaces and clean each if requested
        for pos in range(len(signals)):
            signal = signals[pos]

            signal[::2] = cleanSignals(signal[::2])
            if options.cleaner:
                signal[1::2] = cleanSignals(signal[1::2])

            signals[pos] = signal

    # find the max length signal
    length = 0
    for signal in signals:
        if len(signal) > length:
            length = len(signal)

    text = ''
    # print a header only if we need it
    if len(signals) > 1:
        text += 'action '
        parts = []
        for x in range(len(signals)):
            parts.append('signal%d' % x)
        text += ' | '.join(parts) + '\n'

        text += re.sub('[^|]', '-', text).replace('|', '+') + '\n'

    # print in a nice format
    for x in range(length):
        if x % 2 == 0:
            text += 'pulse  '
        else:
            text += 'space  '

        parts = []
        for signal in signals:
            if len(signal) > x:
                parts.append('%6d' % signal[x])
        text += '  | '.join(parts) + '\n'

    if options.outFile:
        outFile = options.outFile
        if outFile == '-':
            sys.stdout.write('Filename: ')
            outFile = sys.stdin.readline().strip()
        with open(outFile, 'w') as output:
            output.write(text)
    else:
        message(LOG_NORMAL, text)

    if options.loop:
        sys.stdout.write('Another? [Y/n]: ')
        resp = sys.stdin.readline().strip().lower()
        if not resp:
            resp = 'y'
        if resp == 'y' or resp == 'yes':
            continue
    break
