#!/usr/bin/env python3

#
# gPodder: Media and podcast aggregator
# Copyright (c) 2005-2014 Thomas Perl and the gPodder Team
#
# gPodder 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.
#
# gPodder 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 this program.  If not, see <http://www.gnu.org/licenses/>.
#

SERVER_ADDRESS = ("localhost", 9807)
SSL = True
# The hash is generated using hashlib.sha256(b"user:password").hexdigest()
AUTH = {"gpodder": "e9a0061df8abac377bf453616531baf1d223493e70a05ea8293c5d38c41da911",
        "admin": "bb1fb0facf769d730600254116a5ccce4a6c0f1756788fda142f063cd1802aa3"}

import sys
import os
import logging
import traceback
import time
import json
import base64
import binascii
import shutil
import hashlib

import ssl
import http.server
import socketserver
import urllib.parse

gpodder_script = sys.argv[0]
if os.path.islink(gpodder_script):
    gpodder_script = os.readlink(gpodder_script)
script_dir = os.path.dirname(gpodder_script)
gpodder_dir = os.path.join(script_dir, '..', 'gpodder-core')
prefix = os.path.abspath(os.path.normpath(gpodder_dir))

src_dir = os.path.join(prefix, 'src')

if os.path.exists(os.path.join(src_dir, 'gpodder', '__init__.py')):
    # Run gPodder from local source folder (not installed)
    sys.path.insert(0, src_dir)

import gpodder
from gpodder import core
from gpodder import opml
from gpodder import util
from gpodder import registry
from gpodder import download

# A poor man's argparse/getopt - but it works for our use case :)
verbose = False
for flag in ('-v', '--verbose'):
    if flag in sys.argv:
        sys.argv.remove(flag)
        verbose = True
        break

def jsonresp(response="", status=200, msg=None, headers=None):
    if not msg:
        msg = None
    if not headers:
        respheaders = {"Content-Type": "application/json; charset=utf-8"}
    else:
        respheaders = {"Content-Type": "application/json; charset=utf-8"}
        respheaders.update(headers)
    jsoncontent = json.dumps({"status": status, "msg": msg,
        "response": response}, indent=2).encode('utf-8')
    return {"content": jsoncontent, "status": status, "msg": msg,
            "headers": respheaders}

