#!/usr/bin/env python3
"""
kea-sync-from-db.py — Serveur HTTP de synchronisation pour DHCPMan (mode FreeBSD).

Expose un endpoint POST /sync sécurisé par token.
Quand il reçoit une requête :
  1. Lit les réservations depuis MySQL
  2. Regénère /usr/local/etc/kea/kea-dhcp4.conf
  3. Appelle l'API Kea Control Agent (config-reload)

Dépendances : mysql-connector-python (pip install mysql-connector-python)
Python : 3.11+

Configuration : /usr/local/etc/kea/kea-sync-from-db.conf
"""

import argparse
import base64
import configparser
import json
import logging
import os
import signal
import sys
import threading
import urllib.error
import urllib.request
from http.server import BaseHTTPRequestHandler, HTTPServer

try:
    import mysql.connector
except ImportError:
    print("Erreur : mysql-connector-python requis. pip install mysql-connector-python", file=sys.stderr)
    sys.exit(1)

# ── Configuration ─────────────────────────────────────────────────────────────

DEFAULT_CONFIG = "/usr/local/etc/kea/kea-sync-from-db.conf"
CONFIG_FILE    = os.environ.get("SYNC_SERVER_CONF", DEFAULT_CONFIG)

conf = configparser.ConfigParser()

def load_config() -> configparser.ConfigParser:
    global conf
    if not os.path.exists(CONFIG_FILE):
        logging.critical("Fichier de config introuvable : %s", CONFIG_FILE)
        sys.exit(1)
    conf.read(CONFIG_FILE, encoding="utf-8")
    return conf

def c(section: str, key: str, fallback: str = "") -> str:
    return conf.get(section, key, fallback=fallback)

# ── Génération du fichier kea-dhcp4.conf ──────────────────────────────────────

def fetch_reservations(cnx) -> list[dict]:
    """Retourne toutes les réservations depuis la table hosts de Kea."""
    cursor = cnx.cursor(dictionary=True)
    cursor.execute("""
        SELECT
            h.host_id,
            h.dhcp4_subnet_id,
            HEX(h.dhcp_identifier) AS mac_hex,
            h.ipv4_address,
            h.hostname,
            h.dhcp4_client_classes
        FROM hosts h
        WHERE h.dhcp_identifier_type = 0
        ORDER BY h.dhcp4_subnet_id, h.ipv4_address
    """)
    rows = cursor.fetchall()
    cursor.close()
    return rows

def fetch_host_options(cnx) -> dict:
    """Retourne les options DHCP par hôte (scope_id=4) indexées par host_id.
    Résultat : {host_id: {code: formatted_value, ...}, ...}
    Cas spécial : si code=3 et valeur='0.0.0.0', la clé '_no_gateway' est ajoutée (never-send).
    """
    cursor = cnx.cursor(dictionary=True)
    cursor.execute("""
        SELECT host_id, code, formatted_value
        FROM dhcp4_options
        WHERE scope_id = 4
          AND formatted_value IS NOT NULL AND formatted_value != ''
    """)
    rows = cursor.fetchall()
    cursor.close()
    result: dict = {}
    for row in rows:
        hid = row["host_id"]
        if hid not in result:
            result[hid] = {}
        code = int(row["code"])
        if code == 3 and row["formatted_value"] == "0.0.0.0":
            result[hid]["_no_gateway"] = True
        else:
            result[hid][code] = row["formatted_value"]
    return result

def fetch_subnets(cnx) -> list[dict]:
    """Retourne les métadonnées des subnets depuis app_subnets."""
    cursor = cnx.cursor(dictionary=True)
    cursor.execute("""
        SELECT kea_subnet_id, name, cidr, interface, gateway, relay_address,
               dns_servers, ntp_servers, range_start, range_end, has_dynamic_pool,
               short_lease_pool_start, short_lease_pool_end
        FROM app_subnets
        WHERE is_active = 1
        ORDER BY kea_subnet_id
    """)
    rows = cursor.fetchall()
    cursor.close()
    return rows

