#!/usr/bin/python3
import os
import sys
import subprocess
import re
import time
import ipaddress
import shutil
import tempfile
import xml.etree.ElementTree as ET

CONF_PATH = '/etc/redis/redis.conf' if os.path.exists('/etc/redis/redis.conf') else '/etc/redis.conf'
SERVICE_NAME = 'redis-server' if os.path.exists('/lib/systemd/system/redis-server.service') else 'redis'

POLICIES = {
    'noeviction': 'noeviction (Return errors)',
    'allkeys-lru': 'allkeys-lru (Evict any key LRU)',
    'volatile-lru': 'volatile-lru (Evict expire keys LRU)',
    'allkeys-lfu': 'allkeys-lfu (Evict any key LFU)',
    'volatile-lfu': 'volatile-lfu (Evict expire keys LFU)',
    'allkeys-random': 'allkeys-random (Random evict)',
    'volatile-random': 'volatile-random (Random expire evict)',
    'volatile-ttl': 'volatile-ttl (Evict nearest TTL)',
}

LOG_LEVELS = {
    'debug': 'debug',
    'verbose': 'verbose',
    'notice': 'notice',
    'warning': 'warning',
}


def add_slist(root):
    slist_policy = ET.SubElement(root, 'slist', {'name': 'maxmemory_policy'})
    for key, label in POLICIES.items():
        v = ET.SubElement(slist_policy, 'val', {'key': key})
        v.text = label

    slist_loglevel = ET.SubElement(root, 'slist', {'name': 'loglevel'})
    for key, label in LOG_LEVELS.items():
        v = ET.SubElement(slist_loglevel, 'val', {'key': key})
        v.text = label


def parse_config():
    config = {
        'bind': '127.0.0.1',
        'port': '6379',
        'maxmemory': '0',
        'maxmemory-policy': 'noeviction',
        'loglevel': 'notice',
        'maxclients': '10000',
        'timeout': '0',
        'tcp-keepalive': '300'
    }
    if os.path.exists(CONF_PATH):
        with open(CONF_PATH, 'r', encoding='utf-8', errors='replace') as f:
            for line in f:
                stripped = line.strip()
                if not stripped or stripped.startswith('#'):
                    continue
                parts = stripped.split(None, 1)
                if len(parts) == 2:
                    key = parts[0].lower()
                    val = parts[1].strip()
                    if key in config:
                        config[key] = val.strip('"\'')
    return config


def write_config(config):
    if not os.path.exists(CONF_PATH):
        return False
    with open(CONF_PATH, 'r', encoding='utf-8', errors='replace') as f:
        lines = f.readlines()

    updated = set()
    new_lines = []
    for line in lines:
        stripped = line.strip()
        if stripped and not stripped.startswith('#'):
            parts = stripped.split(None, 1)
            if len(parts) >= 1:
                key = parts[0].lower()
                if key in config and key not in updated:
                    new_lines.append(f"{key} {config[key]}\n")
                    updated.add(key)
                    continue
                elif key in config and key in updated:
                    continue
        new_lines.append(line)

    for key, val in config.items():
        if key not in updated:
            new_lines.append(f"{key} {val}\n")

    dir_name = os.path.dirname(CONF_PATH)
    fd, tmp_path = tempfile.mkstemp(dir=dir_name, suffix='.tmp')
    try:
        orig_stat = os.stat(CONF_PATH)
        os.fchown(fd, orig_stat.st_uid, orig_stat.st_gid)
        os.fchmod(fd, orig_stat.st_mode)
        with os.fdopen(fd, 'w', encoding='utf-8') as f:
            f.writelines(new_lines)
            f.flush()
            os.fsync(f.fileno())
        os.rename(tmp_path, CONF_PATH)
    except Exception:
        try:
            os.unlink(tmp_path)
        except OSError:
            pass
        raise
    return True


def validate_bind(bind):
    if not bind:
        return False
    parts = bind.split()
    for part in parts:
        try:
            ipaddress.ip_address(part)
        except ValueError:
            return False
    return True


def sanitize(value):
    return value.replace('\n', '').replace('\r', '').replace('\x00', '').strip()


def check_user_level():
    level = int(os.environ.get('AUTH_LEVEL', '0'))
    return level >= 30


def backup_config():
    if os.path.exists(CONF_PATH):
        backup = CONF_PATH + '.bak'
        shutil.copy2(CONF_PATH, backup)
        return backup
    return None


def restore_config(backup_path):
    if backup_path and os.path.exists(backup_path):
        shutil.copy2(backup_path, CONF_PATH)
        os.unlink(backup_path)


root = ET.parse(sys.stdin).getroot()
func = os.environ.get('PARAM_func', '')

