# Copyright 2018 Red Hat Inc.
#
# 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.

from neutron_lib.api import converters
from neutron_lib import constants
from neutron_lib import context
from neutron_lib.db import model_query
from oslo_config import cfg
from oslo_serialization import jsonutils
from oslo_upgradecheck import common_checks
from oslo_upgradecheck import upgradecheck
from sqlalchemy import and_
from sqlalchemy import exists
from sqlalchemy import func
from sqlalchemy import or_

from neutron._i18n import _
from neutron.cmd.upgrade_checks import base
from neutron.db.extra_dhcp_opt import models as extra_dhcp_opt_models
from neutron.db.models import agent as agent_model
from neutron.db.models import external_net
from neutron.db.models import l3 as l3_models
from neutron.db.models.plugins.ml2 import vlanallocation
from neutron.db.models import segment
from neutron.db import models_v2
from neutron.db.qos import models as qos_models
from neutron.objects import ports as port_obj


OVN_ALEMBIC_TABLE_NAME = "ovn_alembic_version"
LAST_NETWORKING_OVN_EXPAND_HEAD = "e55d09277410"
LAST_NETWORKING_OVN_CONTRACT_HEAD = "1d271ead4eb6"


def get_agents(agt_type):
    """Get agent information from Database

    :param agt_type: agent type, one of constants.AGENT_TYPE_*
    :return: list of database query results
    """
    filters = {'agent_type': [agt_type]}
    ctx = context.get_admin_context()
    query = model_query.get_collection_query(ctx,
                                             agent_model.Agent,
                                             filters=filters)
    return query.all()


def get_extra_dhcp_opts():
    """Get extra DHCP options for all ports from Database

    :return: list of ports' extra_dhcp_option names and values
    """
    ctx = context.get_admin_context()
    query = model_query.get_collection_query(
        ctx,
        extra_dhcp_opt_models.ExtraDhcpOpt)
    return query.all()


def get_l3_agents():
    return get_agents(constants.AGENT_TYPE_L3)


def get_nic_switch_agents():
    return get_agents(constants.AGENT_TYPE_NIC_SWITCH)


def get_networks():
    ctx = context.get_admin_context()
    query = model_query.get_collection_query(ctx,
                                             models_v2.Network)
    return query.all()


def table_exists(table_name):
    ctx = context.get_admin_context()
    tables = [t[0] for t in ctx.session.execute("SHOW TABLES;")]
    return table_name in tables


def get_ovn_db_revisions():
    ctx = context.get_admin_context()
    return [row[0] for row in ctx.session.execute(
        "SELECT version_num from %s;" % OVN_ALEMBIC_TABLE_NAME)]  # nosec


def count_vlan_allocations_invalid_segmentation_id():
    ctx = context.get_admin_context()
    query = ctx.session.query(vlanallocation.VlanAllocation)
    query = query.filter(or_(
        vlanallocation.VlanAllocation.vlan_id < constants.MIN_VLAN_TAG,
        vlanallocation.VlanAllocation.vlan_id > constants.MAX_VLAN_TAG))
    return query.count()


def port_mac_addresses():
    ctx = context.get_admin_context()
    return [port[0] for port in
            ctx.session.query(models_v2.Port.mac_address).all()]


def get_duplicate_network_segment_count():
    ctx = context.get_admin_context()
    query = ctx.session.query(segment.NetworkSegment.network_id)
    # for a unique constraint it's always NULL != NULL --> we filter them out
    query = query.filter(segment.NetworkSegment.physical_network.isnot(None))
    query = query.group_by(
        segment.NetworkSegment.network_id,
        segment.NetworkSegment.network_type,
        segment.NetworkSegment.physical_network
    )
    query = query.having(func.count() > 1)
    return query.count()


def port_binding_profiles():
    ctx = context.get_admin_context()
    return [port_binding.profile
            for port_binding in port_obj.PortBinding.get_objects(ctx)]


def get_external_networks_with_qos_policies():
    ctx = context.get_admin_context()
    query = ctx.session.query(external_net.ExternalNetwork.network_id)
    query = query.filter(external_net.ExternalNetwork.network_id ==
                         qos_models.QosNetworkPolicyBinding.network_id)
    return [network[0] for network in query.all()]


def get_fip_per_network_without_qos_policies(network_id):
    ctx = context.get_admin_context()
    query = ctx.session.query(l3_models.FloatingIP)
    query = query.filter(and_(
        ~exists().where(qos_models.QosFIPPolicyBinding.fip_id ==
                        l3_models.FloatingIP.id),
        l3_models.FloatingIP.floating_network_id == network_id)
    )
    return query.count()