class GpoApi(object):

    def __init__(self, verbose=False):
        self.core = core.Core(verbose=verbose)
        self._db = self.core.db
        self._config = self.core.config
        self._model = self.core.model
        self._queue = download.DownloadQueueManager(self._config)

    # -------------------------------------------------------------------

    def _all_podcasts(self):
        """Returns a list of all Podcasts"""
        return self._model.get_podcasts()

    def _all_episodes(self):
        """Returns a list of all Episodes"""
        episodes = []
        for podcast in self._model.get_podcasts():
            for episode in podcast.episodes:
                episodes.append(episode)
        return episodes

    def _get_podcast_id(self, podcast_id):
        """Returns the podcast with the given id"""
        for podcast in self._all_podcasts():
            if podcast.id == podcast_id:
                return podcast

        raise ServerError(404, 
            "Podcast-ID {} not found".format(podcast_id))

    def _get_podcasts_id(self, podcast_ids):
        """Returns a list of podcasts with the given ids"""
        missing_ids = list(podcast_ids)
        podcasts = []
        for podcast in self._all_podcasts():
            if podcast.id in podcast_ids:
                podcasts.append(podcast)
                missing_ids.remove(podcast.id)

        if missing_ids:
            raise ServerError(404, "Podcast-IDs {} not found".format(
                self._join_ids(missing_ids)))
        else:
            return podcasts

    def _get_podcast_url(self, url, check_only=False):
        """Returns the podcast with the given id"""
        url = self.core.model.normalize_feed_url(url)
        for podcast in self._model.get_podcasts():
            if podcast.url == url:
                return podcast

        if not check_only:
            raise ServerError(404, "Not subscribed to {url}".format(
                url=url))
        else:
            return None

    def _get_episode(self, episode_id, check_only=False):
        """Returns the episode with the given id"""
        for podcast in self._model.get_podcasts():
            for episode in podcast.episodes:
                if episode.id == episode_id:
                    return episode
        if not check_only:
            raise ServerError(404, "Episode-ID {} not found".format(
                episode_id))
        else:
            return None

    def _get_episodes(self, episode_ids):
        """Returns a list of episodes with the give ids"""
        missing_ids = list(episode_ids)
        episodes = []
        for podcast in self._model.get_podcasts():
            for episode in podcast.episodes:
                if episode.id in missing_ids:
                    episodes.append(episode)
                    missing_ids.remove(episode.id)

        if missing_ids:
            raise ServerError(404, "Episode-IDs {} not found".format(
                self._join_ids(missing_ids)))
        else:
            return episodes

    # -------------------------------------------------------------------

    def _podcast_properties(self, podcast):
        """Returns a dictionary with all podcast properties"""
        return {"id": podcast.id,
                "url": podcast.url,
                "title": podcast.title,
                "link": podcast.link,
                "description": podcast.description,
                "cover_url": podcast.cover_url,
                "payment_url": podcast.payment_url,
                "http_last_modified": podcast.http_last_modified,
                "http_etag": podcast.http_etag,
                "auto_archive_episodes": podcast.auto_archive_episodes,
                "download_folder": podcast.download_folder,
                "pause_subscription": podcast.pause_subscription,
                "section": podcast.section,
                "download_strategy": podcast.download_strategy}

    def _episode_properties(self, episode):
        """Return a dictionary with all episode properties"""
        return {"id": episode.id,
                "podcast_id": episode.podcast_id,
                "url": episode.url,
                "title": episode.title,
                "file_size": episode.file_size,
                "mime_type": episode.mime_type,
                "guid": episode.guid,
                "description": episode.description,
                "link": episode.link,
                "published": episode.published,
                "downloading": episode.downloading,
                "download_filename": episode.download_filename,
                "download_progress": episode.download_progress(),
                "payment_url": episode.payment_url,
                "state": episode.state,
                "is_new": episode.is_new,
                "archive": episode.archive,
                "total_time": episode.total_time,
                "chapters": episode.chapters,
                "current_position": episode.current_position,
                "last_playback": episode.last_playback}

    def _prop_dict(self, object_list, prop_function, values=None):
        """Returns a dictionary with the requested properties of each 
           podcast or episode"""
        object_dict = {}
        for obj in object_list:
            obj_prop = prop_function(obj)
            if values == None:
                prop_dict = obj_prop
            else:
                prop_dict = {}
                for value in values:
                    if value in obj_prop:
                        prop_dict[value] = obj_prop[value]
                    else:
                        raise ServerError(400, 
                            "Property not found: {}".format(value))
            object_dict[obj.id] = prop_dict
        return object_dict
    
    def _podcast_dict(self, podcast_list, values=None):
        """Calls the _prop_dict function for a list of podcasts"""
        return self._prop_dict(podcast_list, self._podcast_properties, values)
    
    def _episode_dict(self, episode_list, values=None):
        """Calls the _prop_dict function for a list of episodes"""
        return self._prop_dict(episode_list, self._episode_properties, values)

    # -------------------------------------------------------------------

    def _create_podcast(self, url):
        """Creates a podcast channel from an url and adds it to the 
           podcast model"""
        url = self.core.model.normalize_feed_url(url)
        return self._model.load_podcast(url, create=True)

    def _update_podcast(self, podcast):
        """Updates the podcasts with the given ids"""
        try:
            update_status = podcast.update()
            return update_status
        except Exception:
            return None

    def _subscribe(self, url, title=None):
        """Subscribes to a podcast url"""
        existing = self._get_podcast_url(url, check_only=True)
        if existing is not None:
            raise ServerError(400, 
                "Already subscribed to {url}".format(url=existing.url))

        try:
            podcast = self._create_podcast(url)
            if podcast is None:
                raise ServerError(500, 
                    "Subscription to {url} failed".format(url=url))

            if title is not None:
                podcast.rename(title)
            podcast.save()

            return podcast
        except Exception as e:
            logger.warn('Cannot subscribe: %s', e, exc_info=True)
            raise ServerError(500, 
                getattr(e, 'strerror', str(e)))

    def _download_episode(self, episode):
        """Creates a download task from an episode and adds it to the
           download queue"""
        task = download.DownloadTask(episode)
        task.status = download.DownloadTask.QUEUED
        self._queue.add_task(task, force_start=False)

    # -------------------------------------------------------------------
    
    def _parse_arg(self, query, name, conv=None, optional=False, single=False):
        """Parses an argument and can converts its values using a
           passed conv function"""
        if name not in query:
            if not optional:
                raise ServerError(400, "{} argument is missing".format(name))
            else:
                return None

        query_list = []
        for part in query[name]:
            query_list += part.split(",")
        arg_list = []
        for split_string in query_list:
            if conv is None:
                arg_list.append(split_string)
            else:
                try:
                    arg_list.append(conv(split_string))
                except ValueError:
                    raise ServerError(400, "Invalid argument type {}".format(split_string))

        if single:
            return arg_list[0]
        else:
            return arg_list

    def _join_ids(self, ids):
        """Joins all passed ids to a comma seperated string"""
        return ", ".join(str(x) for x in ids)

    def _atexit(self):
        """Saves the config, db and shuts the core down"""
        self.core.shutdown()

    # -------------------------------------------------------------------

    def podcasts(self, request):
        pod_ids = self._parse_arg(request.query, "id", int, optional=True)
        req_prop = self._parse_arg(request.query, "prop", None, optional=True)
        if pod_ids is not None:
            podcasts = self._get_podcasts_id(pod_ids)
        else:
            podcasts = self._all_podcasts()
        return jsonresp(self._podcast_dict(podcasts, req_prop))

    def auth(self, request):
        pod_ids = self._parse_arg(request.query, "id", int)
        response_dict = {}
        for podcast in self._get_podcasts_id(pod_ids):
            response_dict[podcast.id] = {
                "auth_username": podcast.auth_username,
                "auth_password": podcast.auth_password}
        return jsonresp(response_dict)

    def subscribe(self, request):
        url_arg = self._parse_arg(request.query, "url", single=True)
        url = self.core.model.normalize_feed_url(url_arg)
        title = self._parse_arg(request.query, "title", optional=True, single=True)

        podcast = self._subscribe(url, title)
        podcast.save()
        return jsonresp(self._podcast_dict([podcast]),
            msg="Resolved feed URL: {url}".format(url=podcast.url))

    def unsubscribe(self, request):
        pod_ids = self._parse_arg(request.query, "id", int)

        podcasts = self._get_podcasts_id(pod_ids)
        for podcast in podcasts:
            podcast.unsubscribe()
        return jsonresp()

    def enable(self, request):
        pod_ids = self._parse_arg(request.query, "id", int)
        podcasts = self._get_podcasts_id(pod_ids)

        for podcast in podcasts:
            if podcast.pause_subscription:
                podcast.pause_subscription = False
                podcast.save()
        
        return jsonresp(self._podcast_dict(podcasts, ["pause_subscription"]))

    def disable(self, request):
        pod_ids = self._parse_arg(request.query, "id", int)
        podcasts = self._get_podcasts_id(pod_ids)

        for podcast in podcasts:
            if not podcast.pause_subscription:
                podcast.pause_subscription = True
                podcast.save()

        return jsonresp(self._podcast_dict(podcasts, ["pause_subscription"]))

    def update(self, request):
        pod_ids = self._parse_arg(request.query, "id", int, optional=True)
        id_status = {"updated":[], "unchanged":[],
                     "failed":[], "paused":[]}
        if pod_ids is not None:
            podcasts = self._get_podcasts_id(pod_ids)
        else:
            podcasts = self._all_podcasts()

        for podcast in podcasts:
            if not podcast.pause_subscription:
                update_status = self._update_podcast(podcast)
                if update_status == True:
                    id_status["updated"].append(podcast.id)
                elif update_status == False:
                    id_status["unchanged"].append(podcast.id)
                else:
                    id_status["failed"].append(podcast.id)
            else:
                id_status["paused"].append(podcast.id)

        return jsonresp(id_status)

    def rewrite(self, request):
        pod_id = self._parse_arg(request.query, "id", int, single=True)

        url_arg = self._parse_arg(request.query, "url", single=True)
        url = self.core.model.normalize_feed_url(url_arg)

        podcast = self._get_podcast_id(pod_id)
        old_url = podcast.url
        new_url = podcast.rewrite_url(url)
        podcast.save()
        if new_url is None:
            raise ServerError(400, "Invalid URL: {}".format(url))
        else:
            return jsonresp(self._podcast_dict([podcast], ["url"]))

    def rename(self, request):
        pod_id = self._parse_arg(request.query, "id", int, single=True)
        title = self._parse_arg(request.query, "title", single=True)

        podcast = self._get_podcast_id(pod_id)
        old_title = podcast.title
        podcast.rename(title)
        podcast.save()
        return jsonresp(self._podcast_dict([podcast], ["title"]))

    # -------------------------------------------------------------------

    def episodes(self, request):
        episode_ids = self._parse_arg(request.query, "id", int, optional=True)
        podcast_ids = self._parse_arg(request.query, "podcast", int, optional=True)
        req_prop = self._parse_arg(request.query, "prop", None, optional=True)
        episodes = []
        if podcast_ids is not None:
            for podcast in self._get_podcasts_id(podcast_ids):
                episodes += podcast.episodes
        if episode_ids is not None:
            episodes += self._get_episodes(episode_ids)
        if episode_ids is None and podcast_ids is None:
            episodes = self._all_episodes()
        return jsonresp(self._episode_dict(episodes, req_prop))

    def download(self, request):
        episode_ids = self._parse_arg(request.query, "id", int)
        episodes = self._get_episodes(episode_ids)

        for episode in episodes:
            self._download_episode(episode)
        return jsonresp(self._episode_dict(episodes, ["downloading"]))

    def cancel(self, request):
        episode_ids = self._parse_arg(request.query, "id", int)

        episodes = self._get_episodes(episode_ids)
        for episode in episodes:
            if episode.downloading:
                episode.download_task.cancel()

        return jsonresp(self._episode_dict(episodes, ["state"]))

    def remove(self, request):
        episode_ids = self._parse_arg(request.query, "id", int)
        episodes = self._get_episodes(episode_ids)
        for episode in episodes:
            episode.delete()
        return jsonresp()

    def new(self, request):
        episode_ids = self._parse_arg(request.query, "id", int)
        episodes = self._get_episodes(episode_ids)
        changed_ids = []

        for episode in episodes:
            if not episode.is_new:
                episode.is_new = True
                changed_ids.append(episode.id)
            if episode.is_new and episode.state == gpodder.STATE_DELETED:
                episode.state = gpodder.STATE_NORMAL
            episode.save()
        return jsonresp(self._episode_dict(episodes, ["is_new"]))

    def old(self, request):
        episode_ids = self._parse_arg(request.query, "id", int)
        episodes = self._get_episodes(episode_ids)
        changed_ids = []

        for episode in episodes:
            if episode.is_new:
                episode.is_new = False
                changed_ids.append(episode.id)
            episode.save()
        return jsonresp(self._episode_dict(episodes, ["is_new"]))
    
    def played(self, request):
        episode_id = self._parse_arg(request.query, "id", int, single=True)
        position = self._parse_arg(request.query, "position", int, single=True)
        
        now = time.time()
        episode = self._get_episode(episode_id)
        episode.current_position = position
        episode.current_position_updated = now
        episode.last_playback = now
        episode.save()
        return jsonresp(self._episode_dict([episode], 
            ["total_time", "current_position", "last_playback"]))

    # -------------------------------------------------------------------
    
    def registry(self, request):
        return jsonresp(registry.dump_dict())

    def version(self, request):
        up_to_date, latest_version, releasedate, __ = util.get_update_info()

        info = {'latestversion': latest_version,
                'latestdate': releasedate,
                'url': gpodder.__url__,
                'thisversion': gpodder.__version__,
                'thisdate': gpodder.__date__}

        return jsonresp(info)

    def config(self, request):
        config_dict = {}
        for key in self._config.all_keys():
            value = self._config._lookup(key)
            config_dict[key] = value

        return jsonresp(config_dict)

    def set(self, request):
        key = self._parse_arg(request.query, "key", single=True)
        value = self._parse_arg(request.query, "value", single=True)

        if key not in self._config.all_keys():
            raise ServerError(400, 
                "Configuration option {key} not found".format(key=key))
        else:
            current_value = self._config._lookup(key)
            current_type = type(current_value)
        if current_type == dict:
            raise ServerError(400, 
                "Invalid configuration option: {key}".format(key=key))

        self._config.update_field(key, value)

        return self.config()

    def save(self, request):
        self.core.save()
        return jsonresp()

    def importurl(self, request):
        url_arg = self._parse_arg(request.query, "url", single=True)
        url = self.core.model.normalize_feed_url(url_arg)

        for channel in opml.Importer(url).items:
            self.subscribe(channel['url'], channel.get('title'))
        return jsonresp()

    def importfile(self, request):
        try:
            opmlstring = request.rfile.read().decode("utf-8")
        except UnicodeError:
            raise ServerError(400, msg="Could not decode input file")
        for channel in opml.Importer(url=None, opmlstr=opmlstring).items:
            self.subscribe(channel['url'], channel.get('title'))
        return jsonresp()

    def export(self, request):
        podcasts = self._model.get_podcasts()
        opmlstr = opml.Exporter(None).write(podcasts)
        return {
            "content": opmlstr,
            "status": 200,
            "msg": "OK",
            "headers": {"Content-Type": "application/xml; charset=utf-8",
                "Content-Disposition": 
                    "attachment; filename=gpodder_export.opml"
                }
            }

    def files(self, request):
        episode_id = episode_ids = self._parse_arg(request.query, "id", int, single=True)
        episode = self._get_episode(episode_id)
        filename = episode.local_filename(create=False)
        if filename is None:
            return jsonresp(status=404, msg="Episode {} not downloaded".format(episode_id))
        f = open(filename, "rb")
        try:
            fs = os.fstat(f.fileno())
            request.send_response(200)
            request.send_header("Content-Type", episode.mime_type)
            request.send_header("Content-Disposition", 
                "attachment; filename={}".format(episode.download_filename))
            request.end_headers()
            shutil.copyfileobj(f, request.wfile)
            return None
        finally:
            f.close()


