from __future__ import absolute_import

import logging
import random
import socket
import struct
import sys
import time
from abc import ABCMeta, abstractmethod

from ipv8.messaging.deprecated.encoding import add_url_params
from ipv8.taskmanager import TaskManager

from twisted.internet import defer, reactor
from twisted.internet.defer import Deferred, inlineCallbacks
from twisted.internet.protocol import DatagramProtocol
from twisted.python.failure import Failure
from twisted.web.client import Agent, HTTPConnectionPool, RedirectAgent, readBody

from Tribler.Core.Utilities.tracker_utils import parse_tracker_url
from Tribler.Core.Utilities.unicode import hexlify
from Tribler.Core.Utilities.utilities import bdecode_compat

# Although these are the actions for UDP trackers, they can still be used as
# identifiers.
TRACKER_ACTION_CONNECT = 0
TRACKER_ACTION_ANNOUNCE = 1
TRACKER_ACTION_SCRAPE = 2

MAX_INT32 = 2 ** 16 - 1

UDP_TRACKER_INIT_CONNECTION_ID = 0x41727101980

MAX_INFOHASHES_IN_SCRAPE = 60


def create_tracker_session(tracker_url, timeout, socket_manager, connection_pool=None):
    """
    Creates a tracker session with the given tracker URL.
    :param tracker_url: The given tracker URL.
    :param timeout: The timeout for the session.
    :return: The tracker session.
    """
    tracker_type, tracker_address, announce_page = parse_tracker_url(tracker_url)

    if tracker_type == u'udp':
        return UdpTrackerSession(tracker_url, tracker_address, announce_page, timeout, socket_manager)
    return HttpTrackerSession(tracker_url, tracker_address, announce_page, timeout, connection_pool=connection_pool)


class TrackerSession(TaskManager):
    __meta__ = ABCMeta

    def __init__(self, tracker_type, tracker_url, tracker_address, announce_page, timeout):
        super(TrackerSession, self).__init__()

        self._logger = logging.getLogger(self.__class__.__name__)
        # tracker type in lowercase
        self._tracker_type = tracker_type
        self._tracker_url = tracker_url
        self._tracker_address = tracker_address
        # if this is a nonempty string it starts with '/'.
        self._announce_page = announce_page

        self._infohash_list = []
        self.result_deferred = None

        self.timeout = timeout
        self.timeout_call = None

        self._last_contact = None

        # some flags
        self._is_initiated = False  # you cannot add requests to a session if it has been initiated
        self._is_finished = False
        self._is_failed = False
        self._is_timed_out = False

    def __str__(self):
        return "Tracker[%s, %s]" % (self._tracker_type, self._tracker_url)

    def __unicode__(self):
        return u"Tracker[%s, %s]" % (self._tracker_type, self._tracker_url)

    @inlineCallbacks
    def cleanup(self):
        """
        Sets the _infohash_list to None and returns a deferred that has succeeded.
        :return: A deferred that succeeds immediately.
        """
        yield self.wait_for_deferred_tasks()

        self.shutdown_task_manager()
        self._infohash_list = None

    def has_infohash(self, infohash):
        return infohash in self._infohash_list

    def add_infohash(self, infohash):
        """
        Adds an infohash into this session.
        :param infohash: The infohash to be added.
        """
        assert not self._is_initiated, u"Must not add request to an initiated session."
        assert not self.has_infohash(infohash), u"Must not add duplicate requests"
        if len(self._infohash_list) < MAX_INFOHASHES_IN_SCRAPE:
            self._infohash_list.append(infohash)

    @abstractmethod
    def connect_to_tracker(self):
        """Does some work when a connection has been established."""
        pass

    def start_timeout(self):
        self.timeout_call = self.register_task("timeout", reactor.callLater(self.timeout, self.on_timeout)) \
            if self.timeout != 0 else None

    def on_timeout(self):
        """
        This method is executed if session fails to return the response within expected time.
        """
        self._is_failed = True
        self._is_timed_out = True
        if self.result_deferred and not self.result_deferred.called:
            timeout_msg = "%s tracker timeout for url %s" % (self._tracker_type, self._tracker_url)
            self.result_deferred.errback(ValueError(timeout_msg))
            self.result_deferred = None
        self.timeout_call = None

    @property
    def tracker_type(self):
        return self._tracker_type

    @property
    def tracker_url(self):
        return self._tracker_url

    @property
    def infohash_list(self):
        return self._infohash_list

    @property
    def last_contact(self):
        return self._last_contact

    @property
    def is_initiated(self):
        return self._is_initiated

    @property
    def is_finished(self):
        return self._is_finished

    @property
    def is_failed(self):
        return self._is_failed

    @property
    def is_timed_out(self):
        return self._is_timed_out