class CoreChecks(base.BaseChecks):

    def get_checks(self):
        return [
            (_("Gateway external network"),
             self.gateway_external_network_check),
            (_("External network bridge"),
             self.external_network_bridge_check),
            (_("Worker counts configured"), self.worker_count_check),
            (_("Networking-ovn database revision"),
             self.ovn_db_revision_check),
            (_("NIC Switch agent check kernel"),
             self.nic_switch_agent_min_kernel_check),
            (_("VLAN allocations valid segmentation ID check"),
             self.vlan_allocations_segid_check),
            (_('Policy File JSON to YAML Migration'),
             (common_checks.check_policy_json, {'conf': cfg.CONF})),
            (_('Port MAC address sanity check'),
             self.port_mac_address_sanity),
            (_('NetworkSegments unique constraint check'),
             self.networksegments_unique_constraint_check),
            (_('Port Binding profile sanity check'),
             self.port_binding_profile_sanity),
            (_('Floating IP inherits the QoS policy from the external '
               'network'),
             self.floatingip_inherit_qos_from_network),
            (_('Port extra DHCP options check'),
             self.extra_dhcp_options_check),
        ]

    @staticmethod
    def worker_count_check(checker):

        if cfg.CONF.api_workers and cfg.CONF.rpc_workers:
            return upgradecheck.Result(
                upgradecheck.Code.SUCCESS, _("Number of workers already "
                                             "defined in config"))
        else:
            return upgradecheck.Result(
                upgradecheck.Code.WARNING,
                _("The default number of workers "
                  "has changed. Please see release notes for the new values, "
                  "but it is strongly encouraged for deployers to manually "
                  "set the values for api_workers and rpc_workers."))

    @staticmethod
    def external_network_bridge_check(checker):
        if not cfg.CONF.database.connection:
            return upgradecheck.Result(
                upgradecheck.Code.WARNING,
                _("Database connection string is not set. Check of usage of "
                  "'external_network_bridge' config option in L3 agents "
                  "can't be done"))

        agents_with_external_bridge = []
        for agent in get_l3_agents():
            config_string = agent.get('configurations')
            if not config_string:
                continue
            config = jsonutils.loads(config_string)
            if config.get("external_network_bridge"):
                agents_with_external_bridge.append(agent.get("host"))

        if agents_with_external_bridge:
            return upgradecheck.Result(
                upgradecheck.Code.WARNING,
                _("L3 agents on hosts %s are still using "
                  "'external_network_bridge' config option to provide "
                  "gateway connectivity. This option is now removed. "
                  "Migration of routers from those L3 agents will be "
                  "required to connect them to external network through "
                  "integration bridge.") % agents_with_external_bridge)
        else:
            return upgradecheck.Result(
                upgradecheck.Code.SUCCESS,
                _("L3 agents are using integration bridge to connect external "
                  "gateways"))

    @staticmethod
    def gateway_external_network_check(checker):
        if not cfg.CONF.database.connection:
            return upgradecheck.Result(
                upgradecheck.Code.WARNING,
                _("Database connection string is not set. Check of usage of "
                  "'gateway_external_network_id' config option in L3 agents "
                  "can't be done"))

        agents_with_gateway_external_net = []
        for agent in get_l3_agents():
            config_string = agent.get('configurations')
            if not config_string:
                continue
            config = jsonutils.loads(config_string)
            if config.get("gateway_external_network_id"):
                agents_with_gateway_external_net.append(agent.get("host"))

        if agents_with_gateway_external_net:
            agents_list = ", ".join(agents_with_gateway_external_net)
            return upgradecheck.Result(
                upgradecheck.Code.WARNING,
                _("L3 agents on hosts %s are still using "
                  "'gateway_external_network_id' config option to configure "
                  "external network used as gateway for routers. "
                  "This option is now removed and routers on those hosts can "
                  "use multiple external networks as gateways.") % agents_list)
        else:
            return upgradecheck.Result(
                upgradecheck.Code.SUCCESS,
                _("L3 agents can use multiple networks as external gateways."))

    @staticmethod
    def network_mtu_check(checker):
        if not cfg.CONF.database.connection:
            return upgradecheck.Result(
                upgradecheck.Code.WARNING,
                _("Database connection string is not set. Check of 'mtu' in "
                  "networks can't be done"))

        networks_with_empty_mtu_attr = []
        for network in get_networks():
            mtu = network.get('mtu', None)
            if not mtu:
                networks_with_empty_mtu_attr.append(network.get("id"))

        if networks_with_empty_mtu_attr:
            networks_list = ", ".join(networks_with_empty_mtu_attr)
            return upgradecheck.Result(
                upgradecheck.Code.WARNING,
                _("The 'mtu' attribute of networks %s are not set "
                  "This attribute can't be null now.") % networks_list)
        else:
            return upgradecheck.Result(
                upgradecheck.Code.SUCCESS,
                _("The 'mtu' attribute of all networks are set."))

    @staticmethod
    def ovn_db_revision_check(checker):
        if not cfg.CONF.database.connection:
            return upgradecheck.Result(
                upgradecheck.Code.WARNING,
                _("Database connection string is not set. Check of "
                  "networking-ovn database revision can't be done."))
        if not table_exists(OVN_ALEMBIC_TABLE_NAME):
            return upgradecheck.Result(
                upgradecheck.Code.SUCCESS,
                _("Networking-ovn alembic version table don't exists in "
                  "the database yet."))
        revisions = get_ovn_db_revisions()
        if (LAST_NETWORKING_OVN_EXPAND_HEAD not in revisions or
                LAST_NETWORKING_OVN_CONTRACT_HEAD not in revisions):
            return upgradecheck.Result(
                upgradecheck.Code.FAILURE,
                _("Networking-ovn database tables are not up to date. "
                  "Please firts update networking-ovn to the latest version "
                  "from Train release."))
        return upgradecheck.Result(
            upgradecheck.Code.SUCCESS,
            _("Networking-ovn database tables are up to date."))

    @staticmethod
    def nic_switch_agent_min_kernel_check(checker):
        # TODO(adrianc): This was introduced in U release, consider removing
        # in 1-2 cycles.
        # Background: Issue with old kernel is appernet in CentOS 7 and older.
        # U release is the first release that moves from CentOS-7 to CentOS-8,
        # this was added as a "heads-up" for operators to make sure min kernel
        # requirement is fullfiled.
        if not cfg.CONF.database.connection:
            return upgradecheck.Result(
                upgradecheck.Code.WARNING,
                _("Database connection string is not set. "
                  "Check for NIC Switch agent can't be done."))

        agents = get_nic_switch_agents()
        if len(agents):
            hosts = ','.join([agent.get("host") for agent in agents])
            return upgradecheck.Result(
                upgradecheck.Code.WARNING,
                _("NIC Switch agents detected on hosts %s, please ensure the "
                  "hosts run with a kernel version 3.13 or newer.") % hosts)
        else:
            return upgradecheck.Result(
                upgradecheck.Code.SUCCESS,
                _("No NIC Switch agents detected."))

    @staticmethod
    def vlan_allocations_segid_check(checker):
        """Checks that "ml2_vlan_allocations.vlan_id" has a valid value

        Database register column "ml2_vlan_allocations.vlan_id" must be between
        1 and 4094.
        """
        if not cfg.CONF.database.connection:
            return upgradecheck.Result(
                upgradecheck.Code.WARNING,
                _("Database connection string is not set. Check for VLAN "
                  "allocations with invalid segmentation IDs can't be done."))

        count = count_vlan_allocations_invalid_segmentation_id()
        if count:
            return upgradecheck.Result(
                upgradecheck.Code.WARNING,
                _("There are %(count)s registers in 'ml2_vlan_allocations' "
                  "table with an invalid segmentation ID. 'vlan_id' must be "
                  "between %(min_vlan)s and %(max_vlan)s") %
                {'count': count, 'min_vlan': constants.MIN_VLAN_TAG,
                 'max_vlan': constants.MAX_VLAN_TAG})

        return upgradecheck.Result(
            upgradecheck.Code.SUCCESS,
            _("All 'ml2_vlan_allocations' registers have a valid "
              "segmentation ID."))

    @staticmethod
    def port_mac_address_sanity(checker):
        """Checks the MAC address sanity of each port in the BD

        All MAC addresses should be stored in the format xx:xx:xx:xx:xx:xx.
        """
        if not cfg.CONF.database.connection:
            return upgradecheck.Result(
                upgradecheck.Code.WARNING,
                _("Database connection string is not set. Check for port MAC "
                  "sanity can't be done."))

        for mac in port_mac_addresses():
            if mac != converters.convert_to_sanitized_mac_address(mac):
                return upgradecheck.Result(
                    upgradecheck.Code.WARNING,
                    _("There port MAC addresses not correctly formated in the"
                      "database. The script "
                      "neutron-sanitize-port-mac-addresses should be "
                      "executed"))

        return upgradecheck.Result(
            upgradecheck.Code.SUCCESS,
            _("All port MAC addresses are correctly formated in the "
              "database."))

    @staticmethod
    def networksegments_unique_constraint_check(checker):
        """Checks that there are no duplicate networksegments

        No two networksegments should never share the same network_id,
        network_type and physical_network. Two NULL values are not regarded
        as equal for a unique constraint, so networksegments with NULL as
        physical_network are ignored by this check.
        """
        if not cfg.CONF.database.connection:
            return upgradecheck.Result(
                upgradecheck.Code.WARNING,
                _("Database connection string is not set. Check for port MAC "
                  "sanity can't be done."))

        count = get_duplicate_network_segment_count()
        if count:
            return upgradecheck.Result(
                upgradecheck.Code.WARNING,
                _("There are %d instances of networksegments sharing the same "
                  "combination of network_id, network_type and "
                  "physical_network.") % count)

        return upgradecheck.Result(
            upgradecheck.Code.SUCCESS,
            _("No networksegments sharing the same network_id, network_type "
              "and physical_network found."))

    @staticmethod
    def port_binding_profile_sanity(checker):
        """Checks that "ml2_port_bindings.profile" uses the new format

        All allocation information should be stored in the following format:
        {'allocation': {'<group_uuid>': '<rp_uuid>'}}.
        """
        if not cfg.CONF.database.connection:
            return upgradecheck.Result(
                upgradecheck.Code.WARNING,
                _("Database connection string is not set. Check for "
                  "ml2_port_bindings.profile sanity can't be done."))

        for profile in port_binding_profiles():
            allocation = profile.get('allocation')
            if (allocation and not isinstance(allocation, dict)):
                return upgradecheck.Result(
                    upgradecheck.Code.FAILURE,
                    _("ml2_port_bindings.profile rows are not correctly "
                      "formated in the database. The script "
                      "neutron-sanitize-port-binding-profile-allocation "
                      "should be executed"))

        return upgradecheck.Result(
            upgradecheck.Code.SUCCESS,
            _("All ml2_port_bindings.profile rows are correctly formated in "
              "the database."))

    @staticmethod
    def floatingip_inherit_qos_from_network(checker):
        """Check if a floating IP network has a QoS policy

        Since LP#1950454, the floating IPs inherit the QoS policy from the
        external network. This check emits a warning message in case of having
        any external network with a QoS policy associated and at least one
        bound floating IPs with no QoS policy.
        """
        if not cfg.CONF.database.connection:
            return upgradecheck.Result(
                upgradecheck.Code.WARNING,
                _("Database connection string is not set. Check for "
                  "floating IP network QoS inheritance can't be done."))

        network_ids = []
        for network_id in get_external_networks_with_qos_policies():
            if get_fip_per_network_without_qos_policies(network_id):
                network_ids.append(network_id)

        if network_ids:
            return upgradecheck.Result(
                upgradecheck.Code.WARNING,
                _('The following external networks have a QoS policy '
                  'associated and at least one floating IP without QoS: %s. '
                  'Since LP#1950454, the floating IPs will inherit the QoS '
                  'policy from the external network.') %
                ', '.join(network_ids))

        return upgradecheck.Result(
            upgradecheck.Code.SUCCESS,
            _('There are no external networks with QoS policies associated '
              'and floating IPs without.'))

    @staticmethod
    def extra_dhcp_options_check(checker):
        """Check newline char in the extra_dhcp_opts

        Since LP#1939733, extra_dhcp_opts names and values shouldn't contain
        newline characters. This check emits a warning message in case of
        having any extra dhcp option defined with newline char in the name or
        value.
        """
        if not cfg.CONF.database.connection:
            return upgradecheck.Result(
                upgradecheck.Code.WARNING,
                _("Database connection string is not set. Check for "
                  "extra_dhcp_opts can't be done."))

        ports_with_invalid_options = []
        for extra_dhcp_opt in get_extra_dhcp_opts():
            if (len(extra_dhcp_opt.opt_name.splitlines()) > 1 or
                    len(extra_dhcp_opt.opt_value.splitlines()) > 1):
                ports_with_invalid_options.append(extra_dhcp_opt.port_id)

        if ports_with_invalid_options:
            return upgradecheck.Result(
                upgradecheck.Code.WARNING,
                _('The following ports have an extra DHCP options with '
                  'the newline character inside: %s. '
                  'Please update them manually in the Neutron Database, '
                  'otherwise they will be trimmed automatically before '
                  'used in the DHCP service') %
                ', '.join(ports_with_invalid_options))

        return upgradecheck.Result(
            upgradecheck.Code.SUCCESS,
            _('There are no extra_dhcp_opts with the newline character '
              'in the option name or option value.'))