class ServerError(Exception):
    def __init__(self, code, msg):
        self.code = code
        self.msg = msg
    def __str__(self):
        return "{name}: {desc}".format(
            name=http.server.BaseHTTPRequestHandler.responses[self.code], 
                desc=self.msg)

class Server(socketserver.ThreadingMixIn, http.server.HTTPServer):
    pass

class Handler(http.server.BaseHTTPRequestHandler):
    def call_api(self):
        if self.auth():
            for route in routes:
                if self.path.startswith(route):
                    func = routes[route]
                    self.urlparse = urllib.parse.urlparse(self.path)
                    self.query = urllib.parse.parse_qs(self.urlparse.query)
                    self.respond(func(self))
                    return
        else:
            self.respond(jsonresp(status=401, 
                headers={"WWW-Authenticate": 'Basic realm="gpodder-api"'}))
        self.respond(jsonresp(status=404, msg="API-Method does not exist"))

    def errorhandler(self, exception):
        if type(exception) == ServerError:
            self.respond(jsonresp(status=exception.code, msg=str(exception)))
        else:
            self.respond(jsonresp(status=500, msg="{name}: {desc}".format(
                name=type(exception).__name__, desc=str(exception))))
            traceback.print_exc()
    
    def auth(self):
        if "Authorization" not in self.headers:
            return False
        try:
            authstr = self.headers["Authorization"].split()[-1]
            unbase = base64.b64decode(authstr)
            decoded = unbase.decode("utf-8")
        except (UnicodeError, binascii.Error):
            raise ServerError(403, "Could not decode authorization header")
        
        user = decoded.split(":", 1)[0]
        pwdhash = hashlib.sha256(unbase).hexdigest()
        
        if user in AUTH and pwdhash == AUTH[user]:
            return True
        else:
            return False
    
    def respond(self, response):
        if response is None:
            return
        if "content" in response:
            content = response["content"]
        else:
            self.respond(jsonresp(status=500, msg="Method did not return any data"))
            return
        if "status" in response:
            status = response["status"]
        else:
            status = 200
        if "msg" in response:
            msg = response["msg"]
        else:
            msg = None
        if "headers" in response:
            headers = response["headers"]
        else:
            headers = {}
        
        self.send_response(status, msg)
        for keyword, value in headers.items():
            self.send_header(keyword, value)
        self.end_headers()
        self.wfile.write(content)
    
    def do_GET(self):
        try:
            self.call_api()
        except Exception as error:
            self.errorhandler(error)
    
    def do_POST(self):
        try:
            self.call_api()
        except Exception as error:
            self.errorhandler(error)

routes = {}

def add_route(func, path=None):
    if path is None:
        path = "/api/" + func.__name__
    routes[path] = func

def add_routes():
    add_route(api.podcasts)
    add_route(api.auth)
    add_route(api.subscribe)
    add_route(api.unsubscribe)
    add_route(api.enable)
    add_route(api.disable)
    add_route(api.update)
    add_route(api.rewrite)
    add_route(api.rename)
    add_route(api.episodes)
    add_route(api.download)
    add_route(api.cancel)
    add_route(api.remove)
    add_route(api.new)
    add_route(api.old)
    add_route(api.played)
    add_route(api.registry)
    add_route(api.version)
    add_route(api.config)
    add_route(api.set)
    add_route(api.importurl)
    add_route(api.importfile)
    add_route(api.export)
    add_route(api.save)
    add_route(api.files, "/files")


if __name__ == '__main__':
    #logger = logging.getLogger(__name__)
    httpd = Server(SERVER_ADDRESS, Handler)
    if SSL:
        httpd.socket = ssl.wrap_socket(httpd.socket, server_side=True, 
            certfile="cert.pem")
    api = GpoApi(verbose)
    add_routes()
    print("Starting HTTP server")
    httpd.serve_forever()
