# coding=utf-8
#
# This file is part of SickGear.
#
# SickGear 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.
#
# SickGear 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 SickGear.  If not, see <http://www.gnu.org/licenses/>.

import datetime
try:
    import json as json
except (BaseException, Exception):
    import simplejson as json
from os import path
import socket
# noinspection PyUnresolvedReferences,PyProtectedMember
from ssl import _create_unverified_context
import sys
import time
import traceback

# noinspection PyCompatibility,PyUnresolvedReferences
from urllib.error import URLError
from urllib.parse import urlencode
from urllib.request import (Request, urlopen)

# these are Kodi specific libs, so block the error reports in pycharm
import xbmc
import xbmcaddon
import xbmcgui
import xbmcvfs

ADDON_ID = 'service.sickgear.watchedstate.updater'
ADDON_VERSION = '1.0.9'


class SickGearWatchedStateUpdater(xbmc.Monitor):

    def __init__(self):
        super(SickGearWatchedStateUpdater, self).__init__()
        self.wait_onstartup = 2000

        # noinspection PyTypeChecker
        self.addon = None  # type: xbmcaddon.Addon
        self.addon_name = None
        self.path_addon = None
        self.path_addon_data = None
        self.path_addons = None

        self.red_logo = None
        self.green_logo = None
        self.black_logo = None

        self.kodi_ip = None
        self.kodi_port = None

        self.kodi_events = None
        self.sock_kodi = None

    def run(self):
        """
        Main start
        """
        self.addon = xbmcaddon.Addon()
        self.addon_name = self.addon.getAddonInfo('name')
        self.path_addon = self.addon.getAddonInfo('path')
        self.path_addon_data = self.addon.getAddonInfo('profile')
        self.path_addons = self.make_path([xbmcvfs.translatePath('special://home'), 'addons'])

        icon_size = '%s'
        try:
            if 1350 > xbmcgui.Window.getWidth(xbmcgui.Window()):
                icon_size += '-sm'
        except (BaseException, Exception):
            pass
        icon = f'{self.path_addon}/resources/icon-{icon_size}.png'
        self.red_logo = icon % 'red'
        self.green_logo = icon % 'green'
        self.black_logo = icon % 'black'

        self.kodi_ip = self.get_setting('kodi_ip')
        self.kodi_port = int(self.get_setting('kodi_port'))

        if not self.enable_kodi_allow_remote():
            return

        # migrate legacy data from pre Kodi (Matrix)
        legacy_temp = self.make_path([self.path_addons, 'temp', ''])
        if xbmcvfs.exists(legacy_temp):
            for fname in ['sickgear_buffer.txt', 'sickgear_extra.txt']:
                src = self.make_path([legacy_temp, fname])
                if xbmcvfs.exists(src):
                    dest = self.make_path([self.path_addon_data, fname])
                    try:
                        xbmcvfs.copy(src, dest)
                        xbmcvfs.delete(src)
                    except (BaseException, Exception) as e:
                        self.log(f'Failed to move {src} to {dest} err: {self.ex(e)}')

        self.sock_kodi = socket.socket()
        self.sock_kodi.setblocking(True)
        xbmc.sleep(self.wait_onstartup)
        try:
            self.sock_kodi.connect((self.kodi_ip, self.kodi_port))
        except (BaseException, Exception) as e:
            return self.report_contact_fail(e)

        self.log('Started')
        self.notify('Started in background')

        cache_pkg = f'{self.path_addons}/packages/{ADDON_ID}-{ADDON_VERSION}.zip'
        if xbmcvfs.exists(cache_pkg):
            try:
                xbmcvfs.delete(cache_pkg)
            except (BaseException, Exception):
                pass

        self.kodi_events = xbmc.Monitor()

        sock_buffer, depth, methods, method = '', 0, {'VideoLibrary.OnUpdate': self.video_library_on_update}, None

        # socks listener parsing Kodi json output into action to perform
        while not self.abortRequested():
            chunk = self.decode_str(self.sock_kodi.recv(1))
            sock_buffer += chunk
            if chunk in '{}':
                if '{' == chunk:
                    depth += 1
                else:
                    depth -= 1
                    if not depth:
                        json_msg = json.loads(sock_buffer)
                        try:
                            method = json_msg.get('method')
                            method_handler = methods[method]
                            method_handler(json_msg)
                        except KeyError:
                            if 'System.OnQuit' == method:
                                break
                            if __dev__:
                                self.log(f'pass on event: {json_msg.get("method")}')

                        sock_buffer = ''

        self.sock_kodi.close()
        del self.kodi_events
        self.log('Stopped')

    def get_setting(self, name):
        """
        Return value of an Add-on setting as String

        :param name: id of Addon setting
        :type name: AnyStr
        :return: Success as setting string
        :rtype: AnyStr
        """
        # return self.addon.getSettings().getString(name) # for v10 when they fix the bug
        return self.addon.getSetting(name)

    def is_enabled(self, name):
        """
        Return state of an Add-on setting as Boolean

        :param name: id of Addon setting
        :type name: String
        :return: Success as True if addon setting is enabled, else False
        :rtype: Bool
        """
        # return self.addon.getSettings().getBool(name) # for v10 when they fix the bug
        return 'true' == self.addon.getSetting(name)

    def log(self, msg, error=False):
        """
        Add a message to the Kodi logging system (provided setting allows it)

        :param msg: Text to add to log file
        :type msg: String
        :param error: Specify whether text indicates an error or action
        :type error: Boolean
        :return:
        :rtype:
        """
        if self.is_enabled('verbose_log'):
            xbmc.log(f'[{self.addon_name}]:: {msg}', (xbmc.LOGINFO, xbmc.LOGERROR)[error])

    def notify(self, msg, period=4, error=None):
        """
        Invoke the Kodi onscreen notification panel with a message (provided setting allows it)

        :param msg: Text to display in panel
        :type msg: String
        :param period: Wait seconds before closing dialog
        :type period: Integer
        :param error: Specify whether text indicates an error or action
        :type error: Boolean
        :return:
        :rtype:
        """
        if not error and self.is_enabled('action_notification') or (error and self.is_enabled('error_notification')):
            xbmc.executebuiltin(f'Notification({self.addon_name}, "{msg}", {1000 * period}, '
                                f'{((self.green_logo, self.red_logo)[any([error])], self.black_logo)[None is error]})')

    @staticmethod
    def make_path(path_parts):
        # #type: List[AnyStr] -> AnyStr
        return xbmcvfs.translatePath(path.join(*path_parts))

    @staticmethod
    def ex(e):
        return '\n'.join(['\nEXCEPTION Raised: --> Python callback/script returned the following error <--',
                          'Error type: <type \'{0}\'>',
                          'Error content: {1!r}',
                          '{2}',
                          '--> End of Python script error report <--\n'
                          ]).format(type(e).__name__, e.args, traceback.format_exc())

    def report_contact_fail(self, e):
        msg = f'Failed to contact Kodi at {self.kodi_ip}:{self.kodi_port}'
        self.log(f'{msg} {self.ex(e)}', error=True)
        self.notify(msg, period=20, error=True)

    def kodi_request(self, params):
        params.update(dict(jsonrpc='2.0', id='SickGear'))
        try:
            response = xbmc.executeJSONRPC(json.dumps(params))
        except (BaseException, Exception) as e:
            return self.report_contact_fail(e)
        return json.loads(response)

    def video_library_on_update(self, json_msg):
        """
        Actions to perform for: Kodi Notifications / VideoLibrary / VideoLibrary.OnUpdate
        invoked in Kodi when: A video item has been updated
        source: https://kodi.wiki/view/JSON-RPC_API/v8#VideoLibrary.OnUpdate

        :param json_msg: A JSON parsed from socks
        :type json_msg: String
        :return:
        :rtype:
        """
        try:
            # note: this is called multiple times when a season is marked as un-/watched
            if 'episode' == json_msg['params']['data']['item']['type']:
                media_id = json_msg['params']['data']['item']['id']
                play_count = json_msg['params']['data']['playcount']

                json_resp = self.kodi_request(dict(
                    method='Profiles.GetCurrentProfile'))
                current_profile = json_resp['result']['label']

                json_resp = self.kodi_request(dict(
                    method='VideoLibrary.GetEpisodeDetails',
                    params=dict(episodeid=media_id, properties=['file'])))
                path_file = self.decode_str(json_resp['result']['episodedetails']['file'])

                self.update_sickgear(media_id, path_file, play_count, current_profile)
        except (BaseException, Exception):
            pass

    def update_sickgear(self, media_id, path_file, play_count, profile):

        self.notify('Update sent to SickGear')

        file_name = 'sickgear_extra.txt'
        data_extra = self.load_json(file_name)
        scheme = data_extra.get('scheme', 'http')

        url = f'{scheme}://{self.get_setting("sickgear_ip")}:{self.get_setting("sickgear_port")}/' \
              'update-watched-state-kodi/'
        self.log(f'Notify state to {url} with path_file={path_file}')

        msg_bad = f'Failed to contact SickGear on port ' \
                  f'{self.get_setting("sickgear_port")} at {self.get_setting("sickgear_ip")}'

        payload_json = self.payload_prep(dict(media_id=media_id, path_file=path_file, played=play_count, label=profile))
        if payload_json:
            payload = urlencode(dict(payload=payload_json, version=ADDON_VERSION))
            r = None
            change_scheme = False
            try:
                rq = Request(url, data=self.decode_bytes(payload))
                param = ({'context': _create_unverified_context()}, {})[url.startswith('http:')]
                r = urlopen(rq, **param)
            except (BaseException, Exception):
                change_scheme = True

            try:
                if change_scheme:
                    old_scheme, scheme = 'http', 'https'
                    if url.startswith('https'):
                        old_scheme, scheme = 'https', 'http'
                    url = url.replace(old_scheme, scheme)

                    self.log(f'Change scheme, notify state to {url}')

                    rq = Request(url, data=self.decode_bytes(payload))
                    param = ({'context': _create_unverified_context()}, {})[url.startswith('http:')]
                    r = urlopen(rq, **param)

                response = json.load(r)
                r.close()
                if 'OK' == r.msg:
                    if change_scheme:
                        data_extra['scheme'] = scheme
                        output = json.dumps(data_extra)
                        self.save_json(file_name, output)

                    self.payload_prep(response)
                    if not all(iter(response.values())):
                        msg = 'Success, watched state updated'
                    else:
                        msg = f'Success, {len([None for v in iter(response.values()) if v])}' \
                              f'/{len([None for _ in iter(response.values())])} watched stated updated'
                    self.log(msg)
                    self.notify(msg, error=False)
                else:
                    msg_bad = 'Failed to update watched state'
                    self.log(msg_bad)
                    self.notify(msg_bad, error=True)
            except (BaseException, Exception) as e:
                self.log(f'Couldn\'t contact SickGear {self.ex(e)}', error=True)
                self.notify(msg_bad, error=True, period=15)

    def load_json(self, file_name):
        result = {}

        file_path = self.make_path([self.path_addon_data, file_name])
        if xbmcvfs.exists(file_path):
            try:
                with xbmcvfs.File(file_path) as fh:
                    result = json.load(fh)
            except (BaseException, Exception):
                pass

        return result

    def save_json(self, file_name, data):
        temp_ok = xbmcvfs.exists(self.path_addon_data) or xbmcvfs.exists(self.make_path([self.path_addon_data, '']))
        if not temp_ok:
            temp_ok = xbmcvfs.mkdirs(self.path_addon_data)

        if temp_ok:
            try:
                with xbmcvfs.File(self.make_path([self.path_addon_data, file_name]), 'w') as fh:
                    fh.write(data)
            except (BaseException, Exception):
                pass

    def payload_prep(self, payload):
        # type: (dict) -> str

        file_name = 'sickgear_buffer.txt'

        data_pool = self.load_json(file_name)

        response_data = False
        for k, v in iter(payload.items()):
            if response_data or k in data_pool:
                response_data = True
                if not v:
                    # whether no fail response or bad input, remove this from data
                    data_pool.pop(k)
                elif isinstance(v, str):
                    # error so retry next time
                    continue
        if not response_data:
            ts_now = time.mktime(datetime.datetime.now().timetuple())
            timeout = 100
            while ts_now in data_pool and timeout:
                ts_now = time.mktime(datetime.datetime.now().timetuple())
                timeout -= 1

            max_payload = 50-1
            for k in list(iter(data_pool.keys()))[max_payload:]:
                data_pool.pop(k)
            payload.update(dict(date_watched=ts_now))
            data_pool.update({ts_now: payload})

        output = json.dumps(data_pool)
        self.save_json(file_name, output)

        return output

    def enable_kodi_allow_remote(self):
        try:
            # setting esenabled: allow remote control by programs on this system
            # setting esallinterfaces: allow remote control by programs on other systems
            settings = [dict(esenabled=True), dict(esallinterfaces=True)]
            for setting in settings:
                name = next(iter(setting.keys()))
                if not self.kodi_request(dict(
                        method='Settings.SetSettingValue',
                        params=dict(setting=f'services.{name}', value=next(iter(setting.values())))
                )).get('result', {}):
                    settings[setting] = self.kodi_request(dict(
                        method='Settings.GetSettingValue',
                        params=dict(setting=f'services.{name}')
                    )).get('result', {}).get('value')
        except (BaseException, Exception):
            return

        setting_states = [next(iter(setting.values())) for setting in settings]
        if not all(setting_states):
            if not (any(setting_states)):
                msg = 'Please enable *all* Kodi settings to allow remote control by programs...'
            else:
                msg = 'Please enable Kodi setting to allow remote control by programs on other systems'
            msg = f'Failed startup. {msg} in system service/remote control'
            self.log(msg, error=True)
            self.notify(msg, period=20, error=True)
            return
        return True

    @staticmethod
    def decode_bytes(d, encoding='utf-8', errors='replace'):
        if not isinstance(d, bytes):
            return bytes(d, encoding=encoding, errors=errors)
        return d

    @staticmethod
    def decode_str(s, encoding='utf-8', errors=None):
        if isinstance(s, bytes):
            if None is errors:
                return s.decode(encoding)
            return s.decode(encoding, errors)
        return s


__dev__ = True
if __dev__:
    try:
        # specific to a dev env
        # noinspection PyProtectedMember, PyUnresolvedReferences
        import _devenv as devenv
    except ImportError:
        __dev__ = False


if 1 < len(sys.argv):
    if __dev__:
        devenv.setup_devenv(False)
    if 3 <= len(sys.argv) and sys.argv[2].endswith('send_all'):
        print('>>>>>> TESTTESTTEST')

elif '__main__' == __name__:
    if __dev__:
        devenv.setup_devenv(True)
    WSU = SickGearWatchedStateUpdater()
    WSU.run()
    del WSU

if __dev__:
    devenv.stop()