def fetch_relay_settings(cnx) -> dict:
    """Lit les paramètres relay depuis app_settings."""
    cursor = cnx.cursor(dictionary=True)
    cursor.execute("""
        SELECT setting_key, setting_value FROM app_settings
        WHERE setting_key IN ('relay_mode', 'relay_interface')
    """)
    rows = cursor.fetchall()
    cursor.close()
    settings = {r["setting_key"]: r["setting_value"] for r in rows}
    return {
        "relay_mode":      settings.get("relay_mode", "0") == "1",
        "relay_interface": settings.get("relay_interface", ""),
    }

def ip_from_int(n: int) -> str:
    """Convertit un entier Kea (ipv4_address) en IP lisible."""
    return ".".join(str((n >> s) & 0xFF) for s in (24, 16, 8, 0))

def format_mac(hex_str: str) -> str:
    """Formate un hex brut en adresse MAC (bc:dd:c2:47:3d:b6)."""
    h = hex_str.lower()
    return ":".join(h[i:i+2] for i in range(0, len(h), 2))

def build_config(subnets: list[dict], reservations: list[dict], host_options: dict = {}, control_socket: str = "", relay_settings: dict | None = None) -> dict:
    """Construit le dictionnaire de config Kea DHCPv4."""
    if relay_settings is None:
        relay_settings = {"relay_mode": False, "relay_interface": ""}
    relay_mode  = relay_settings["relay_mode"]
    relay_iface = relay_settings["relay_interface"]

    # Regrouper les réservations par subnet_id
    res_by_subnet: dict[int, list] = {}
    for r in reservations:
        sid = r["dhcp4_subnet_id"]
        res_by_subnet.setdefault(sid, []).append(r)

    kea_subnets = []
    for s in subnets:
        sid  = s["kea_subnet_id"]
        cidr = s["cidr"]

        subnet_obj: dict = {
            "id":     sid,
            "subnet": cidr,
        }

        if relay_mode:
            # En mode relay : pas d'interface par subnet, relay GIADDR
            if s.get("relay_address"):
                subnet_obj["relay"] = {"ip-addresses": [s["relay_address"]]}
        else:
            if s.get("interface"):
                subnet_obj["interface"] = s["interface"]

        # Option routeur (gateway)
        option_data = []
        if s.get("gateway"):
            option_data.append({
                "name":  "routers",
                "data":  s["gateway"],
            })
        if s.get("dns_servers"):
            option_data.append({
                "name": "domain-name-servers",
                "data": s["dns_servers"],
            })
        if option_data:
            subnet_obj["option-data"] = option_data

        # Pool dynamique principal + pool short-lease
        pools = []
        if s.get("has_dynamic_pool") and s.get("range_start") and s.get("range_end"):
            pools.append({"pool": f"{s['range_start']} - {s['range_end']}"})
        if s.get("short_lease_pool_start") and s.get("short_lease_pool_end"):
            pools.append({
                "pool":         f"{s['short_lease_pool_start']} - {s['short_lease_pool_end']}",
                "client-class": "short-lease",
            })
        if pools:
            subnet_obj["pools"] = pools

        # Réservations
        hosts = []
        for r in res_by_subnet.get(sid, []):
            mac = format_mac(r["mac_hex"])
            host_obj: dict = {
                "hw-address": mac,
            }
            # ipv4_address = 0 signifie allocation dynamique (pool short-lease)
            if r.get("ipv4_address") and r["ipv4_address"] != 0:
                host_obj["ip-address"] = ip_from_int(r["ipv4_address"])
            if r.get("hostname"):
                host_obj["hostname"] = r["hostname"]

            if r.get("dhcp4_client_classes"):
                host_obj["client-classes"] = [r["dhcp4_client_classes"]]

            # Options DHCP spécifiques à l'hôte (scope_id = 4)
            h_opts = host_options.get(r["host_id"], {})
            host_opt_data = []
            if h_opts.get("_no_gateway"):  # never-send option 3
                host_opt_data.append({"name": "routers", "never-send": True})
            elif 3 in h_opts:             # option 3 — routers (gateway spécifique)
                host_opt_data.append({"name": "routers", "data": h_opts[3]})
            if 42 in h_opts:              # option 42 — ntp-servers
                host_opt_data.append({"name": "ntp-servers", "data": h_opts[42]})
            if 121 in h_opts:             # option 121 — classless-static-routes
                host_opt_data.append({"name": "classless-static-route", "data": h_opts[121]})
            if host_opt_data:
                host_obj["option-data"] = host_opt_data

            hosts.append(host_obj)

        if hosts:
            subnet_obj["reservations"] = hosts

        kea_subnets.append(subnet_obj)

    if relay_mode:
        interfaces_cfg: dict = {
            "interfaces":      [relay_iface] if relay_iface else ["*"],
            "dhcp-socket-type": "udp",
        }
    else:
        seen: list[str] = []
        for s in subnets:
            iface = s.get("interface", "")
            if iface and iface not in seen:
                seen.append(iface)
        interfaces_cfg = {"interfaces": seen}

    dhcp4: dict = {
        "interfaces-config": interfaces_cfg,
        "lease-database": {
            "type":    "memfile",
            "persist": True,
            "name":    "/var/db/kea/kea-leases4.csv",
        },
        "valid-lifetime": 86400,
        "client-classes": [
            {
                "name":          "short-lease",
                "valid-lifetime": 3600,
            }
        ],
        "subnet4": kea_subnets,
        "loggers": [
            {
                "name":           "kea-dhcp4",
                "output_options": [{"output": "syslog"}],
                "severity":       "INFO",
            }
        ],
    }

    if control_socket:
        dhcp4["control-socket"] = {
            "socket-type": "unix",
            "socket-name": control_socket,
        }

    lease_cmds_lib = c("kea", "lease_cmds_lib", "")
    if lease_cmds_lib:
        dhcp4["hooks-libraries"] = [{"library": lease_cmds_lib}]

    return {"Dhcp4": dhcp4}

