# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#    http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import eventlet
eventlet.monkey_patch()

import functools
import os
import re
import ssl
import sys

import flask
from futurist import periodics
from oslo_config import cfg
from oslo_log import log
from oslo_utils import uuidutils
import werkzeug

from ironic_inspector import db
from ironic_inspector.common.i18n import _, _LC, _LE, _LI, _LW
from ironic_inspector.common import ironic as ir_utils
from ironic_inspector.common import swift
from ironic_inspector import conf  # noqa
from ironic_inspector import firewall
from ironic_inspector import introspect
from ironic_inspector import node_cache
from ironic_inspector.plugins import base as plugins_base
from ironic_inspector import process
from ironic_inspector import rules
from ironic_inspector import utils

CONF = cfg.CONF


app = flask.Flask(__name__)
LOG = utils.getProcessingLogger(__name__)

MINIMUM_API_VERSION = (1, 0)
CURRENT_API_VERSION = (1, 6)
_LOGGING_EXCLUDED_KEYS = ('logs',)


def _get_version():
    ver = flask.request.headers.get(conf.VERSION_HEADER,
                                    _DEFAULT_API_VERSION)
    try:
        requested = tuple(int(x) for x in ver.split('.'))
    except (ValueError, TypeError):
        return error_response(_('Malformed API version: expected string '
                                'in form of X.Y'), code=400)
    return requested


def _format_version(ver):
    return '%d.%d' % ver


_DEFAULT_API_VERSION = _format_version(CURRENT_API_VERSION)


def error_response(exc, code=500):
    res = flask.jsonify(error={'message': str(exc)})
    res.status_code = code
    LOG.debug('Returning error to client: %s', exc)
    return res


