#
# Inara sync
#

from collections import OrderedDict
import json
import requests
import sys
import time
from Queue import Queue
from threading import Thread

import Tkinter as tk
from ttkHyperlinkLabel import HyperlinkLabel
import myNotebook as nb

from config import appname, applongname, appversion, config
import companion
import plug

if __debug__:
    from traceback import print_exc

_TIMEOUT = 20
FAKE = ['CQC', 'Training', 'Destination']	# Fake systems that shouldn't be sent to Inara
CREDIT_RATIO = 1.05		# Update credits if they change by 5% over the course of a session


this = sys.modules[__name__]	# For holding module globals
this.session = requests.Session()
this.queue = Queue()	# Items to be sent to Inara by worker thread

# Cached Cmdr state
this.events = []	# Unsent events
this.cmdr = None
this.multicrew = False	# don't send captain's ship info to Inara while on a crew
this.newuser = False	# just entered API Key
this.undocked = False	# just undocked
this.suppress_docked = False	# Skip Docked event after Location if started docked
this.cargo = None
this.materials = None
this.lastcredits = 0	# Send credit update soon after Startup / new game
this.needfleet = True	# Send full fleet update soon after Startup / new game
this.shipswap = False	# just swapped ship

# URLs
this.system = None
this.station = None


def plugin_start():
    this.thread = Thread(target = worker, name = 'Inara worker')
    this.thread.daemon = True
    this.thread.start()
    return 'Inara'

def plugin_stop():
    # Send any unsent events
    call()
    # Signal thread to close and wait for it
    this.queue.put(None)
    this.thread.join()
    this.thread = None

def plugin_prefs(parent, cmdr, is_beta):

    PADX = 10
    BUTTONX = 12	# indent Checkbuttons and Radiobuttons
    PADY = 2		# close spacing

    frame = nb.Frame(parent)
    frame.columnconfigure(1, weight=1)

    HyperlinkLabel(frame, text='Inara', background=nb.Label().cget('background'), url='https://inara.cz/', underline=True).grid(columnspan=2, padx=PADX, sticky=tk.W)	# Don't translate
    this.log = tk.IntVar(value = config.getint('inara_out') and 1)
    this.log_button = nb.Checkbutton(frame, text=_('Send flight log and Cmdr status to Inara'), variable=this.log, command=prefsvarchanged)
    this.log_button.grid(columnspan=2, padx=BUTTONX, pady=(5,0), sticky=tk.W)

    nb.Label(frame).grid(sticky=tk.W)	# big spacer
    this.label = HyperlinkLabel(frame, text=_('Inara credentials'), background=nb.Label().cget('background'), url='https://inara.cz/settings-api', underline=True)	# Section heading in settings
    this.label.grid(columnspan=2, padx=PADX, sticky=tk.W)

    this.apikey_label = nb.Label(frame, text=_('API Key'))	# EDSM setting
    this.apikey_label.grid(row=12, padx=PADX, sticky=tk.W)
    this.apikey = nb.Entry(frame)
    this.apikey.grid(row=12, column=1, padx=PADX, pady=PADY, sticky=tk.EW)

    prefs_cmdr_changed(cmdr, is_beta)

    return frame

def prefs_cmdr_changed(cmdr, is_beta):
    this.log_button['state'] = cmdr and not is_beta and tk.NORMAL or tk.DISABLED
    this.apikey['state'] = tk.NORMAL
    this.apikey.delete(0, tk.END)
    if cmdr:
        cred = credentials(cmdr)
        if cred:
            this.apikey.insert(0, cred)
    this.label['state'] = this.apikey_label['state'] = this.apikey['state'] = cmdr and not is_beta and this.log.get() and tk.NORMAL or tk.DISABLED

def prefsvarchanged():
    this.label['state'] = this.apikey_label['state'] = this.apikey['state'] = this.log.get() and this.log_button['state'] or tk.DISABLED

