# -*- coding: utf-8 -*-

# Copyright (C) 2012-2015 Avencall
#
# This program 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.
#
# This program 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/>

import errno
import json
import os
import logging
import subprocess
from enum import StrEnum
from typing import Any, Dict, Optional
from pydantic import root_validator, validator, ValidationError, BaseModel
from xivo_dao.alchemy.netiface import Netiface
from xivo_dao.helpers.db_utils import session_scope

from xivo import http_json_server
from xivo.http_json_server import CMD_R, CMD_RW

logger = logging.getLogger(__name__)

KAMAILIO_PJSIP_TEMPLATE = """[kamailio]
type=endpoint
context=kamailio
disallow=all
allow=alaw,ulaw
transport=transport-udp
aors=kamailio
; IMPORTANT : ne pas exiger d'auth pour les OPTIONS
direct_media=no
force_rport=yes
rewrite_contact=yes

[kamailio]
type=aor
contact=sip:KAMAILIO_IP_PLACEHOLDER:5060
qualify_frequency=30

[kamailio-identify]
type=identify
endpoint=kamailio
match=KAMAILIO_IP_PLACEHOLDER
"""

class HaNodeType(StrEnum):
    DISABLED = 'disabled'
    MASTER = 'master'
    SLAVE = 'slave'

class HAConfig(BaseModel, extra="forbid"):
    node_type: HaNodeType = HaNodeType.DISABLED
    remote_address: str = ''
    local_address: str = ''
    automatic_failover: bool = False
    active: bool = False

    @root_validator(pre=True)
    def set_active_default(cls, values):
        if 'active' not in values:
            values['active'] = values.get('node_type') == HaNodeType.MASTER
        return values

    @validator("node_type", pre=True, always=True)
    def ensure_enum(cls, v):
        if v is None:
            return HaNodeType.DISABLED
        try:
            return HaNodeType(v)
        except ValueError as exc:
            raise ValueError(f"node_type must be one of {list(HaNodeType)}") from exc

class HAConfigManager:
    DEFAULT_HA_CONF_FILE = '/etc/xivo/ha.conf'
    DEFAULT_HA_CONFIG = HAConfig()
    CRONFILE_SLAVE = 'xivo-ha-slave'
    CRONFILE_MASTER = 'xivo-ha-master'
    KAMAILIO_PJSIP_FILE = '/etc/asterisk/pjsip.d/10-kamailio.conf'

    def __init__(self, postgres_config_updater_factory, cronfile_installer,
                 ha_conf_file=DEFAULT_HA_CONF_FILE,
                 kamailio_pjsip_file=KAMAILIO_PJSIP_FILE):
        self._postgres_config_updater_factory = postgres_config_updater_factory
        self._cronfile_installer = cronfile_installer
        self._ha_conf_file: str = ha_conf_file
        self._kamailio_pjsip_file: str = kamailio_pjsip_file

    def get_ha_config(self, args, options) -> Dict[str,Any]:
        return self._read_ha_config().dict()

    def _read_ha_config(self) -> HAConfig:
        try:
            with open(self._ha_conf_file, encoding='utf-8') as fobj:
                return self._read_ha_config_from_fobj(fobj)
        except IOError as e:
            if e.errno == errno.ENOENT:
                return self.DEFAULT_HA_CONFIG
            else:
                raise

    def _read_ha_config_from_fobj(self, fobj) -> HAConfig:
        try:
            config_dict = json.load(fobj)
        except json.JSONDecodeError as e:
            logger.error("Invalid HA configuration file: %s, returning default", e)
            return self.DEFAULT_HA_CONFIG
        config_dict.setdefault('node_type', 'disabled')
        config_dict.setdefault('automatic_failover', True)

        return self._convert_ha_config(config_dict)

    def _convert_ha_config(self, dict_ha_config: dict[str,Any]) -> HAConfig:
        try:
            return HAConfig(**dict_ha_config)
        except ValidationError as e:
            raise ValueError(f"Invalid HA configuration: {e}") from e

    def search_voip_address(self) -> Optional[str]:
        with session_scope() as session:
            return session.query(Netiface.address).filter(Netiface.networktype == "voip").scalar()

    def _add_local_address_if_missing(self, ha_config: HAConfig) -> HAConfig:
        if ha_config.local_address == '' and ha_config.node_type != HaNodeType.DISABLED:
            try:
                voip_address = self.search_voip_address()
                if voip_address:
                    ha_config.local_address = voip_address
            except Exception as e:
                logger.error("Error querying voip address from database: %s", e)
        return ha_config

    def update_ha_config(self, args, options):
        ha_config = self._convert_ha_config(args)
        ha_config = self._add_local_address_if_missing(ha_config)
        self._write_ha_config(ha_config)
        self._update_postgres(ha_config)
        self._update_cronfiles(ha_config)
        self._update_kamailio_pjsip_config(ha_config)
        self._manage_services(ha_config)

    def update_ha_state(self, args, options):
        ha_conf = self._read_ha_config()

        active_value = bool(args['active'])
        logger.info("Updating resilience state, from: %s to: %s", ha_conf.active, active_value)
        ha_conf.active = active_value

        if ha_conf.node_type == HaNodeType.SLAVE:
            self._update_postgres(ha_conf)

        self._write_ha_config(ha_conf)

        command_args = ['/usr/sbin/xivo-manage-slave-services', 'start' if ha_conf.active else 'stop']

        subprocess.check_call(command_args, close_fds=True)


    def _write_ha_config(self, ha_config: HAConfig):
        with open(self._ha_conf_file, 'w', encoding='utf-8') as fobj:
            self._write_ha_config_to_fobj(ha_config, fobj)

    def _write_ha_config_to_fobj(self, ha_config: HAConfig, fobj):
        fobj.write(json.dumps(ha_config.dict()))

    def _update_postgres(self, ha_config: HAConfig):
        postgres_updater = self._postgres_config_updater_factory(ha_config)
        postgres_updater.update_pg_hba_file()
        postgres_updater.reload_postgres()

    def _update_cronfiles(self, ha_config: HAConfig):
        node_type = ha_config.node_type
        remote_address = ha_config.remote_address
        self._cronfile_installer.remove_cronfile(self.CRONFILE_MASTER)
        self._cronfile_installer.remove_cronfile(self.CRONFILE_SLAVE)
        if node_type == HaNodeType.MASTER:
            self._add_master_cronfile(remote_address)
        elif node_type == HaNodeType.SLAVE and ha_config.automatic_failover:
            self._add_slave_cronfile(remote_address)

    def _add_master_cronfile(self, remote_address: str):
        content = '0 * * * * root /usr/sbin/xivo-master-slave-db-replication %s >/dev/null\n' \
                  '0 * * * * root /usr/bin/xivo-sync >/dev/null\n' % remote_address
        self._cronfile_installer.add_cronfile(self.CRONFILE_MASTER, content)

    def _add_slave_cronfile(self, remote_address: str):
        content = '* * * * * root /usr/sbin/xivo-check-master-status %s >/dev/null\n' % remote_address
        self._cronfile_installer.add_cronfile(self.CRONFILE_SLAVE, content)

    def _manage_services(self, ha_config: HAConfig):
        if ha_config.node_type != HaNodeType.SLAVE:
            command_args = ['/usr/sbin/xivo-manage-slave-services', 'start']
            subprocess.check_call(command_args, close_fds=True)

    def _update_kamailio_pjsip_config(self, ha_config: HAConfig):
        """Generate Kamailio PJSIP configuration file if file doesn't exist."""
        if os.path.exists(self._kamailio_pjsip_file):
            logger.debug("Kamailio PJSIP config already exists at %s, skipping", self._kamailio_pjsip_file)
            return

        try:
            with open(self._kamailio_pjsip_file, 'w', encoding='utf-8') as fobj:
                fobj.write(KAMAILIO_PJSIP_TEMPLATE)
                fobj.close()

            logger.info("Created Kamailio PJSIP configuration with placeholders at %s",
                       self._kamailio_pjsip_file)
        except Exception as e:
            logger.error("Failed to create Kamailio PJSIP configuration: %s", e)