def convert_exceptions(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        try:
            return func(*args, **kwargs)
        except utils.Error as exc:
            return error_response(exc, exc.http_code)
        except werkzeug.exceptions.HTTPException as exc:
            return error_response(exc, exc.code or 400)
        except Exception as exc:
            LOG.exception(_LE('Internal server error'))
            msg = _('Internal server error')
            if CONF.debug:
                msg += ' (%s): %s' % (exc.__class__.__name__, exc)
            return error_response(msg)

    return wrapper


@app.before_request
def check_api_version():
    requested = _get_version()

    if requested < MINIMUM_API_VERSION or requested > CURRENT_API_VERSION:
        return error_response(_('Unsupported API version %(requested)s, '
                                'supported range is %(min)s to %(max)s') %
                              {'requested': _format_version(requested),
                               'min': _format_version(MINIMUM_API_VERSION),
                               'max': _format_version(CURRENT_API_VERSION)},
                              code=406)


@app.after_request
def add_version_headers(res):
    res.headers[conf.MIN_VERSION_HEADER] = '%s.%s' % MINIMUM_API_VERSION
    res.headers[conf.MAX_VERSION_HEADER] = '%s.%s' % CURRENT_API_VERSION
    return res


def create_link_object(urls):
    links = []
    for url in urls:
        links.append({"rel": "self",
                      "href": os.path.join(flask.request.url_root, url)})
    return links


def generate_resource_data(resources):
    data = []
    for resource in resources:
        item = {}
        item['name'] = str(resource).split('/')[-1]
        item['links'] = create_link_object([str(resource)[1:]])
        data.append(item)
    return data


@app.route('/', methods=['GET'])
@convert_exceptions
def api_root():
    versions = [
        {
            "status": "CURRENT",
            "id": '%s.%s' % CURRENT_API_VERSION,
        },
    ]

    for version in versions:
        version['links'] = create_link_object(
            ["v%s" % version['id'].split('.')[0]])

    return flask.jsonify(versions=versions)


@app.route('/<version>', methods=['GET'])
@convert_exceptions
def version_root(version):
    pat = re.compile("^\/%s\/[^\/]*?$" % version)

    resources = []
    for url in app.url_map.iter_rules():
        if pat.match(str(url)):
            resources.append(url)

    if not resources:
        raise utils.Error(_('Version not found.'), code=404)

    return flask.jsonify(resources=generate_resource_data(resources))


@app.route('/v1/continue', methods=['POST'])
@convert_exceptions
def api_continue():
    data = flask.request.get_json(force=True)
    if not isinstance(data, dict):
        raise utils.Error(_('Invalid data: expected a JSON object, got %s') %
                          data.__class__.__name__)

    logged_data = {k: (v if k not in _LOGGING_EXCLUDED_KEYS else '<hidden>')
                   for k, v in data.items()}
    LOG.debug("Received data from the ramdisk: %s", logged_data,
              data=data)

    return flask.jsonify(process.process(data))


# TODO(sambetts) Add API discovery for this endpoint
@app.route('/v1/introspection/<node_id>', methods=['GET', 'POST'])
@convert_exceptions
def api_introspection(node_id):
    utils.check_auth(flask.request)

    if flask.request.method == 'POST':
        new_ipmi_password = flask.request.args.get('new_ipmi_password',
                                                   type=str,
                                                   default=None)
        if new_ipmi_password:
            new_ipmi_username = flask.request.args.get('new_ipmi_username',
                                                       type=str,
                                                       default=None)
            new_ipmi_credentials = (new_ipmi_username, new_ipmi_password)
        else:
            new_ipmi_credentials = None

        introspect.introspect(node_id,
                              new_ipmi_credentials=new_ipmi_credentials,
                              token=flask.request.headers.get('X-Auth-Token'))
        return '', 202
    else:
        node_info = node_cache.get_node(node_id)
        return flask.json.jsonify(finished=bool(node_info.finished_at),
                                  error=node_info.error or None)


@app.route('/v1/introspection/<node_id>/abort', methods=['POST'])
@convert_exceptions
def api_introspection_abort(node_id):
    utils.check_auth(flask.request)
    introspect.abort(node_id, token=flask.request.headers.get('X-Auth-Token'))
    return '', 202


@app.route('/v1/introspection/<node_id>/data', methods=['GET'])
@convert_exceptions
def api_introspection_data(node_id):
    utils.check_auth(flask.request)

    if CONF.processing.store_data == 'swift':
        if not uuidutils.is_uuid_like(node_id):
            node = ir_utils.get_node(node_id, fields=['uuid'])
            node_id = node.uuid
        res = swift.get_introspection_data(node_id)
        return res, 200, {'Content-Type': 'application/json'}
    else:
        return error_response(_('Inspector is not configured to store data. '
                                'Set the [processing] store_data '
                                'configuration option to change this.'),
                              code=404)


@app.route('/v1/introspection/<node_id>/data/unprocessed', methods=['POST'])
@convert_exceptions
def api_introspection_reapply(node_id):
    utils.check_auth(flask.request)

    if flask.request.content_length:
        return error_response(_('User data processing is not '
                                'supported yet'), code=400)

    if CONF.processing.store_data == 'swift':
        process.reapply(node_id)
        return '', 202
    else:
        return error_response(_('Inspector is not configured to store'
                                ' data. Set the [processing] '
                                'store_data configuration option to '
                                'change this.'), code=400)


def rule_repr(rule, short):
    result = rule.as_dict(short=short)
    result['links'] = [{
        'href': flask.url_for('api_rule', uuid=result['uuid']),
        'rel': 'self'
    }]
    return result


@app.route('/v1/rules', methods=['GET', 'POST', 'DELETE'])
@convert_exceptions
def api_rules():
    utils.check_auth(flask.request)

    if flask.request.method == 'GET':
        res = [rule_repr(rule, short=True) for rule in rules.get_all()]
        return flask.jsonify(rules=res)
    elif flask.request.method == 'DELETE':
        rules.delete_all()
        return '', 204
    else:
        body = flask.request.get_json(force=True)
        if body.get('uuid') and not uuidutils.is_uuid_like(body['uuid']):
            raise utils.Error(_('Invalid UUID value'), code=400)

        rule = rules.create(conditions_json=body.get('conditions', []),
                            actions_json=body.get('actions', []),
                            uuid=body.get('uuid'),
                            description=body.get('description'))

        response_code = (200 if _get_version() < (1, 6) else 201)
        return flask.make_response(
            flask.jsonify(rule_repr(rule, short=False)), response_code)


@app.route('/v1/rules/<uuid>', methods=['GET', 'DELETE'])
@convert_exceptions
def api_rule(uuid):
    utils.check_auth(flask.request)

    if flask.request.method == 'GET':
        rule = rules.get(uuid)
        return flask.jsonify(rule_repr(rule, short=False))
    else:
        rules.delete(uuid)
        return '', 204


@app.errorhandler(404)
def handle_404(error):
    return error_response(error, code=404)


@periodics.periodic(spacing=CONF.firewall.firewall_update_period,
                    enabled=CONF.firewall.manage_firewall)
def periodic_update():  # pragma: no cover
    try:
        firewall.update_filters()
    except Exception:
        LOG.exception(_LE('Periodic update of firewall rules failed'))


@periodics.periodic(spacing=CONF.clean_up_period)
def periodic_clean_up():  # pragma: no cover
    try:
        if node_cache.clean_up():
            firewall.update_filters()
        sync_with_ironic()
    except Exception:
        LOG.exception(_LE('Periodic clean up of node cache failed'))


def sync_with_ironic():
    ironic = ir_utils.get_client()
    # TODO(yuikotakada): pagination
    ironic_nodes = ironic.node.list(limit=0)
    ironic_node_uuids = {node.uuid for node in ironic_nodes}
    node_cache.delete_nodes_not_in_list(ironic_node_uuids)


def create_ssl_context():
    if not CONF.use_ssl:
        return

    MIN_VERSION = (2, 7, 9)

    if sys.version_info < MIN_VERSION:
        LOG.warning(_LW('Unable to use SSL in this version of Python: '
                        '%(current)s, please ensure your version of Python is '
                        'greater than %(min)s to enable this feature.'),
                    {'current': '.'.join(map(str, sys.version_info[:3])),
                     'min': '.'.join(map(str, MIN_VERSION))})
        return

    context = ssl.create_default_context(purpose=ssl.Purpose.CLIENT_AUTH)
    if CONF.ssl_cert_path and CONF.ssl_key_path:
        try:
            context.load_cert_chain(CONF.ssl_cert_path, CONF.ssl_key_path)
        except IOError as exc:
            LOG.warning(_LW('Failed to load certificate or key from defined '
                            'locations: %(cert)s and %(key)s, will continue '
                            'to run with the default settings: %(exc)s'),
                        {'cert': CONF.ssl_cert_path, 'key': CONF.ssl_key_path,
                         'exc': exc})
        except ssl.SSLError as exc:
            LOG.warning(_LW('There was a problem with the loaded certificate '
                            'and key, will continue to run with the default '
                            'settings: %s'), exc)
    return context


class Service(object):
    _periodics_worker = None

    def setup_logging(self, args):
        log.register_options(CONF)
        CONF(args, project='ironic-inspector')

        log.set_defaults(default_log_levels=[
            'sqlalchemy=WARNING',
            'iso8601=WARNING',
            'requests=WARNING',
            'urllib3.connectionpool=WARNING',
            'keystonemiddleware=WARNING',
            'swiftclient=WARNING',
            'keystoneauth=WARNING',
            'ironicclient=WARNING'
        ])
        log.setup(CONF, 'ironic_inspector')

        LOG.debug("Configuration:")
        CONF.log_opt_values(LOG, log.DEBUG)

    def init(self):
        if utils.get_auth_strategy() != 'noauth':
            utils.add_auth_middleware(app)
        else:
            LOG.warning(_LW('Starting unauthenticated, please check'
                            ' configuration'))

        if CONF.processing.store_data == 'none':
            LOG.warning(_LW('Introspection data will not be stored. Change '
                            '"[processing] store_data" option if this is not '
                            'the desired behavior'))
        elif CONF.processing.store_data == 'swift':
            LOG.info(_LI('Introspection data will be stored in Swift in the '
                         'container %s'), CONF.swift.container)

        utils.add_cors_middleware(app)

        db.init()

        try:
            hooks = [ext.name for ext in
                     plugins_base.processing_hooks_manager()]
        except KeyError as exc:
            # callback function raises MissingHookError derived from KeyError
            # on missing hook
            LOG.critical(_LC('Hook(s) %s failed to load or was not found'),
                         str(exc))
            sys.exit(1)

        LOG.info(_LI('Enabled processing hooks: %s'), hooks)

        if CONF.firewall.manage_firewall:
            firewall.init()

        self._periodics_worker = periodics.PeriodicWorker(
            callables=[(periodic_update, None, None),
                       (periodic_clean_up, None, None)],
            executor_factory=periodics.ExistingExecutor(utils.executor()))
        utils.executor().submit(self._periodics_worker.start)

    def shutdown(self):
        LOG.debug('Shutting down')

        firewall.clean_up()

        if self._periodics_worker is not None:
            self._periodics_worker.stop()
            self._periodics_worker.wait()
            self._periodics_worker = None

        if utils.executor().alive:
            utils.executor().shutdown(wait=True)

        LOG.info(_LI('Shut down successfully'))

    def run(self, args, application):
        self.setup_logging(args)

        app_kwargs = {'host': CONF.listen_address,
                      'port': CONF.listen_port}

        context = create_ssl_context()
        if context:
            app_kwargs['ssl_context'] = context

        self.init()
        try:
            application.run(**app_kwargs)
        finally:
            self.shutdown()


def main(args=sys.argv[1:]):  # pragma: no cover
    service = Service()
    service.run(args, app)