def prefs_changed(cmdr, is_beta):
    changed = config.getint('inara_out') != this.log.get()
    config.set('inara_out', this.log.get())

    if cmdr and not is_beta:
        this.cmdr = cmdr
        cmdrs = config.get('inara_cmdrs') or []
        apikeys = config.get('inara_apikeys') or []
        if cmdr in cmdrs:
            idx = cmdrs.index(cmdr)
            apikeys.extend([''] * (1 + idx - len(apikeys)))
            changed |= (apikeys[idx] != this.apikey.get().strip())
            apikeys[idx] = this.apikey.get().strip()
        else:
            config.set('inara_cmdrs', cmdrs + [cmdr])
            changed = True
            apikeys.append(this.apikey.get().strip())
        config.set('inara_apikeys', apikeys)

        if this.log.get() and changed:
            this.newuser = True	# Send basic info at next Journal event
            add_event('getCommanderProfile', time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), { 'searchName': cmdr })
            call()

def credentials(cmdr):
    # Credentials for cmdr
    if not cmdr:
        return None

    cmdrs = config.get('inara_cmdrs') or []
    if cmdr in cmdrs and config.get('inara_apikeys'):
        return config.get('inara_apikeys')[cmdrs.index(cmdr)]
    else:
        return None


def journal_entry(cmdr, is_beta, system, station, entry, state):

    # Send any unsent events when switching accounts
    if cmdr and cmdr != this.cmdr:
        call()

    this.cmdr = cmdr
    this.multicrew = bool(state['Role'])

    if entry['event'] == 'LoadGame':
        # clear cached state
        this.undocked = False
        this.suppress_docked = False
        this.cargo = None
        this.materials = None
        this.lastcredits = 0
        this.needfleet = True
        this.shipswap = False
    elif entry['event'] in ['Resurrect', 'ShipyardBuy', 'ShipyardSell', 'SellShipOnRebuy']:
        # Events that mean a significant change in credits so we should send credits after next "Update"
        this.lastcredits = 0
    elif entry['event'] in ['ShipyardNew', 'ShipyardSwap'] or (entry['event'] == 'Location' and entry['Docked']):
        this.suppress_docked = True


    # Send location and status on new game or StartUp. Assumes Location is the last event on a new game (other than Docked).
    # Always send an update on Docked, FSDJump, Undocked+SuperCruise, Promotion and EngineerProgress.
    # Also send material and cargo (if changed) whenever we send an update.

    if config.getint('inara_out') and not is_beta and not this.multicrew and credentials(cmdr):
        try:
            old_events = len(this.events)	# Will only send existing events if we add a new event below

            # Send rank info to Inara on startup or change
            if (entry['event'] in ['StartUp', 'Location'] or this.newuser) and state['Rank']:
                for k,v in state['Rank'].iteritems():
                    if v is not None:
                        add_event('setCommanderRankPilot', entry['timestamp'],
                                  OrderedDict([
                                      ('rankName', k.lower()),
                                      ('rankValue', v[0]),
                                      ('rankProgress', v[1] / 100.0),
                                  ]))
            elif entry['event'] == 'Promotion':
                for k,v in state['Rank'].iteritems():
                    if k in entry:
                        add_event('setCommanderRankPilot', entry['timestamp'],
                                  OrderedDict([
                                      ('rankName', k.lower()),
                                      ('rankValue', v[0]),
                                      ('rankProgress', 0),
                                  ]))

            # Send engineer status to Inara on change (not available on startup)
            if entry['event'] == 'EngineerProgress':
                if 'Rank' in entry:
                    add_event('setCommanderRankEngineer', entry['timestamp'],
                              OrderedDict([
                                  ('engineerName', entry['Engineer']),
                                  ('rankValue', entry['Rank']),
                              ]))
                else:
                    add_event('setCommanderRankEngineer', entry['timestamp'],
                              OrderedDict([
                                  ('engineerName', entry['Engineer']),
                                  ('rankStage', entry['Progress']),
                              ]))

            # Send PowerPlay status to Inara on change (not available on startup, and promotion not available at all)
            if entry['event'] == 'PowerplayJoin':
                add_event('setCommanderRankPower', entry['timestamp'],
                          OrderedDict([
                              ('powerName', entry['Power']),
                              ('rankValue', 1),
                          ]))
            elif entry['event'] == 'PowerplayLeave':
                add_event('setCommanderRankPower', entry['timestamp'],
                          OrderedDict([
                              ('powerName', entry['Power']),
                              ('rankValue', 0),
                          ]))
            elif entry['event'] == 'PowerplayDefect':
                add_event('setCommanderRankPower', entry['timestamp'],
                          OrderedDict([
                              ('powerName', entry['ToPower']),
                              ('rankValue', 1),
                          ]))

            # Update ship
            if (entry['event'] in ['StartUp', 'Location', 'ShipyardNew'] or
                (entry['event'] == 'Loadout' and this.shipswap) or
                this.newuser):
                if entry['event'] == 'ShipyardNew':
                    add_event('addCommanderShip', entry['timestamp'],
                              OrderedDict([
                                  ('shipType', state['ShipType']),
                                  ('shipGameID', state['ShipID']),
                              ]))
                add_event('setCommanderShip', entry['timestamp'],
                          OrderedDict([
                              ('shipType', state['ShipType']),
                              ('shipGameID', state['ShipID']),
                              ('shipName', state['ShipName']),		# Can be None
                              ('shipIdent', state['ShipIdent']),	# Can be None
                              ('isCurrentShip', True),
                ]))
                this.shipswap = False

            # Update location
            if (entry['event'] in ['StartUp', 'Location'] or this.newuser) and system:
                this.undocked = False
                add_event('setCommanderTravelLocation', entry['timestamp'],
                          OrderedDict([
                              ('starsystemName', system),
                              ('stationName', station),		# Can be None
                              ('shipType', state['ShipType']),
                              ('shipGameID', state['ShipID']),
                          ]))

            elif entry['event'] == 'Docked':
                if this.undocked:
                    # Undocked and now docking again. Don't send.
                    this.undocked = False
                elif this.suppress_docked:
                    # Don't send Docked event on new game - i.e. following 'Location' event
                    this.suppress_docked = False
                else:
                    add_event('addCommanderTravelDock', entry['timestamp'],
                              OrderedDict([
                                  ('starsystemName', system),
                                  ('stationName', station),
                                  ('shipType', state['ShipType']),
                                  ('shipGameID', state['ShipID']),
                              ]))

            elif entry['event'] == 'Undocked':
                this.undocked = True

            elif entry['event'] == 'SupercruiseEntry':
                if this.undocked:
                    # Staying in system after undocking - send any pending events from in-station action
                    add_event('setCommanderTravelLocation', entry['timestamp'],
                              OrderedDict([
                                  ('starsystemName', system),
                                  ('shipType', state['ShipType']),
                                  ('shipGameID', state['ShipID']),
                              ]))
                this.undocked = False

            elif entry['event'] == 'FSDJump':
                this.undocked = False
                add_event('addCommanderTravelFSDJump', entry['timestamp'],
                          OrderedDict([
                              ('starsystemName', entry['StarSystem']),
                              ('jumpDistance', entry['JumpDist']),
                              ('shipType', state['ShipType']),
                              ('shipGameID', state['ShipID']),
                          ]))


            if entry['event'] == 'ShutDown' or len(this.events) > old_events:
                # We have new event(s) so send to Inara

                # Send cargo and materials if changed
                cargo = [ OrderedDict([('itemName', k), ('itemCount', state['Cargo'][k])]) for k in sorted(state['Cargo']) ]
                if this.cargo != cargo:
                    add_event('setCommanderInventoryCargo', entry['timestamp'], cargo)
                    this.cargo = cargo
                materials = []
                for category in ['Raw', 'Manufactured', 'Encoded']:
                    materials.extend([ OrderedDict([('itemName', k), ('itemCount', state[category][k])]) for k in sorted(state[category]) ])
                if this.materials != materials:
                    add_event('setCommanderInventoryMaterials', entry['timestamp'],  materials)
                    this.materials = materials

                # Queue a call to Inara
                call()

        except Exception as e:
            if __debug__: print_exc()
            return unicode(e)

        #
        # Events that don't need to be sent immediately but will be sent on the next mandatory event
        #

        # Selling / swapping ships
        if entry['event'] in ['ShipyardBuy', 'ShipyardSell', 'SellShipOnRebuy', 'ShipyardSwap']:
            if entry['event'] == 'ShipyardSwap':
                this.shipswap = True	# Don't know new ship name and ident 'til the following Loadout event
            if 'StoreShipID' in entry:
                add_event('setCommanderShip', entry['timestamp'],
                          OrderedDict([
                              ('shipType', entry['StoreOldShip']),
                              ('shipGameID', entry['StoreShipID']),
                              ('starsystemName', system),
                              ('stationName', station),
                          ]))
            elif 'SellShipID' in entry:
                add_event('delCommanderShip', entry['timestamp'],
                          OrderedDict([
                              ('shipType', entry.get('SellOldShip', entry['ShipType'])),
                              ('shipGameID', entry['SellShipID']),
                          ]))

        elif entry['event'] == 'SetUserShipName':
            add_event('setCommanderShip', entry['timestamp'],
                      OrderedDict([
                          ('shipType', state['ShipType']),
                          ('shipGameID', state['ShipID']),
                          ('shipName', state['ShipName']),	# Can be None
                          ('shipIdent', state['ShipIdent']),	# Can be None
                          ('isCurrentShip', True),
                      ]))

        elif entry['event'] == 'ShipyardTransfer':
            add_event('setCommanderShipTransfer', entry['timestamp'],
                      OrderedDict([
                          ('shipType', entry['ShipType']),
                          ('shipGameID', entry['ShipID']),
                          ('starsystemName', system),
                          ('stationName', station),
                          ('transferTime', entry['TransferTime']),
                      ]))

        # Missions
        if entry['event'] == 'MissionAccepted':
            data = OrderedDict([
                ('missionName', entry['Name']),
                ('missionGameID', entry['MissionID']),
                ('influenceGain', entry['Influence']),
                ('reputationGain', entry['Reputation']),
                ('starsystemNameOrigin', system),
                ('stationNameOrigin', station),
                ('minorfactionNameOrigin', entry['Faction']),
            ])
            # optional mission-specific properties
            for (iprop, prop) in [
                    ('missionExpiry', 'Expiry'),	# Listed as optional in the docs, but always seems to be present
                    ('starsystemNameTarget', 'DestinationSystem'),
                    ('stationNameTarget', 'DestinationStation'),
                    ('minorfactionNameTarget', 'TargetFaction'),
                    ('commodityName', 'Commodity'),
                    ('commodityCount', 'Count'),
                    ('targetName', 'Target'),
                    ('targetType', 'TargetType'),
                    ('killCount', 'KillCount'),
                    ('passengerType', 'PassengerType'),
                    ('passengerCount', 'PassengerCount'),
                    ('passengerIsVIP', 'PassengerVIPs'),
                    ('passengerIsWanted', 'PassengerWanted'),
            ]:
                if prop in entry:
                    data[iprop] = entry[prop]
            add_event('addCommanderMission', entry['timestamp'], data)

        elif entry['event'] == 'MissionAbandoned':
            add_event('setCommanderMissionAbandoned', entry['timestamp'], { 'missionGameID': entry['MissionID'] })

        elif entry['event'] == 'MissionCompleted':
            for x in entry.get('PermitsAwarded', []):
                add_event('addCommanderPermit', entry['timestamp'], { 'starsystemName': x })

            data = OrderedDict([ ('missionGameID', entry['MissionID']) ])
            if 'Donation' in entry:
                data['donationCredits'] = entry['Donation']
            if 'Reward' in entry:
                data['rewardCredits'] = entry['Reward']
            if 'PermitsAwarded' in entry:
                data['rewardPermits'] = [{ 'starsystemName': x } for x in entry['PermitsAwarded']]
            if 'CommodityReward' in entry:
                data['rewardCommodities'] = [{ 'itemName': x['Name'], 'itemCount': x['Count'] } for x in entry['CommodityReward']]
            add_event('setCommanderMissionCompleted', entry['timestamp'], data)

        # Journal doesn't list rewarded materials directly, just as 'MaterialCollected'
        elif (entry['event'] == 'MaterialCollected' and this.events and
              this.events[-1]['eventName'] == 'setCommanderMissionCompleted' and
              this.events[-1]['eventTimestamp'] == entry['timestamp']):
            this.events[-1]['eventData']['rewardMaterials'] = [{ 'itemName': entry['Name'], 'itemCount': entry['Count'] }]

        elif entry['event'] == 'MissionFailed':
            add_event('setCommanderMissionFailed', entry['timestamp'], { 'missionGameID': entry['MissionID'] })

        # Combat
        if entry['event'] == 'Died':
            data = OrderedDict([ ('starsystemName', system) ])
            if 'Killers' in entry:
                data['wingOpponentNames'] = [x['Name'] for x in entry['Killers']]
            elif 'KillerName' in entry:
                data['opponentName'] = entry['KillerName']
            add_event('addCommanderCombatDeath', entry['timestamp'], data)

        elif entry['event'] == 'Interdicted':
            add_event('addCommanderCombatInterdicted', entry['timestamp'],
                      OrderedDict([('starsystemName', system),
                                   ('opponentName', entry['Interdictor']),
                                   ('isPlayer', entry['IsPlayer']),
                                   ('isSubmit', entry['Submitted']),
                      ]))

        elif entry['event'] == 'Interdiction':
            data = OrderedDict([('starsystemName', system),
                                ('isPlayer', entry['IsPlayer']),
                                ('isSuccess', entry['Success']),
            ])
            if 'Interdictor' in entry:
                data['opponentName'] = entry['Interdictor']
            elif 'Faction' in entry:
                data['opponentName'] = entry['Faction']
            elif 'Power' in entry:
                data['opponentName'] = entry['Power']
            add_event('addCommanderCombatInterdiction', entry['timestamp'], data)

        elif entry['event'] == 'EscapeInterdiction':
            add_event('addCommanderCombatInterdictionEscape', entry['timestamp'],
                      OrderedDict([('starsystemName', system),
                                   ('opponentName', entry['Interdictor']),
                                   ('isPlayer', entry['IsPlayer']),
                      ]))

        elif entry['event'] == 'PVPKill':
            add_event('addCommanderCombatKill', entry['timestamp'],
                      OrderedDict([('starsystemName', system),
                                   ('opponentName', entry['Victim']),
                      ]))

        # Community Goals
        if entry['event'] == 'CommunityGoal':
            this.events = [x for x in this.events if x['eventName'] not in ['setCommunityGoal', 'setCommanderCommunityGoalProgress']]	# Remove any unsent
            for goal in entry['CurrentGoals']:

                data = OrderedDict([
                    ('communitygoalGameID', goal['CGID']),
                    ('communitygoalName', goal['Title']),
                    ('starsystemName', goal['SystemName']),
                    ('stationName', goal['MarketName']),
                    ('goalExpiry', goal['Expiry']),
                    ('isCompleted', goal['IsComplete']),
                    ('contributorsNum', goal['NumContributors']),
                    ('contributionsTotal', goal['CurrentTotal']),
                ])
                if 'TierReached' in goal:
                    data['tierReached'] = int(goal['TierReached'].split()[-1])
                if 'TopRankSize' in goal:
                    data['topRankSize'] = goal['TopRankSize']
                add_event('setCommunityGoal', entry['timestamp'], data)

                data = OrderedDict([
                    ('communitygoalGameID', goal['CGID']),
                    ('contribution', goal['PlayerContribution']),
                    ('percentileBand', goal['PlayerPercentileBand']),
                ])
                if 'Bonus' in goal:
                    data['percentileBandReward'] = goal['Bonus']
                if 'PlayerInTopRank' in goal:
                    data['isTopRank'] = goal['PlayerInTopRank']
                add_event('setCommanderCommunityGoalProgress', entry['timestamp'], data)

        this.newuser = False