def write_config(config: dict) -> None:
    """Écrit le fichier kea-dhcp4.conf."""
    output_path = c("kea", "config_file", "/usr/local/etc/kea/kea-dhcp4.conf")
    tmp_path    = output_path + ".tmp"
    content     = json.dumps(config, indent=4)
    with open(tmp_path, "w", encoding="utf-8") as f:
        f.write(content)
    os.replace(tmp_path, output_path)
    logging.info("Config écrite dans %s", output_path)

# ── Appel API Kea ─────────────────────────────────────────────────────────────

def kea_config_reload() -> dict:
    """Appelle config-reload via l'API Kea Control Agent.
    Retourne {"ok": True} ou {"ok": False, "message": "..."}.
    """
    kea_url  = c("kea", "api_url",      "http://localhost:8000/")
    api_user = c("kea", "api_user",     "")
    api_pass = c("kea", "api_password", "")
    payload  = json.dumps({"command": "config-reload", "service": ["dhcp4"]}).encode()
    headers  = {"Content-Type": "application/json"}
    if api_user:
        token = base64.b64encode(f"{api_user}:{api_pass}".encode()).decode()
        headers["Authorization"] = f"Basic {token}"
    req = urllib.request.Request(kea_url, data=payload, headers=headers, method="POST")
    try:
        with urllib.request.urlopen(req, timeout=5) as resp:
            body = json.loads(resp.read().decode())
        if isinstance(body, list) and body[0].get("result") == 0:
            logging.info("Kea config-reload OK")
            return {"ok": True}
        msg = body[0].get("text", str(body)) if isinstance(body, list) else str(body)
        logging.error("Kea config-reload erreur : %s", msg)
        return {"ok": False, "message": f"Kea API erreur : {msg}"}
    except urllib.error.URLError as e:
        if isinstance(e.reason, ConnectionRefusedError):
            msg = "Kea API non joignable (connexion refusée)"
        else:
            msg = f"Kea API non joignable : {e.reason}"
        logging.warning("%s — %s", msg, kea_url)
        return {"ok": False, "message": msg}
    except Exception as e:
        msg = f"Kea API erreur inattendue : {type(e).__name__}"
        logging.error("%s : %s", msg, e)
        return {"ok": False, "message": msg}

# ── MySQL ─────────────────────────────────────────────────────────────────────

def get_db_connection():
    return mysql.connector.connect(
        host     = c("mysql", "host",     "127.0.0.1"),
        port     = int(c("mysql", "port", "3306")),
        database = c("mysql", "database", "kea"),
        user     = c("mysql", "user",     "kea"),
        password = c("mysql", "password", ""),
        connection_timeout = 5,
    )