class _PostgresConfigUpdater:
    DEFAULT_CONFIG_DIR = '/var/lib/postgresql/17/data'
    PG_HBA_FILE = 'pg_hba.conf'

    def __init__(self, ha_config, postgres_config_dir=DEFAULT_CONFIG_DIR):
        self._ha_config = ha_config
        self._pg_hba_file = os.path.join(postgres_config_dir, self.PG_HBA_FILE)

    def update_pg_hba_file(self):
        self._clear_host_line_in_pg_hba()
        if self._ha_config.node_type == HaNodeType.SLAVE and not self._ha_config.active:
            self._append_host_line_in_pg_hba()

    def _clear_host_line_in_pg_hba(self):
        command_args = ['sed', '-i', '/^host asterisk postgres/d', self._pg_hba_file]
        subprocess.check_call(command_args, close_fds=True)

    def _append_host_line_in_pg_hba(self):
        master_ip_address = self._ha_config.remote_address
        host_line = 'host asterisk postgres %s/32 trust\n' % master_ip_address
        with open(self._pg_hba_file, 'a', encoding='utf-8') as fobj:
            fobj.write(host_line)

    def reload_postgres(self):
        # Send a SIGHUP to postgres to make postgres re-read the pg_hba.conf.
        # See pg_hba.conf documentation
        command_args = ['/usr/bin/xivo-dcomp', 'kill', '-s', 'SIGHUP', 'db']
        subprocess.check_call(command_args, close_fds=True)


class _CronFileInstaller:
    DEFAULT_CRON_DIR = '/etc/cron.d'

    def __init__(self, cron_dir=DEFAULT_CRON_DIR):
        self._cron_dir = cron_dir

    def add_cronfile(self, filename, content):
        abs_filename = os.path.join(self._cron_dir, filename)
        with open(abs_filename, 'w', encoding='utf-8') as fobj:
            fobj.write(content)

    def remove_cronfile(self, filename):
        abs_filename = os.path.join(self._cron_dir, filename)
        try:
            os.unlink(abs_filename)
        except OSError as e:
            if e.errno != errno.ENOENT:
                raise


ha_config_manager = HAConfigManager(_PostgresConfigUpdater, _CronFileInstaller())
http_json_server.register(ha_config_manager.get_ha_config, CMD_R,
                          name='get_ha_config')
http_json_server.register(ha_config_manager.update_ha_config, CMD_RW,
                          name='update_ha_config')
http_json_server.register(ha_config_manager.update_ha_state, CMD_RW,
                          name='update_ha_state')