class HttpTrackerSession(TrackerSession):
    def __init__(self, tracker_url, tracker_address, announce_page, timeout, connection_pool=None):
        super(HttpTrackerSession, self).__init__(u'http', tracker_url, tracker_address, announce_page, timeout)
        self._header_buffer = None
        self._message_buffer = None
        self._content_encoding = None
        self._content_length = None
        self._received_length = None
        self.result_deferred = None
        self._parse_deferred = None
        self.request = None
        self._connection_pool = connection_pool if connection_pool else HTTPConnectionPool(reactor, False)

    def connect_to_tracker(self):
        # create the HTTP GET message
        # Note: some trackers have strange URLs, e.g.,
        #       http://moviezone.ws/announce.php?passkey=8ae51c4b47d3e7d0774a720fa511cc2a
        #       which has some sort of 'key' as parameter, so we need to use the add_url_params
        #       utility function to handle such cases.

        url = add_url_params("http://%s:%s%s" %
                             (self._tracker_address[0], self._tracker_address[1],
                              self._announce_page.replace(u'announce', u'scrape')),
                             {"info_hash": self._infohash_list})

        # no more requests can be appended to this session
        self._is_initiated = True
        self._last_contact = int(time.time())

        agent = RedirectAgent(Agent(reactor, connectTimeout=self.timeout, pool=self._connection_pool))
        try:
            self.request = self.register_task("request", agent.request(b'GET', url.encode('ascii')))
            self.request.addCallback(self.on_response)
            self.request.addErrback(self.on_error)
            self._logger.debug(u"%s HTTP SCRAPE message sent: %s", self, url)

            self.start_timeout()

            # Return deferred that will evaluate when the whole chain is done.
            self.result_deferred = self.register_task("result", Deferred(canceller=self._on_cancel))

        except (UnicodeEncodeError, ValueError) as e:
            self.result_deferred = defer.fail(e)

        return self.result_deferred

    def on_error(self, failure):
        """
        Handles the case of an error during the request.
        :param failure: The failure object that is thrown by a deferred.
        """
        self.failed(msg=failure.getErrorMessage())

    def on_response(self, response):
        # Check if this one was OK.
        if response.code != 200:
            # error response code
            self._logger.warning(u"%s HTTP SCRAPE error response code [%s, %s]", self, response.code, response.phrase)
            self.failed(msg="error code %s" % response.code)
            return

        # All ok, parse the body
        self._parse_deferred = readBody(response).addCallbacks(self._process_scrape_response, self.on_error)
        self.register_task("parse_body", self._parse_deferred)

    def _on_cancel(self, _):
        """
        :param _: The deferred which we ignore.
        This function handles the scenario of the session prematurely being cleaned up,
        most likely due to a shutdown.
        This function only should be called by the result_deferred.
        """
        self._logger.info("The result deferred of this HTTP tracker session is being cancelled "
                          "due to a session cleanup. HTTP url: %s", self.tracker_url)

    def failed(self, msg=None):
        """
        This method handles everything that needs to be done when one step
        in the session has failed and thus no data can be obtained.
        """
        self._is_failed = True
        if self.result_deferred and not self.result_deferred.called:
            result_msg = "HTTP tracker failed for url %s" % self._tracker_url
            if msg:
                result_msg += " (error: %s)" % msg
            self.result_deferred.errback(ValueError(result_msg))

    def _process_scrape_response(self, body):
        """
        This function handles the response body of a HTTP tracker,
        parsing the results.
        """
        # parse the retrieved results
        if body is None:
            self.failed(msg="no response body")
            return

        response_dict = bdecode_compat(body)
        if not response_dict:
            self.failed(msg="no valid response")
            return

        response_list = []

        unprocessed_infohash_list = self._infohash_list[:]
        if b'files' in response_dict and isinstance(response_dict[b'files'], dict):
            for infohash in response_dict[b'files']:
                complete = 0
                incomplete = 0
                if isinstance(response_dict[b'files'][infohash], dict):
                    complete = response_dict[b'files'][infohash].get(b'complete', 0)
                    incomplete = response_dict[b'files'][infohash].get(b'incomplete', 0)

                # Sow complete as seeders. "complete: number of peers with the entire file, i.e. seeders (integer)"
                #  - https://wiki.theory.org/BitTorrentSpecification#Tracker_.27scrape.27_Convention
                seeders = complete
                leechers = incomplete

                # Store the information in the dictionary
                response_list.append({'infohash': hexlify(infohash), 'seeders': seeders, 'leechers': leechers})

                # remove this infohash in the infohash list of this session
                if infohash in unprocessed_infohash_list:
                    unprocessed_infohash_list.remove(infohash)

        elif b'failure reason' in response_dict:
            self._logger.info(u"%s Failure as reported by tracker [%s]", self, repr(response_dict[b'failure reason']))
            self.failed(msg=repr(response_dict[b'failure reason']))
            return

        # handle the infohashes with no result (seeders/leechers = 0/0)
        for infohash in unprocessed_infohash_list:
            response_list.append({'infohash': hexlify(infohash), 'seeders': 0, 'leechers': 0})

        self._is_finished = True
        if self.result_deferred and not self.result_deferred.called:
            self.result_deferred.callback({self.tracker_url: response_list})

    @inlineCallbacks
    def cleanup(self):
        """
        Cleans the session by cancelling all deferreds and closing sockets.
        :return: A deferred that fires once the cleanup is done.
        """
        yield self._connection_pool.closeCachedConnections()
        yield super(HttpTrackerSession, self).cleanup()

        # If we are in the process of reading a HTTP response from a remote server (with the readBody) method, it might
        # be that it is called after the deferred has been cancelled already (by the super.cleanup method). This would
        # result in an AlreadyCalled error. The following line prevents this behaviour.
        if self._parse_deferred:
            self._parse_deferred._suppressAlreadyCalled = True

        self.request = None
        self.result_deferred = None


