# coding: utf-8
"""
This module contains the definition of the web app API routes.
"""
from __future__ import (
    absolute_import, division, print_function, unicode_literals
)

import datetime
import itertools
import json
import re

import bottle
import vobject

import flatisfy.data
from flatisfy.models import flat as flat_model
from flatisfy.models.postal_code import PostalCode

FILTER_RE = re.compile(r"filter\[([A-z0-9_]+)\]")


def JSONError(error_code, error_str):  # pylint: disable=invalid-name
    """
    Return an HTTP error with a JSON payload.

    :param error_code: HTTP error code to return.
    :param error_str: Error as a string.
    :returns: Set correct response parameters and returns JSON-serialized error
        content.
    """
    bottle.response.status = error_code
    bottle.response.content_type = "application/json"
    return json.dumps(dict(error=error_str, status_code=error_code))


def _JSONApiSpec(query, model, default_sorting=None):
    """
    Implementing JSON API spec for filtering, sorting and paginating results.

    :param query: A Bottle query dict.
    :param model: Database model used in this query.
    :param default_sorting: Optional field to sort on if no sort options are
        passed through parameters.
    :return: A tuple of filters, page number, page size (items per page) and
        sorting to apply.
    """
    # Handle filtering according to JSON API spec
    filters = {}
    for param in query:
        filter_match = FILTER_RE.match(param)
        if not filter_match:
            continue
        field = filter_match.group(1)
        value = query[filter_match.group(0)]
        filters[field] = value

    # Handle pagination according to JSON API spec
    page_number, page_size = 0, None
    try:
        if 'page[size]' in query:
            page_size = int(query['page[size]'])
            assert page_size > 0
        if 'page[number]' in query:
            page_number = int(query['page[number]'])
            assert page_number >= 0
    except (AssertionError, ValueError):
        raise ValueError("Invalid pagination provided.")

    # Handle sorting according to JSON API spec
    sorting = []
    if 'sort' in query:
        for index in query['sort'].split(','):
            try:
                sort_field = getattr(model, index.lstrip('-'))
            except AttributeError:
                raise ValueError(
                    "Invalid sorting key provided: {}.".format(index)
                )
            if index.startswith('-'):
                sort_field = sort_field.desc()
            sorting.append(sort_field)
    # Default sorting options
    if not sorting and default_sorting:
        try:
            sorting.append(getattr(model, default_sorting))
        except AttributeError:
            raise ValueError(
                "Invalid default sorting key provided: {}.".format(
                    default_sorting
                )
            )

    return filters, page_number, page_size, sorting


def _serialize_flat(flat, config):
    """
    Serialize a flat for JSON API.

    Converts it to a JSON-representable dict and add postal code metadata.

    :param flat: An SQLAlchemy Flat object.
    :param config: A config dict.
    :returns: A flat dict ready to be serialized.
    """
    flat = flat.json_api_repr()

    postal_codes = {}
    for constraint_name, constraint in config["constraints"].items():
        postal_codes[constraint_name] = flatisfy.data.load_data(
            PostalCode, constraint, config
        )

    try:
        assert flat["flatisfy_postal_code"]

        postal_code_data = next(
            x
            for x in postal_codes.get(flat["flatisfy_constraint"], [])
            if x.postal_code == flat["flatisfy_postal_code"]
        )
        flat["flatisfy_postal_code"] = {
            "postal_code": flat["flatisfy_postal_code"],
            "name": postal_code_data.name,
            "gps": (postal_code_data.lat, postal_code_data.lng)
        }
    except (AssertionError, StopIteration):
        flat["flatisfy_postal_code"] = {}

    return flat


def index_v1():
    """
    API v1 index route.

    Example::

        GET /api/v1/
    """
    return {
        "opendata": "/api/v1/opendata",
        "flats": "/api/v1/flats",
        "flat": "/api/v1/flat/:id",
        "search": "/api/v1/search",
        "ics": "/api/v1/ics/visits.ics",
        "time_to_places": "/api/v1/time_to_places"
    }


