from __future__ import unicode_literals, division, absolute_import
from builtins import *  # noqa pylint: disable=unused-import, redefined-builtin

from datetime import datetime
import logging

from flexget import plugin, db_schema
from flexget.config_schema import one_or_more
from flexget.entry import Entry
from flexget.event import event
from flexget.manager import Session
from flexget.plugin import PluginError
from flexget.utils.database import json_synonym
from flexget.utils.requests import TokenBucketLimiter
from flexget.components.sites.utils import normalize_unicode
from flexget.utils.tools import parse_filesize

from sqlalchemy import Column, Unicode, String, DateTime


DETECT_2FA = ("Authenticator Code", "TOTP code")
log = logging.getLogger('gazelle')
Base = db_schema.versioned_base('gazelle_session', 0)


class GazelleSession(Base):
    __tablename__ = 'gazelle_session'

    username = Column(Unicode, primary_key=True)
    base_url = Column(String, primary_key=True)

    authkey = Column(String)
    passkey = Column(String)
    _cookies = Column('cookie', Unicode)
    cookies = json_synonym('_cookies')
    expires = Column(DateTime)


class InputGazelle(object):
    """A generic plugin that searches a Gazelle-based website

    Limited functionality but should work for almost all of them.
    """

    def __init__(self):
        """Set up a plugin that only has the ability to do about basic search"""
        self.base_url = None

        # Aliases for config -> api params
        # Extended in subclasses
        self.aliases = {"search": "searchstr"}

        # API parameters
        # None means a raw value entry (no validation other than schema)
        # A dict means an enum with a config -> api mapping
        # A list is an enum with no mapping
        # Extended in subclasses
        self.params = {"searchstr": None}

    @property
    def schema(self):
        """The schema of the plugin

        Subclasses should extend this to implement more params
        """
        schema = {
            'type': 'object',
            'properties': {
                'base_url': {'type': 'string'},
                'username': {'type': 'string'},
                'password': {'type': 'string'},
                'max_pages': {'type': 'integer'},
                'search': {'type': 'string'},
            },
            'required': ['username', 'password'],
            'additionalProperties': False,
        }
        # base_url is only required if the subclass doesn't set it
        if not self.base_url:
            schema['required'].append('base_url')
        return schema

    def _key(self, key):
        """Gets the API key name from the entered key"""
        if key in self.aliases:
            return self.aliases[key]
        return key

    def _opts(self, key):
        """Gets the options for the specified key"""
        return self.params[self._key(key)]

    def _getval(self, key, val):
        """Gets the value for the specified key based on a config option"""
        opts = self._opts(key)
        if isinstance(opts, dict):
            # Translate the input value to the API value
            # The str cast converts bools to 'True'/'False' for use as keys
            # This allows for options that have True/False/Other values
            return opts[str(val)]
        elif isinstance(val, list):
            # Fix yaml parser making a list out of a string
            return ",".join(val)
        return val

    def params_from_config(self, config):
        """Filter params and map config values -> api values"""
        ret = {}
        for k, v in config.items():
            key = self._key(k)
            if key in self.params:
                ret[key] = self._getval(k, v)
        return ret

    def setup(self, task, config):
        """Set up a session and log in"""
        self._session = task.requests
        base_url = config.get('base_url', "").rstrip("/")
        if base_url:
            if self.base_url and self.base_url != base_url:
                log.warning(
                    "Using plugin designed for %s on %s - things may break",
                    self.base_url,
                    base_url,
                )
            self.base_url = base_url

        if not self.base_url:
            raise PluginError("No 'base_url' configured")

        # Any more than 5 pages is probably way too loose of a search
        self.max_pages = config.get('max_pages', 5)

        # The consistent request limiting rule seems to be:
        # "Refrain from making more than five (5) requests every ten (10) seconds"
        self._session.add_domain_limiter(TokenBucketLimiter(self.base_url, 2, '2 seconds'))

        self.username = config['username']
        self.password = config['password']

        # Login
        self.authenticate()

        # Logged in successfully, it's ok if nothing matches now
        task.no_entries_ok = True

    def resume_session(self):
        """Resume an existing session from the datebase

        Return True on successful recovery, False otherwise
        """
        log.debug("Attempting to find an existing session in the DB")
        with Session() as session:
            db_session = (
                session.query(GazelleSession)
                .filter(
                    GazelleSession.base_url == self.base_url,
                    GazelleSession.username == self.username,
                )
                .one_or_none()
            )
            if db_session and db_session.expires and db_session.expires >= datetime.utcnow():
                # Found a valid session in the DB - use it
                self._session.cookies.update(db_session.cookies)
                self.authkey = db_session.authkey
                self.passkey = db_session.passkey
                return True
        return False

    def save_current_session(self):
        """Store the current session in the database so it can be resumed later"""
        log.debug("Storing session info in the DB")
        with Session() as session:
            expires = None
            for c in self._session.cookies:
                if c.name == "session":
                    expires = datetime.utcfromtimestamp(c.expires)
            db_session = GazelleSession(
                username=self.username,
                base_url=self.base_url,
                cookies=dict(self._session.cookies),
                expires=expires,
                authkey=self.authkey,
                passkey=self.passkey,
            )
            session.merge(db_session)

    def authenticate(self, force=False):
        """Log in and store auth data from the server

        Adapted from https://github.com/isaaczafuta/whatapi
        """
        # clean slate before creating/restoring cookies
        self._session.cookies.clear()

        if not force and self.resume_session():
            log.info("Logged into %s using cached session", self.base_url)
            return

        # Forcing a re-login or no session in DB - log in using provided creds
        url = "{}/login.php".format(self.base_url)
        data = {'username': self.username, 'password': self.password, 'keeplogged': 1}
        r = self._session.post(url, data=data, allow_redirects=False, raise_status=True)
        if not r.is_redirect or r.next.url != "{}/index.php".format(self.base_url):
            msg = "Failed to log into {}".format(self.base_url)
            for otp_text in DETECT_2FA:
                # TODO: Find a better signal that 2FA is enabled
                if otp_text in r.text:
                    msg += " - Accounts using 2FA are currently not supported"
                    break
            raise PluginError(msg)

        account_info = self.request(no_login=True, action='index')
        self.authkey = account_info['authkey']
        self.passkey = account_info['passkey']
        log.info("Logged in to %s", self.base_url)

        # Store the session so we can resume it later
        self.save_current_session()

    def request(self, no_login=False, **params):
        """Make an AJAX request to the API

        If `no_login` is True, logging in will not be attempted if the request
        is redirected to the login page.

        Adapted from https://github.com/isaaczafuta/whatapi
        """
        if 'action' not in params:
            raise ValueError("An 'action' is required when making a request")

        ajaxpage = "{}/ajax.php".format(self.base_url)
        r = self._session.get(ajaxpage, params=params, allow_redirects=False, raise_status=True)
        if not no_login and r.is_redirect and r.next.url == "{}/login.php".format(self.base_url):
            log.warning("Redirected to login page, reauthenticating and trying again")
            self.authenticate(force=True)
            return self.request(no_login=True, **params)

        if r.status_code != 200:
            raise PluginError("{} returned a non-200 status code".format(self.base_url))

        try:
            json_response = r.json()
            if json_response['status'] != "success":

                # Try to deal with errors returned by the API
                error = json_response.get('error', json_response.get('status'))
                if not error or error == "failure":
                    error = json_response.get('response', str(json_response))

                raise PluginError(
                    "{} gave a failure response of '{}'".format(self.base_url, error)
                )
            return json_response['response']
        except (ValueError, TypeError, KeyError):
            raise PluginError("{} returned an invalid response".format(self.base_url))

    def search_results(self, params):
        """Generator that yields search results"""
        page = 1
        pages = None
        while page <= self.max_pages:
            if pages and page >= pages:
                break

            log.debug("Attempting to get page %d of search results", page)
            result = self.request(action='browse', page=page, **params)
            if not result['results']:
                break
            for x in result['results']:
                yield x

            pages = result.get('pages', pages)
            page += 1

        if page > self.max_pages:
            log.warning("Stopped after %d pages (out of %d total pages)", self.max_pages, pages)

    def get_entries(self, search_results):
        """Generator that yields Entry objects from search results"""
        for result in search_results:
            # Get basic information on the release
            info = dict((k, result[k]) for k in ('groupId', 'groupName'))

            # Releases can have multiple download options
            for tor in result['torrents']:
                temp = info.copy()
                temp['torrentId'] = tor['torrentId']

                yield Entry(
                    title="{groupName} ({groupId} - {torrentId}).torrent".format(**temp),
                    url="{}/torrents.php?action=download&id={}&authkey={}&torrent_pass={}"
                    "".format(self.base_url, temp['torrentId'], self.authkey, self.passkey),
                    torrent_seeds=tor['seeders'],
                    torrent_leeches=tor['leechers'],
                    # Size is returned in bytes
                    content_size=parse_filesize(str(tor['size']) + "b"),
                )

    @plugin.internet(log)
    def search(self, task, entry, config):
        """Search interface"""
        self.setup(task, config)

        entries = set()
        params = self.params_from_config(config)
        for search_string in entry.get('search_strings', [entry['title']]):
            query = normalize_unicode(search_string)
            params[self._key('search')] = query
            entries.update(self.get_entries(self.search_results(params)))
        return entries

    @plugin.internet(log)
    def on_task_input(self, task, config):
        """Task input interface"""
        self.setup(task, config)

        params = self.params_from_config(config)