# Copyright 2016 Capital One Services, LLC
#
# 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.
"""
Jsonschema validation of cloud custodian config.

We start with a walkthrough of the various class registries
of resource types and assemble and generate the schema.

We do some specialization to reduce overall schema size
via reference usage, although in some cases we prefer
copies, due to issues with inheritance via reference (
allowedProperties and enum extension).

All filters and actions are annotated with schema typically using
the utils.type_schema function.
"""
from __future__ import absolute_import, division, print_function, unicode_literals

from collections import Counter
import json
import logging

from jsonschema import Draft4Validator as Validator
from jsonschema.exceptions import best_match

from c7n.manager import resources
from c7n.resources import load_resources
from c7n.filters import ValueFilter, EventFilter, AgeFilter


def validate(data, schema=None):
    if schema is None:
        schema = generate()
        Validator.check_schema(schema)
    validator = Validator(schema)

    errors = list(validator.iter_errors(data))
    if not errors:
        counter = Counter([p['name'] for p in data.get('policies')])
        dupes = []
        for k, v in counter.items():
            if v > 1:
                dupes.append(k)
        if dupes:
            return [ValueError(
                "Only one policy with a given name allowed, duplicates: %s" % (
                    ", ".join(dupes)))]
        return []
    try:
        resp = specific_error(errors[0])
        name = isinstance(
            errors[0].instance,
            dict) and errors[0].instance.get(
            'name',
            'unknown') or 'unknown'
        return [resp, name]
    except Exception:
        logging.exception(
            "specific_error failed, traceback, followed by fallback")

    return filter(None, [
        errors[0],
        best_match(validator.iter_errors(data)),
    ])


def specific_error(error):
    """Try to find the best error for humans to resolve

    The jsonschema.exceptions.best_match error is based purely on a
    mix of a strong match (ie. not anyOf, oneOf) and schema depth,
    this often yields odd results that are semantically confusing,
    instead we can use a bit of structural knowledge of schema to
    provide better results.
    """
    if error.validator not in ('anyOf', 'oneOf'):
        return error

    r = t = None
    if isinstance(error.instance, dict):
        t = error.instance.get('type')
        r = error.instance.get('resource')

    if r is not None:
        found = None
        for idx, v in enumerate(error.validator_value):
            if r in v['$ref'].rsplit('/', 2):
                found = idx
        if found is not None:
            # error context is a flat list of all validation
            # failures, we have to index back to the policy
            # of interest.
            for e in error.context:
                # resource policies have a fixed path from
                # the top of the schema
                if e.absolute_schema_path[4] == found:
                    return specific_error(e)
            return specific_error(error.context[idx])

    if t is not None:
        found = None
        for idx, v in enumerate(error.validator_value):
            if '$ref' in v and v['$ref'].endswith(t):
                found = idx
        if found is not None:
            # Try to walk back an element/type ref to the specific
            # error
            spath = list(error.context[0].absolute_schema_path)
            spath.reverse()
            slen = len(spath)
            if 'oneOf' in spath:
                idx = spath.index('oneOf')
            elif 'anyOf' in spath:
                idx = spath.index('anyOf')
            vidx = slen - idx
            for e in error.context:
                if e.absolute_schema_path[vidx] == found:
                    return e
    return error


def generate(resource_types=()):
    resource_defs = {}
    definitions = {
        'resources': resource_defs,
        'filters': {
            'value': ValueFilter.schema,
            'event': EventFilter.schema,
            'age': AgeFilter.schema,
            # Shortcut form of value filter as k=v
            'valuekv': {
                'type': 'object',
                'minProperties': 1,
                'maxProperties': 1},
        },

        'policy': {
            'type': 'object',
            'required': ['name', 'resource'],
            'additionalProperties': False,
            'properties': {
                'name': {
                    'type': 'string',
                    'pattern': "^[A-z][A-z0-9]*(-[A-z0-9]+)*$"},
                'region': {'type': 'string'},
                'resource': {'type': 'string'},
                'max-resources': {'type': 'integer'},
                'comment': {'type': 'string'},
                'comments': {'type': 'string'},
                'description': {'type': 'string'},
                'tags': {'type': 'array', 'items': {'type': 'string'}},
                'mode': {'$ref': '#/definitions/policy-mode'},
                'source': {'enum': ['describe', 'config']},
                'actions': {
                    'type': 'array',
                },
                'filters': {
                    'type': 'array'
                },
                #
                # unclear if this should be allowed, it kills resource
                # cache coherency between policies, and we need to
                # generalize server side query mechanisms, currently
                # this only for ec2 instance queries. limitations
                # in json schema inheritance prevent us from doing this
                # on a type specific basis http://goo.gl/8UyRvQ
                'query': {
                    'type': 'array', 'items': {
                        'type': 'object',
                        'minProperties': 1,
                        'maxProperties': 1}}
            },
        },
        'policy-mode': {
            'type': 'object',
            'required': ['type'],
            'properties': {
                'type': {
                    'enum': [
                        'cloudtrail',
                        'ec2-instance-state',
                        'asg-instance-state',
                        'config-rule',
                        'periodic'
                    ]},
                'events': {'type': 'array', 'items': {
                    'oneOf': [
                        {'type': 'string'},
                        {'type': 'object',
                         'required': ['event', 'source', 'ids'],
                         'properties': {
                             'source': {'type': 'string'},
                             'ids': {'type': 'string'},
                             'event': {'type': 'string'}}}]
                }}
            },
        },
    }

    resource_refs = []
    for type_name, resource_type in resources.items():
        if resource_types and type_name not in resource_types:
            continue
        resource_refs.append(
            process_resource(type_name, resource_type, resource_defs))

    schema = {
        '$schema': 'http://json-schema.org/schema#',
        'id': 'http://schema.cloudcustodian.io/v0/custodian.json',
        'definitions': definitions,
        'type': 'object',
        'required': ['policies'],
        'additionalProperties': False,
        'properties': {
            'vars': {'type': 'object'},
            'policies': {
                'type': 'array',
                'additionalItems': False,
                'items': {'anyOf': resource_refs}
            }
        }
    }

    return schema