def flats_v1(config, db):
    """
    API v1 flats route.

    Example::

        GET /api/v1/flats

    .. note::

        Filtering can be done through the ``filter`` GET param, according
        to JSON API spec (http://jsonapi.org/recommendations/#filtering).

    .. note::

        By default no pagination is done. Pagination can be forced using
        ``page[size]`` to specify a number of items per page and
        ``page[number]`` to specify which page to return. Pages are numbered
        starting from 0.

    .. note::

        Sorting can be handled through the ``sort`` GET param, according to
        JSON API spec (http://jsonapi.org/format/#fetching-sorting).

    :return: The available flats objects in a JSON ``data`` dict.
    """
    if bottle.request.method == 'OPTIONS':
        # CORS
        return ''

    try:
        try:
            filters, page_number, page_size, sorting = _JSONApiSpec(
                bottle.request.query,
                flat_model.Flat,
                default_sorting='cost'
            )
        except ValueError as exc:
            return JSONError(400, str(exc))

        # Build flat list
        db_query = (
            db.query(flat_model.Flat).filter_by(**filters).order_by(*sorting)
        )
        flats = [
            _serialize_flat(flat, config)
            for flat in itertools.islice(
                db_query,
                page_number * page_size if page_size else None,
                page_number * page_size + page_size if page_size else None
            )
        ]
        return {
            "data": flats,
            "page": page_number,
            "items_per_page": page_size if page_size else len(flats)
        }
    except Exception as exc:  # pylint: disable= broad-except
        return JSONError(500, str(exc))


def flat_v1(flat_id, config, db):
    """
    API v1 flat route.

    Example::

        GET /api/v1/flats/:flat_id

    :return: The flat object in a JSON ``data`` dict.
    """
    if bottle.request.method == 'OPTIONS':
        # CORS
        return {}

    try:
        flat = db.query(flat_model.Flat).filter_by(id=flat_id).first()

        if not flat:
            return JSONError(404, "No flat with id {}.".format(flat_id))

        return {
            "data": _serialize_flat(flat, config)
        }
    except Exception as exc:  # pylint: disable= broad-except
        return JSONError(500, str(exc))


def update_flat_v1(flat_id, config, db):
    """
    API v1 route to update flat status.

    Example::

        PATCH /api/v1/flat/:flat_id
        Data: {
            "status": "NEW_STATUS",
            "visit_date": "ISO8601 DATETIME"
        }

    .. note::

        The keys in the data sent are same keys as in ``Flat`` model. You
        can provide any subset of them to update part of the flat infos.

    :return: The new flat object in a JSON ``data`` dict.
    """
    if bottle.request.method == 'OPTIONS':
        # CORS
        return {}

    try:
        flat = db.query(flat_model.Flat).filter_by(id=flat_id).first()
        if not flat:
            return JSONError(404, "No flat with id {}.".format(flat_id))

        try:
            json_body = json.load(bottle.request.body)
            for key, value in json_body.items():
                setattr(flat, key, value)
        except ValueError as exc:
            return JSONError(
                400,
                "Invalid payload provided: {}.".format(str(exc))
            )

        return {
            "data": _serialize_flat(flat, config)
        }
    except Exception as exc:  # pylint: disable= broad-except
        return JSONError(500, str(exc))


def time_to_places_v1(config):
    """
    API v1 route to fetch the details of the places to compute time to.

    Example::

        GET /api/v1/time_to_places

    :return: The JSON dump of the places to compute time to (dict of places
        names mapped to GPS coordinates).
    """
    if bottle.request.method == 'OPTIONS':
        # CORS
        return {}

    try:
        places = {}
        for constraint_name, constraint in config["constraints"].items():
            places[constraint_name] = {
                k: v["gps"]
                for k, v in constraint["time_to"].items()
            }
        return {
            "data": places
        }
    except Exception as exc:  # pylint: disable= broad-except
        return JSONError(500, str(exc))