# ── Handler HTTP ──────────────────────────────────────────────────────────────

class SyncHandler(BaseHTTPRequestHandler):
    def log_message(self, fmt, *args):
        logging.info("HTTP %s - " + fmt, self.address_string(), *args)

    def send_json(self, code: int, data: dict) -> None:
        body = json.dumps(data).encode()
        self.send_response(code)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def do_POST(self):
        if self.path != "/sync":
            self.send_json(404, {"status": "error", "message": "Not found"})
            return

        expected_token = c("server", "token", "")
        received_token = self.headers.get("X-Token", "")
        if expected_token and received_token != expected_token:
            logging.warning("Requête refusée : token invalide depuis %s", self.address_string())
            self.send_json(403, {"status": "error", "message": "Forbidden"})
            return

        try:
            cnx           = get_db_connection()
            subnets       = fetch_subnets(cnx)
            res           = fetch_reservations(cnx)
            host_options  = fetch_host_options(cnx)
            relay_settings = fetch_relay_settings(cnx)
            cnx.close()
        except Exception as e:
            logging.error("Erreur MySQL : %s", e)
            self.send_json(500, {"status": "error", "message": "MySQL: " + str(e)})
            return

        try:
            ctrl_sock = c("kea", "control_socket", "")
            config    = build_config(subnets, res, host_options, ctrl_sock, relay_settings)
            write_config(config)
        except Exception as e:
            logging.error("Erreur génération config : %s", e)
            self.send_json(500, {"status": "error", "message": "Config: " + str(e)})
            return

        kea = kea_config_reload()
        if kea["ok"]:
            self.send_json(200, {"status": "ok", "message": "Config synchronisée et Kea rechargé"})
        else:
            self.send_json(200, {"status": "partial", "message": "Config synchronisée — " + kea["message"]})

    def do_GET(self):
        if self.path == "/health":
            self.send_json(200, {"status": "ok"})
        else:
            self.send_json(404, {"status": "error", "message": "Not found"})

# ── Main ──────────────────────────────────────────────────────────────────────

def oneshot():
    """Regénère kea-dhcp4.conf depuis la base et quitte."""
    try:
        cnx            = get_db_connection()
        subnets        = fetch_subnets(cnx)
        res            = fetch_reservations(cnx)
        host_options   = fetch_host_options(cnx)
        relay_settings = fetch_relay_settings(cnx)
        cnx.close()
    except Exception as e:
        logging.critical("Erreur MySQL : %s", e)
        sys.exit(1)

    ctrl_sock = c("kea", "control_socket", "")
    config    = build_config(subnets, res, host_options, ctrl_sock, relay_settings)
    write_config(config)
    logging.info("Terminé — %d subnet(s), %d réservation(s).", len(subnets), len(res))


def main():
    parser = argparse.ArgumentParser(description="kea-sync-from-db — synchronisation DHCPMan → Kea")
    parser.add_argument(
        "--oneshot",
        action="store_true",
        help="Regénère kea-dhcp4.conf depuis la base et quitte (sans démarrer le serveur HTTP)",
    )
    args = parser.parse_args()

    load_config()

    log_level = getattr(logging, c("server", "log_level", "INFO").upper(), logging.INFO)
    logging.basicConfig(
        level=log_level,
        format="%(asctime)s %(levelname)s %(message)s",
        datefmt="%Y-%m-%d %H:%M:%S",
    )

    if args.oneshot:
        oneshot()
        sys.exit(0)

    host = c("server", "host", "0.0.0.0")
    port = int(c("server", "port", "8765"))

    server = HTTPServer((host, port), SyncHandler)
    logging.info("kea-sync-from-db démarré sur %s:%d", host, port)

    def shutdown(sig, frame):
        logging.info("Arrêt du serveur…")
        # shutdown() doit être appelé depuis un thread distinct pour ne pas
        # bloquer serve_forever() qui tourne dans le thread principal.
        threading.Thread(target=server.shutdown, daemon=True).start()

    signal.signal(signal.SIGTERM, shutdown)
    signal.signal(signal.SIGINT,  shutdown)

    try:
        server.serve_forever()
    finally:
        server.server_close()
        logging.info("Serveur arrêté.")
        sys.exit(0)

if __name__ == "__main__":
    main()