def process_resource(type_name, resource_type, resource_defs):
    r = resource_defs.setdefault(type_name, {'actions': {}, 'filters': {}})

    seen_actions = set()  # Aliases get processed once
    action_refs = []
    for action_name, a in resource_type.action_registry.items():
        if a in seen_actions:
            continue
        else:
            seen_actions.add(a)
        r['actions'][action_name] = a.schema
        action_refs.append(
            {'$ref': '#/definitions/resources/%s/actions/%s' % (
                type_name, action_name)})

    # one word action shortcuts
    action_refs.append(
        {'enum': resource_type.action_registry.keys()})

    nested_filter_refs = []
    filters_seen = set()
    for k, v in sorted(resource_type.filter_registry.items()):
        if v in filters_seen:
            continue
        else:
            filters_seen.add(v)
        nested_filter_refs.append(
            {'$ref': '#/definitions/resources/%s/filters/%s' % (
                type_name, k)})
    nested_filter_refs.append(
        {'$ref': '#/definitions/filters/valuekv'})

    filter_refs = []
    filters_seen = set()  # for aliases
    for filter_name, f in sorted(resource_type.filter_registry.items()):
        if f in filters_seen:
            continue
        else:
            filters_seen.add(f)

        if filter_name in ('or', 'and', 'not'):
            continue
        elif filter_name == 'value':
            r['filters'][filter_name] = {
                '$ref': '#/definitions/filters/value'}
            r['filters']['valuekv'] = {
                '$ref': '#/definitions/filters/valuekv'}
        elif filter_name == 'event':
            r['filters'][filter_name] = {
                '$ref': '#/definitions/filters/event'}
        else:
            r['filters'][filter_name] = f.schema
        filter_refs.append(
            {'$ref': '#/definitions/resources/%s/filters/%s' % (
                type_name, filter_name)})
    filter_refs.append(
        {'$ref': '#/definitions/filters/valuekv'})

    # one word filter shortcuts
    filter_refs.append(
        {'enum': resource_type.filter_registry.keys()})

    resource_policy = {
        'allOf': [
            {'$ref': '#/definitions/policy'},
            {'properties': {
                'resource': {'enum': [type_name]},
                'filters': {
                    'type': 'array',
                    'items': {'anyOf': filter_refs}},
                'actions': {
                    'type': 'array',
                    'items': {'anyOf': action_refs}}}},
        ]
    }

    if type_name == 'ec2':
        resource_policy['allOf'][1]['properties']['query'] = {}

    r['policy'] = resource_policy
    return {'$ref': '#/definitions/resources/%s/policy' % type_name}


def resource_vocabulary():
    vocabulary = {}
    for type_name, resource_type in resources.items():
        classes = {'actions': {}, 'filters': {}}

        actions = []
        for action_name, cls in resource_type.action_registry.items():
            actions.append(action_name)
            classes['actions'][action_name] = cls

        filters = []
        for filter_name, cls in resource_type.filter_registry.items():
            filters.append(filter_name)
            classes['filters'][filter_name] = cls

        vocabulary[type_name] = {
            'filters': sorted(filters),
            'actions': sorted(actions),
            'classes': classes,
        }
    return vocabulary


def summary(vocabulary):
    print("resource count: %d" % len(vocabulary))
    action_count = filter_count = 0

    common_actions = set(['notify', 'invoke-lambda'])
    common_filters = set(['value', 'and', 'or', 'event'])

    for rv in vocabulary.values():
        action_count += len(
            set(rv.get('actions', ())).difference(common_actions))
        filter_count += len(
            set(rv.get('filters', ())).difference(common_filters))
    print("unique actions: %d" % action_count)
    print("common actions: %d" % len(common_actions))
    print("unique filters: %d" % filter_count)
    print("common filters: %s" % len(common_filters))


def json_dump(resource=None):
    load_resources()
    print(json.dumps(generate(resource), indent=2))


if __name__ == '__main__':
    json_dump()