class UdpSocketManager(DatagramProtocol):
    """
    The UdpSocketManager ensures that the network packets are forwarded to the right UdpTrackerSession.
    """

    def __init__(self):
        self._logger = logging.getLogger(self.__class__.__name__)
        self.tracker_sessions = {}

    def send_request(self, data, tracker_session):
        self.tracker_sessions[tracker_session.transaction_id] = tracker_session
        try:
            self.transport.write(data, (tracker_session.ip_address, tracker_session.port))
        except socket.error as exc:
            self._logger.warning("Unable to write data to %s:%d - %s",
                                 tracker_session.ip_address, tracker_session.port, exc)

    def datagramReceived(self, data, _):
        # If the incoming data is valid, find the tracker session and give it the data
        if data and len(data) >= 4:
            transaction_id = struct.unpack_from('!i', data, 4)[0]
            if transaction_id in self.tracker_sessions:
                self.tracker_sessions.pop(transaction_id).handle_response(data)


class UdpTrackerSession(TrackerSession):
    """
    The UDPTrackerSession makes a connection with a UDP tracker and queries
    seeders and leechers for one or more infohashes. It handles the message serialization
    and communication with the torrent checker by making use of Deferred (asynchronously).
    """

    # A list of transaction IDs that have been used in order to avoid conflict.
    _active_session_dict = dict()
    reactor = reactor

    def __init__(self, tracker_url, tracker_address, announce_page, timeout, socket_mgr):
        super(UdpTrackerSession, self).__init__(u'udp', tracker_url, tracker_address, announce_page, timeout)

        self._logger.setLevel(logging.INFO)
        self._connection_id = 0
        self.transaction_id = 0
        self.port = tracker_address[1]
        self.ip_address = None
        self.expect_connection_response = True
        self.socket_mgr = socket_mgr
        self.ip_resolve_deferred = None

        # prepare connection message
        self._connection_id = UDP_TRACKER_INIT_CONNECTION_ID
        self.action = TRACKER_ACTION_CONNECT
        self.generate_transaction_id()

    def on_error(self, failure):
        """
        Handles the case when resolving an ip address fails.
        :param failure: The failure object thrown by the deferred.
        """
        self.failed(msg=failure.getErrorMessage())

    def _on_cancel(self, _):
        """
        :param _: The deferred which we ignore.
        This function handles the scenario of the session prematurely being cleaned up,
        most likely due to a shutdown.
        This function only should be called by the result_deferred.
        """
        self._logger.info(
            "The result deferred of this UDP tracker session is being cancelled due to a session cleanup. UDP url: %s",
            self.tracker_url)

    def on_ip_address_resolved(self, ip_address, start_scraper=True):
        """
        Called when a hostname has been resolved to an ip address.
        Constructs a scraper and opens a UDP port to listen on.
        Removes an old scraper if present.
        :param ip_address: The ip address that matches the hostname of the tracker_url.
        :param start_scraper: Whether we should start the scraper immediately.
        """
        self.ip_address = ip_address
        self.connect()

    def failed(self, msg=None):
        """
        This method handles everything that needs to be done when one step
        in the session has failed and thus no data can be obtained.
        """
        if self.result_deferred and not self.result_deferred.called and not self._is_failed:
            result_msg = "UDP tracker failed for url %s" % self._tracker_url
            if msg:
                result_msg += " (error: %s)" % msg
            self.result_deferred.errback(ValueError(result_msg))

        self._is_failed = True

    def generate_transaction_id(self):
        """
        Generates a unique transaction id and stores this in the _active_session_dict set.
        """
        while True:
            # make sure there is no duplicated transaction IDs
            transaction_id = random.randint(0, MAX_INT32)
            if transaction_id not in UdpTrackerSession._active_session_dict.items():
                UdpTrackerSession._active_session_dict[self] = transaction_id
                self.transaction_id = transaction_id
                break

    def remove_transaction_id(self):
        """
        Removes an session and its corresponding id from the _active_session_dict set and the socket manager.
        :param session: The session that needs to be removed from the set.
        """
        if self in UdpTrackerSession._active_session_dict:
            del UdpTrackerSession._active_session_dict[self]

        # Checking for socket_mgr is a workaround for race condition
        # in Tribler Session startup/shutdown that sometimes causes
        # unit tests to fail on teardown.
        if self.socket_mgr and self.transaction_id in self.socket_mgr.tracker_sessions:
            self.socket_mgr.tracker_sessions.pop(self.transaction_id)

    @inlineCallbacks
    def cleanup(self):
        """
        Cleans the session by cancelling all deferreds and closing sockets.
        :return: A deferred that fires once the cleanup is done.
        """
        yield super(UdpTrackerSession, self).cleanup()
        self.remove_transaction_id()
        # Cleanup deferred that fires when everything has been cleaned
        # Cancel the resolving ip deferred.
        self.ip_resolve_deferred = None

        self.result_deferred = None

        if self.timeout_call and self.timeout_call.active():
            self.timeout_call.cancel()

    def connect_to_tracker(self):
        """
        Connects to the tracker and starts querying for seed and leech data.
        :return: A deferred that will fire with a dictionary containing seed/leech information per infohash
        """
        # no more requests can be appended to this session
        self._is_initiated = True

        self.start_timeout()

        # clean old deferreds if present
        self.cancel_pending_task("result")
        self.cancel_pending_task("resolve")

        # Resolve the hostname to an IP address if not done already
        self.ip_resolve_deferred = self.register_task("resolve", reactor.resolve(self._tracker_address[0]))
        self.ip_resolve_deferred.addCallbacks(self.on_ip_address_resolved, self.on_error)

        self._last_contact = int(time.time())

        self.result_deferred = Deferred(self._on_cancel)
        return self.result_deferred

    def connect(self):
        """
        Creates a connection message and calls the socket manager to send it.
        """
        if not self.socket_mgr.transport:
            self.failed(msg="UDP socket transport not ready")
            return

        # Initiate the connection
        message = struct.pack('!qii', self._connection_id, self.action, self.transaction_id)
        self.socket_mgr.send_request(message, self)

    def handle_response(self, response):
        if self.is_failed:
            return

        if self.expect_connection_response:
            if self.timeout_call and self.timeout_call.active():
                self.timeout_call.cancel()

            self.handle_connection_response(response)
            self.expect_connection_response = False
        else:
            self.handle_scrape_response(response)

    def handle_connection_response(self, response):
        """
        Handles the connection response from the UDP tracker and queries
        it immediately for seed/leech data per infohash
        :param response: The connection response from the UDP tracker
        """
        # check message size
        if len(response) < 16:
            self._logger.error(u"%s Invalid response for UDP CONNECT: %s", self, repr(response))
            self.failed(msg="invalid response size")
            return

        # check the response
        action, transaction_id = struct.unpack_from('!ii', response, 0)
        if action != self.action or transaction_id != self.transaction_id:
            # get error message
            errmsg_length = len(response) - 8
            error_message, = struct.unpack_from('!' + str(errmsg_length) + 's', response, 8)

            self._logger.info(u"%s Error response for UDP CONNECT [%s]: %s",
                              self, repr(response), repr(error_message))
            self.failed(msg=error_message.decode('utf8', errors='ignore'))
            return

        # update action and IDs
        self._connection_id = struct.unpack_from('!q', response, 8)[0]
        self.action = TRACKER_ACTION_SCRAPE
        self.generate_transaction_id()

        # pack and send the message
        if sys.version_info.major > 2:
            infohash_list = self._infohash_list
        else:
            infohash_list = [str(infohash) for infohash in self._infohash_list]

        fmt = '!qii' + ('20s' * len(self._infohash_list))
        message = struct.pack(fmt, self._connection_id, self.action, self.transaction_id, *infohash_list)

        # Send the scrape message
        self.socket_mgr.send_request(message, self)

        self._last_contact = int(time.time())

    def handle_scrape_response(self, response):
        """
        Handles the scrape response from the UDP tracker.
        :param response: The response from the UDP tracker
        """
        # check message size
        if len(response) < 8:
            self._logger.info(u"%s Invalid response for UDP SCRAPE: %s", self, repr(response))
            self.failed("invalid message size")
            return

        # check response
        action, transaction_id = struct.unpack_from('!ii', response, 0)
        if action != self.action or transaction_id != self.transaction_id:
            # get error message
            errmsg_length = len(response) - 8
            error_message, = struct.unpack_from('!' + str(errmsg_length) + 's', response, 8)

            self._logger.info(u"%s Error response for UDP SCRAPE: [%s] [%s]",
                              self, repr(response), repr(error_message))
            self.failed(msg=error_message.decode('utf8', errors='ignore'))
            return

        # get results
        if len(response) - 8 != len(self._infohash_list) * 12:
            self._logger.info(u"%s UDP SCRAPE response mismatch: %s", self, len(response))
            self.failed(msg="invalid response size")
            return

        offset = 8

        response_list = []

        for infohash in self._infohash_list:
            complete, _downloaded, incomplete = struct.unpack_from('!iii', response, offset)
            offset += 12

            # Store the information in the hash dict to be returned.
            # Sow complete as seeders. "complete: number of peers with the entire file, i.e. seeders (integer)"
            #  - https://wiki.theory.org/BitTorrentSpecification#Tracker_.27scrape.27_Convention
            response_list.append({'infohash': hexlify(infohash),
                                  'seeders': complete, 'leechers': incomplete})

        # close this socket and remove its transaction ID from the list
        self.remove_transaction_id()
        self._is_finished = True

        if self.result_deferred and not self.result_deferred.called:
            self.result_deferred.callback({self.tracker_url: response_list})