def cmdr_data(data, is_beta):

    this.cmdr = data['commander']['name']

    if config.getint('inara_out') and not is_beta and not this.multicrew and credentials(this.cmdr):

        timestamp = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
        assets = data['commander']['credits'] - data['commander'].get('debt', 0)

        for ship in companion.listify(data.get('ships', [])):
            if ship:
                assets += ship['value']['total']
                if this.needfleet:
                    if ship['id'] != data['commander']['currentShipId']:
                        add_event('setCommanderShip', timestamp,
                                  OrderedDict([
                                      ('shipType', ship['name']),
                                      ('shipGameID', ship['id']),
                                      ('shipName', ship.get('shipName')),	# Can be None
                                      ('shipIdent', ship.get('shipID')),	# Can be None
                                      ('shipHullValue', ship['value']['hull']),
                                      ('shipModulesValue', ship['value']['modules']),
                                      ('starsystemName', ship['starsystem']['name']),
                                      ('stationName', ship['station']['name']),
                                  ]))
                    else:
                        add_event('setCommanderShip', timestamp,
                                  OrderedDict([
                                      ('shipType', ship['name']),
                                      ('shipGameID', ship['id']),
                                      ('shipName', ship.get('shipName')),	# Can be None
                                      ('shipIdent', ship.get('shipID')),	# Can be None
                                      ('isCurrentShip', True),
                                      ('shipHullValue', ship['value']['hull']),
                                      ('shipModulesValue', ship['value']['modules']),
                                  ]))

        if not (CREDIT_RATIO > this.lastcredits / data['commander']['credits'] > 1/CREDIT_RATIO):
            this.events = [x for x in this.events if x['eventName'] != 'setCommanderCredits']	# Remove any unsent
            add_event('setCommanderCredits', timestamp,
                      OrderedDict([
                          ('commanderCredits', data['commander']['credits']),
                          ('commanderAssets', assets),
                          ('commanderLoan', data['commander'].get('debt', 0)),
                      ]))
            this.lastcredits = float(data['commander']['credits'])

        # *Don't* queue a call to Inara if we're just updating credits - wait for next mandatory event
        if this.needfleet:
            call()
            this.needfleet = False