def search_v1(db, config):
    """
    API v1 route to perform a fulltext search on flats.

    Example::

        POST /api/v1/search
        Data: {
            "query": "SOME_QUERY"
        }

    .. note::

        Filtering can be done through the ``filter`` GET param, according
        to JSON API spec (http://jsonapi.org/recommendations/#filtering).

    .. note::

        By default no pagination is done. Pagination can be forced using
        ``page[size]`` to specify a number of items per page and
        ``page[number]`` to specify which page to return. Pages are numbered
        starting from 0.

    .. note::

        Sorting can be handled through the ``sort`` GET param, according to
        JSON API spec (http://jsonapi.org/format/#fetching-sorting).

    :return: The matching flat objects in a JSON ``data`` dict.
    """
    if bottle.request.method == 'OPTIONS':
        # CORS
        return {}

    try:
        try:
            query = json.load(bottle.request.body)["query"]
        except (ValueError, KeyError):
            return JSONError(400, "Invalid query provided.")

        try:
            filters, page_number, page_size, sorting = _JSONApiSpec(
                bottle.request.query,
                flat_model.Flat,
                default_sorting='cost'
            )
        except ValueError as exc:
            return JSONError(400, str(exc))

        flats_db_query = (flat_model.Flat
                          .search_query(db, query)
                          .filter_by(**filters)
                          .order_by(*sorting))
        flats = [
            _serialize_flat(flat, config)
            for flat in itertools.islice(
                flats_db_query,
                page_number * page_size if page_size else None,
                page_number * page_size + page_size if page_size else None
            )
        ]

        return {
            "data": flats,
            "page": page_number,
            "items_per_page": page_size if page_size else len(flats)
        }
    except Exception as exc:  # pylint: disable= broad-except
        return JSONError(500, str(exc))


def ics_feed_v1(config, db):
    """
    API v1 ICS feed of visits route.

    Example::

        GET /api/v1/ics/visits.ics

    :return: The ICS feed for the visits.
    """
    if bottle.request.method == 'OPTIONS':
        # CORS
        return {}

    cal = vobject.iCalendar()
    try:
        flats_with_visits = db.query(flat_model.Flat).filter(
            flat_model.Flat.visit_date.isnot(None)
        )

        for flat in flats_with_visits:
            vevent = cal.add('vevent')
            vevent.add('dtstart').value = flat.visit_date
            vevent.add('dtend').value = (
                flat.visit_date + datetime.timedelta(hours=1)
            )
            vevent.add('summary').value = 'Visit - {}'.format(flat.title)

            description = (
                '{} (area: {}, cost: {} {})\n{}#/flat/{}\n'.format(
                    flat.title, flat.area, flat.cost, flat.currency,
                    config['website_url'], flat.id
                )
            )
            description += '\n{}\n'.format(flat.text)
            if flat.notes:
                description += '\n{}\n'.format(flat.notes)

            vevent.add('description').value = description
    except Exception:  # pylint: disable= broad-except
        pass

    return cal.serialize()


def opendata_index_v1():
    """
    API v1 data index route.

    Example::

        GET /api/v1/opendata
    """
    return {
        "postal_codes": "/api/v1/opendata/postal_codes"
    }


def opendata_postal_codes_v1(db):
    """
    API v1 data postal codes route.

    Example::

        GET /api/v1/opendata/postal_codes

    .. note::

        Filtering can be done through the ``filter`` GET param, according
        to JSON API spec (http://jsonapi.org/recommendations/#filtering).

    .. note::

        By default no pagination is done. Pagination can be forced using
        ``page[size]`` to specify a number of items per page and
        ``page[number]`` to specify which page to return. Pages are numbered
        starting from 0.

    .. note::

        Sorting can be handled through the ``sort`` GET param, according to
        JSON API spec (http://jsonapi.org/format/#fetching-sorting).


    :return: The postal codes data from opendata.
    """
    if bottle.request.method == 'OPTIONS':
        # CORS
        return {}

    try:
        try:
            filters, page_number, page_size, sorting = _JSONApiSpec(
                bottle.request.query,
                PostalCode,
                default_sorting='postal_code'
            )
        except ValueError as exc:
            return JSONError(400, str(exc))

        db_query = db.query(PostalCode).filter_by(**filters).order_by(*sorting)
        postal_codes = [
            x.json_api_repr() for x in itertools.islice(
                db_query,
                page_number * page_size if page_size else None,
                page_number * page_size + page_size if page_size else None
            )
        ]
        return {
            "data": postal_codes,
            "page": page_number,
            "items_per_page": page_size if page_size else len(postal_codes)
        }
    except Exception as exc:  # pylint: disable= broad-except
        return JSONError(500, str(exc))