class FakeDHTSession(TrackerSession):
    """
    Fake TrackerSession that manages DHT requests
    """

    def __init__(self, session, infohash, timeout):
        super(FakeDHTSession, self).__init__(u'DHT', u'DHT', u'DHT', u'DHT', timeout)

        self.result_deferred = Deferred()
        self.infohash = infohash
        self._session = session

    def cleanup(self):
        """
        Cleans the session by cancelling all deferreds and closing sockets.
        :return: A deferred that fires once the cleanup is done.
        """
        self._infohash_list = None
        self._session = None
        # Return a defer that immediately calls its callback
        return defer.succeed(None)

    def add_infohash(self, infohash):
        """
        This function adds a infohash to the request list.
        :param infohash: The infohash to be added.
        """
        self.infohash = infohash

    def connect_to_tracker(self):
        """
        Fakely connects to a tracker.
        :return: A deferred that fires with the health information.
        """
        def on_metainfo(metainfo):
            if not metainfo:
                self.result_deferred.errback(Failure(RuntimeError("Metainfo lookup error")))
                return

            self.result_deferred.callback({
                "DHT": [{
                    "infohash": hexlify(self.infohash),
                    "seeders": metainfo[b"seeders"],
                    "leechers": metainfo[b"leechers"]
                }]
            })

        self._session.lm.ltmgr.get_metainfo(self.infohash, timeout=self.timeout).addCallback(on_metainfo)
        return self.result_deferred


class FakeBep33DHTSession(FakeDHTSession):
    """
    Fake session for a BEP33 lookup.
    """

    def connect_to_tracker(self):
        """
        Fakely connects to a tracker.
        :return: A deferred that fires with the health information.
        """
        return self._session.lm.ltmgr.dht_health_manager.get_health(self.infohash)