def add_event(name, timestamp, data):
    this.events.append(OrderedDict([
        ('eventName', name),
        ('eventTimestamp', timestamp),
        ('eventData', data),
    ]))


# Queue a call to Inara, handled in Worker thread
def call(callback=None):
    if not this.events:
        return

    data = OrderedDict([
        ('header', OrderedDict([
            ('appName', applongname),
            ('appVersion', appversion),
            ('APIkey', credentials(this.cmdr)),
            ('commanderName', this.cmdr.encode('utf-8')),
        ])),
        ('events', list(this.events)),	# shallow copy
    ])
    this.events = []
    this.queue.put(('https://inara.cz/inapi/v1/', data, None))

# Worker thread
def worker():
    while True:
        item = this.queue.get()
        if not item:
            return	# Closing
        else:
            (url, data, callback) = item

        retrying = 0
        while retrying < 3:
            try:
                r = this.session.post(url, data=json.dumps(data, separators = (',', ':')), timeout=_TIMEOUT)
                r.raise_for_status()
                reply = r.json()
                status = reply['header']['eventStatus']
                if callback:
                    callback(reply)
                elif status // 100 != 2:	# 2xx == OK (maybe with warnings)
                    # Log fatal errors
                    print 'Inara\t%s %s' % (reply['header']['eventStatus'], reply['header'].get('eventStatusText', ''))
                    print json.dumps(data, indent=2, separators = (',', ': '))
                    plug.show_error(_('Error: Inara {MSG}').format(MSG = reply['header'].get('eventStatusText', status)))
                else:
                    # Log individual errors and warnings
                    for data_event, reply_event in zip(data['events'], reply['events']):
                        if reply_event['eventStatus'] != 200:
                            print 'Inara\t%s %s\t%s' % (reply_event['eventStatus'], reply_event.get('eventStatusText', ''), json.dumps(data_event, separators = (',', ': ')))
                            if reply_event['eventStatus'] // 100 != 2:
                                plug.show_error(_('Error: Inara {MSG}').format(MSG = '%s, %s' % (data_event['eventName'], reply_event.get('eventStatusText', reply_event['eventStatus']))))
                            if data_event['eventName'] in ['addCommanderTravelDock', 'addCommanderTravelFSDJump', 'setCommanderTravelLocation']:
                                eventData = reply_event.get('eventData', {})
                                this.system  = eventData.get('starsystemInaraURL')
                                this.station = eventData.get('stationInaraURL')
                break
            except:
                if __debug__: print_exc()
                retrying += 1
        else:
            if callback:
                callback(None)
            else:
                plug.show_error(_("Error: Can't connect to Inara"))