if func == 'redis_custom':
    if not check_user_level():
        err_elem = ET.SubElement(root, 'error')
        ET.SubElement(err_elem, 'msg', {'name': 'body'}).text = 'Access denied'
        ET.dump(root)
        sys.exit(0)

    clicked = os.environ.get('PARAM_clicked_button', '')
    sok = os.environ.get('PARAM_sok', '')

    bind = None
    port = None
    maxmemory = None
    maxmemory_policy = None
    loglevel = None
    maxclients = None
    timeout = None
    tcp_keepalive = None
    has_validation_error = False

    if clicked == 'save' or sok == 'ok':
        bind = sanitize(os.environ.get('PARAM_bind', '127.0.0.1'))
        port = sanitize(os.environ.get('PARAM_port', '6379'))
        maxmemory = sanitize(os.environ.get('PARAM_maxmemory', '0'))
        maxmemory_policy = sanitize(os.environ.get('PARAM_maxmemory_policy', 'noeviction'))
        loglevel = sanitize(os.environ.get('PARAM_loglevel', 'notice'))
        maxclients = sanitize(os.environ.get('PARAM_maxclients', '10000'))
        timeout = sanitize(os.environ.get('PARAM_timeout', '0'))
        tcp_keepalive = sanitize(os.environ.get('PARAM_tcp_keepalive', '300'))

        errors = []

        if not validate_bind(bind):
            errors.append('invalid_bind')

        if not re.match(r'^\d+$', port):
            errors.append('invalid_port')
        if not re.match(r'^\d+$', maxclients):
            errors.append('invalid_maxclients')
        if not re.match(r'^\d+$', timeout):
            errors.append('invalid_timeout')
        if not re.match(r'^\d+$', tcp_keepalive):
            errors.append('invalid_tcp_keepalive')
        if not re.match(r'^\d+(?:kb|mb|gb|KB|MB|GB)?$', maxmemory):
            errors.append('invalid_maxmemory')
        if maxmemory_policy not in POLICIES:
            errors.append('invalid_maxmemory_policy')
        if loglevel not in LOG_LEVELS:
            errors.append('invalid_loglevel')

        if errors:

            has_validation_error = True
            err_elem = ET.SubElement(root, 'error')
            ET.SubElement(err_elem, 'msg', {'name': 'body'}).text = \
                'Validation failed. Please check numeric fields for invalid characters or letters.'
            for err in errors:
                param_name = err.replace('invalid_', '')
                ET.SubElement(err_elem, 'param', {'name': param_name})

            config = {
                'bind': bind,
                'port': port,
                'maxmemory': maxmemory,
                'maxmemory-policy': maxmemory_policy,
                'loglevel': loglevel,
                'maxclients': maxclients,
                'timeout': timeout,
                'tcp-keepalive': tcp_keepalive,
            }
        else:

            config_to_save = {
                'bind': bind,
                'port': port,
                'maxmemory': maxmemory,
                'maxmemory-policy': maxmemory_policy,
                'loglevel': loglevel,
                'maxclients': maxclients,
                'timeout': timeout,
                'tcp-keepalive': tcp_keepalive,
            }

            backup_path = backup_config()
            try:
                if write_config(config_to_save):
                    restart_res = subprocess.run(
                        ['systemctl', 'restart', SERVICE_NAME],
                        capture_output=True, text=True, timeout=15
                    )

                    time.sleep(1)

                    status_res = subprocess.run(
                        ['systemctl', 'is-active', SERVICE_NAME],
                        capture_output=True, text=True, timeout=5
                    )

                    if status_res.stdout.strip() != 'active':
                        restore_config(backup_path)
                        subprocess.run(
                            ['systemctl', 'restart', SERVICE_NAME],
                            capture_output=True, text=True, timeout=15
                        )
                        err_elem = ET.SubElement(root, 'error')
                        ET.SubElement(err_elem, 'msg', {'name': 'body'}).text = \
                            'Failed to apply configuration. Changes have been rolled back.'
                        details = (restart_res.stderr or status_res.stderr or '').strip()
                        if details:
                            ET.SubElement(err_elem, 'msg', {'name': 'hint'}).text = details

                        config = parse_config()
                    else:
                        ok_elem = ET.SubElement(root, 'ok', {'msg': 'yes'})
                        ET.SubElement(ok_elem, 'msg', {'name': 'body'}).text = \
                            'Redis settings saved and restarted.'

                        config = parse_config()
                else:
                    err_elem = ET.SubElement(root, 'error')
                    ET.SubElement(err_elem, 'msg', {'name': 'body'}).text = \
                        f'Config file not found: {CONF_PATH}'

                    config = config_to_save
            finally:
                if backup_path and os.path.exists(backup_path):
                    os.unlink(backup_path)
    else:

        config = parse_config()


    for k, v in config.items():
        ET.SubElement(root, k.replace('-', '_')).text = v
    add_slist(root)

ET.dump(root)
