"""Bounded, authenticated loopback backend. Apache provides public HTTPS."""
import hmac
import json
import os
import socket
import threading
import time
from collections import OrderedDict, deque
from http.server import BaseHTTPRequestHandler, HTTPServer
from socketserver import ThreadingMixIn
from urllib.parse import urlsplit
from catalog import Addon
from provider import ProviderError


class RateLimiter:
    def __init__(self, limit=240):
        self.limit = limit
        self.clients = OrderedDict()
        self.guard = threading.Lock()

    def allowed(self, client):
        now = time.monotonic()
        with self.guard:
            entries = self.clients.setdefault(client, deque())
            self.clients.move_to_end(client)
            while entries and now-entries[0] >= 60:
                entries.popleft()
            if len(entries) >= self.limit:
                return False
            entries.append(now)
            while len(self.clients) > 1024:
                self.clients.popitem(last=False)
            return True


class Server(ThreadingMixIn, HTTPServer):
    daemon_threads = True
    request_queue_size = 32

    def __init__(self, address, handler, token, addon=None):
        self.token = token
        public=os.getenv('MOVIEBOX_PUBLIC_BASE','https://addon.example.com').rstrip('/')
        self.addon = addon or Addon(playback_base=public+'/moviebox/'+token)
        self.slots = threading.BoundedSemaphore(8)
        self.rate = RateLimiter(int(os.getenv('MOVIEBOX_RATE_PER_IP', '240')))
        self.failures = OrderedDict()
        self.fail_guard = threading.Lock()
        super().__init__(address, handler)

    def process_request(self, request, address):
        if not self.slots.acquire(blocking=False):
            try:
                request.settimeout(1)
                request.sendall(b'HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nRetry-After: 3\r\nContent-Length: 0\r\n\r\n')
            except OSError:
                pass
            self.shutdown_request(request)
            return
        try:
            super().process_request(request, address)
        except Exception:
            self.slots.release()
            raise

    def process_request_thread(self, request, address):
        try:
            super().process_request_thread(request, address)
        finally:
            self.slots.release()

    def handle_error(self, request, address):
        # No raw request path, cookie, URL or exception traceback in logs.
        print('Request handler failed; request details suppressed', flush=True)


class Handler(BaseHTTPRequestHandler):
    timeout = 20
    server_version = 'MovieBox-Addon'
    sys_version = ''

    def log_message(self, *_):
        pass

    def authorized_route(self):
        if len(self.path) > 2048:
            return None
        parts = urlsplit(self.path).path.split('/', 2)
        if len(parts) != 3:
            return None
        try:
            valid = hmac.compare_digest(parts[1], self.server.token)
        except TypeError:
            valid = False
        return '/' + parts[2] if valid else None

    def reply(self, status, payload, retry=0):
        body = json.dumps(payload, ensure_ascii=False).encode()
        self.send_response(status)
        for key, value in [('Content-Type', 'application/json; charset=utf-8'), ('Content-Length', str(len(body))), ('Cache-Control', 'no-store'), ('Access-Control-Allow-Origin', '*'), ('X-Content-Type-Options', 'nosniff')]:
            self.send_header(key, value)
        if retry:
            self.send_header('Retry-After', str(retry))
        self.end_headers()
        if self.command != 'HEAD':
            try:
                self.wfile.write(body)
            except (BrokenPipeError, ConnectionResetError):
                pass

    def do_GET(self):
        route = self.authorized_route()
        if route is None:
            self.reply(404, {'error': 'Not found'})
            return
        if urlsplit(self.path).query:
            self.reply(400, {'error': 'Use Stremio path parameters'})
            return
        # Only Apache can reach the backend. Its appended client address is last.
        client = self.headers.get('X-Forwarded-For', self.client_address[0]).split(',')[-1].strip()[:64]
        if not self.server.rate.allowed(client):
            self.reply(429, {'error': 'Request limit exceeded', 'streams': []}, 60)
            return
        now = time.monotonic()
        with self.server.fail_guard:
            previous = self.server.failures.get(route)
        if previous and previous[0] > now:
            self.reply(previous[1], previous[2], max(1,int(previous[0]-now)))
            return
        try:
            if route.startswith('/dash/'):
                body=self.server.addon.quality.manifest(route)
                self.send_response(200)
                for key,value in [('Content-Type','application/dash+xml'),('Content-Length',str(len(body))),('Cache-Control','no-store'),('Access-Control-Allow-Origin','*'),('X-Content-Type-Options','nosniff')]:
                    self.send_header(key,value)
                self.end_headers()
                if self.command!='HEAD':
                    try: self.wfile.write(body)
                    except (BrokenPipeError,ConnectionResetError): pass
                return
            response = self.server.addon.dispatch(route)
            self.reply(200, response)
        except LookupError:
            self.reply(404, {'error': 'Not found'})
        except (ValueError, TypeError):
            self.reply(400, {'error': 'Invalid request parameters'})
        except ProviderError as error:
            status = 429 if error.status == 429 else 502
            retry = error.retry_after if status == 429 else 15
            payload = {'error': str(error), 'streams': []}
            with self.server.fail_guard:
                self.server.failures[route] = (time.monotonic()+retry, status, payload)
                self.server.failures.move_to_end(route)
                while len(self.server.failures) > 128:
                    self.server.failures.popitem(last=False)
            self.reply(status, payload, retry)
        except Exception:
            print('Adapter failure; details suppressed', flush=True)
            self.reply(500, {'error': 'Source lookup failed', 'streams': []})

    do_HEAD = do_GET

    def do_OPTIONS(self):
        if self.authorized_route() is None:
            self.reply(404, {'error': 'Not found'})
            return
        self.send_response(204)
        self.send_header('Access-Control-Allow-Origin', '*')
        self.send_header('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS')
        self.send_header('Access-Control-Allow-Headers', 'Content-Type')
        self.send_header('Content-Length', '0')
        self.end_headers()


def make_server(port=8766, token=None, addon=None):
    token = token or os.getenv('MOVIEBOX_TOKEN') or os.getenv('MOVIEBOX_TEST_TOKEN')
    if not token or len(token) < 32:
        raise RuntimeError('Set MOVIEBOX_TOKEN to a strong random token (32+ characters)')
    return Server(('127.0.0.1', port), Handler, token, addon)


if __name__ == '__main__':
    server = make_server(int(os.getenv('MOVIEBOX_PORT','8766')))
    print('MovieBox 1.1.3 listening on loopback', flush=True)
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        pass
    finally:
        server.server_close()
