"""Service exécution patch yum :
   - pre_patch_capture()  : capture services + ports avant patch (wiki SANEF)
   - yum_dryrun()         : `sudo -n yum update --assumeno --exclude=...`
   - yum_update()         : `sudo -n yum update -y --exclude=...`
   - post_patch_compare() : compare services/ports avant/après + rapport
   Réutilise _resolve/_connect du realtime_audit_service.
"""
import base64
import logging
import re
import socket
from datetime import datetime
from typing import Dict, Any, List

from .realtime_audit_service import _resolve, _connect, PARAMIKO_OK

log = logging.getLogger("patchcenter.patch_run")

# Timeouts SSH (secondes)
TIMEOUT_DRYRUN = 60
TIMEOUT_UPDATE = 1800  # 30 min — yum update peut être long

# Whitelist caractères autorisés dans un nom de paquet (anti-injection shell)
EXCLUDE_RE = re.compile(r"^[A-Za-z0-9._*\-/+]+$")


def _safe_excludes(raw) -> List[str]:
    """Normalise et filtre la string `effective_excludes` (séparée par espaces).
       Rejette les patterns suspects pour éviter toute injection."""
    if not raw:
        return []
    parts = str(raw).split()
    return [p for p in parts if EXCLUDE_RE.match(p)]


def _build_cmd(mode: str, excludes: List[str]) -> str:
    """mode = 'dryrun' (--assumeno) ou 'update' (-y)."""
    flag = "--assumeno" if mode == "dryrun" else "-y"
    parts = ["sudo", "-n", "yum", "update", flag]
    for e in excludes:
        parts.append(f"--exclude={e}")
    parts.append("2>&1")
    return " ".join(parts)


def _exec(client, cmd: str, timeout: int) -> Dict[str, Any]:
    try:
        stdin, stdout, stderr = client.exec_command(cmd, timeout=timeout)
        out = stdout.read().decode("utf-8", "replace")
        err = stderr.read().decode("utf-8", "replace")
        rc = stdout.channel.recv_exit_status()
        return {"rc": rc, "stdout": out, "stderr": err}
    except Exception as e:
        return {"rc": -1, "stdout": "", "stderr": f"exec error: {e}"}


def _open_ssh(hostname: str):
    target = _resolve(hostname)
    if not target:
        return None, None, "DNS résolution impossible"
    if not PARAMIKO_OK:
        return None, target, "paramiko non disponible côté serveur PatchCenter"
    client = _connect(target, hostname)
    if not client:
        return None, target, "Connexion SSH échouée"
    return client, target, None


def extract_problem_packages(stdout: str) -> List[str]:
    """Heuristique : extrait les noms de paquets en cause dans les erreurs yum.
       Couvre les motifs courants (multilib, requires, cannot install, conflict).
       Retourne une liste de noms (string courte type 'glibc', 'httpd')."""
    found: set = set()

    def add(name: str):
        if not name:
            return
        # Strip arch suffix : .x86_64, .i686, .noarch
        name = re.sub(r"\.(x86_64|i[3-6]86|noarch|aarch64)$", "", name)
        # Strip version suffix : -1.2.3-rc1.el8 etc. (garde juste le nom)
        m = re.match(r"^([A-Za-z0-9._+]+?)(?:-\d.*)?$", name)
        if m:
            name = m.group(1)
        # Validation finale
        if EXCLUDE_RE.match(name) and len(name) >= 2:
            found.add(name)

    # 1. Multilib : "protection ... : glibc-2.17-326.i686 != glibc-2.17-325.x86_64"
    for m in re.finditer(r":\s+(\S+)\s*!=\s*(\S+)", stdout):
        for v in m.groups():
            add(v)

    # 2. dnf/yum 4 : "Cannot install the best update candidate for package X"
    for m in re.finditer(
        r"(?:Cannot install|Impossible d.installer).*?package\s+(\S+)",
        stdout, re.IGNORECASE):
        add(m.group(1))

    # 3. "Problem: package X requires Y" / "nécessite Y"
    for m in re.finditer(
        r"(?:Problem|Probl.me).*?package\s+(\S+)",
        stdout, re.IGNORECASE):
        add(m.group(1))

    # 4. "Error: Package: X-1.2.3"
    for m in re.finditer(r"(?:Error|Erreur):\s*Package:?\s+(\S+)", stdout):
        add(m.group(1))

    # 5. "conflicts with X provided by Y"
    for m in re.finditer(
        r"conflicts? with\s+(\S+)|conflit avec\s+(\S+)",
        stdout, re.IGNORECASE):
        for v in m.groups():
            if v:
                add(v)

    return sorted(found)


def _summary(stdout: str) -> List[str]:
    """Extrait les lignes-clé du plan yum (compte de paquets, 'Nothing to do'…)."""
    keys = (
        "package(s)", "paquet(s)",
        "nothing to do", "rien à faire",
        "transaction summary", "résumé de la transaction",
        "install ", "installation ", "upgrade ", "mise à niveau",
        "complete!", "terminé !",
        "no packages marked for update", "no package",
    )
    summary = []
    for ln in stdout.splitlines():
        ll = ln.lower().strip()
        if any(k in ll for k in keys):
            summary.append(ln.strip())
            if len(summary) >= 25:
                break
    return summary


def yum_dryrun(hostname: str, excludes_raw) -> Dict[str, Any]:
    """Lance un dry-run (--assumeno). rc=0 → rien à faire, rc=1 → updates dispo (normal)."""
    excludes = _safe_excludes(excludes_raw)
    client, target, err = _open_ssh(hostname)
    if err:
        return {"ok": False, "detail": err, "target": target, "excludes": excludes}
    try:
        cmd = _build_cmd("dryrun", excludes)
        r = _exec(client, cmd, TIMEOUT_DRYRUN)
    finally:
        try:
            client.close()
        except Exception:
            pass
    # Avec --assumeno : rc=0 si "Nothing to do", rc=1 si plan d'updates dispo
    ok = r["rc"] in (0, 1)
    return {
        "ok": ok,
        "rc": r["rc"],
        "cmd": cmd,
        "target": target,
        "excludes": excludes,
        "summary": _summary(r["stdout"]),
        "stdout_tail": r["stdout"][-3000:],
        "stderr": r["stderr"][:500] if r["stderr"] else "",
    }


def yum_stream_lines(hostname: str, excludes_raw, mode: str):
    """Generator SSE-friendly : yield des dicts {type, ...} en live.
       mode = 'dryrun' (--assumeno) ou 'update' (-y)."""
    excludes = _safe_excludes(excludes_raw)
    if mode not in ("dryrun", "update"):
        yield {"type": "error", "msg": f"mode invalide: {mode}"}
        return
    client, target, err = _open_ssh(hostname)
    if err:
        yield {"type": "error", "msg": err}
        return
    cmd = _build_cmd(mode, excludes)
    yield {"type": "cmd", "cmd": cmd, "target": target,
           "excludes": excludes, "hostname": hostname}
    full_lines: List[str] = []
    try:
        stdin, stdout, stderr = client.exec_command(cmd, get_pty=False)
        # Lecture ligne par ligne ; yum bufferise peu son stdout sur opérations longues
        for line in iter(stdout.readline, ""):
            ln = line.rstrip("\n")
            full_lines.append(ln)
            yield {"type": "line", "data": ln}
        rc = stdout.channel.recv_exit_status()
        # Détection de paquets problématiques si KO
        problems: List[str] = []
        if mode == "update" and rc != 0:
            problems = extract_problem_packages("\n".join(full_lines))
        elif mode == "dryrun" and rc not in (0, 1):
            problems = extract_problem_packages("\n".join(full_lines))
        yield {"type": "end", "rc": rc, "problems": problems}
    except Exception as e:
        yield {"type": "error", "msg": f"exec error: {e}"}
    finally:
        try:
            client.close()
        except Exception:
            pass


def yum_update(hostname: str, excludes_raw) -> Dict[str, Any]:
    """Lance le vrai patch (-y)."""
    excludes = _safe_excludes(excludes_raw)
    client, target, err = _open_ssh(hostname)
    if err:
        return {"ok": False, "detail": err, "target": target, "excludes": excludes}
    try:
        cmd = _build_cmd("update", excludes)
        r = _exec(client, cmd, TIMEOUT_UPDATE)
    finally:
        try:
            client.close()
        except Exception:
            pass
    ok = (r["rc"] == 0)
    return {
        "ok": ok,
        "rc": r["rc"],
        "cmd": cmd,
        "target": target,
        "excludes": excludes,
        "summary": _summary(r["stdout"]),
        "stdout_tail": r["stdout"][-5000:],
        "stderr": r["stderr"][:500] if r["stderr"] else "",
    }


# ─── B3.4 + B3.5 — Pre/Post patch capture (scripts du wiki SANEF) ─────────

PRE_PATCH_SCRIPT = r"""#!/bin/bash
# Généré par PatchCenter — capture pré-patching (wiki SANEF "Patch Linux")
HOSTNAME=$(hostname)
SNAPSHOT_DIR="/tmp"
sudo -n systemctl list-units --type=service --state=running --no-pager \
    | awk '{print $1}' | grep '\.service$' \
    > ${SNAPSHOT_DIR}/secops_services_avant_${HOSTNAME}.txt
SVC=$(wc -l < ${SNAPSHOT_DIR}/secops_services_avant_${HOSTNAME}.txt)
echo "OK services_avant=${SVC} -> ${SNAPSHOT_DIR}/secops_services_avant_${HOSTNAME}.txt"
sudo -n ss -tlnup > ${SNAPSHOT_DIR}/secops_ports_avant_${HOSTNAME}.txt
sudo -n ss -tlnup | awk 'NR>1 && $7 != "" {
    match($7, /users:\(\("([^"]+)"/, arr)
    split($5, addr, ":")
    port = addr[length(addr)]
    if (arr[1] != "" && port+0 < 32768) print port, arr[1]
}' | sort -u > ${SNAPSHOT_DIR}/secops_ports_detail_avant_${HOSTNAME}.txt
PORTS=$(grep -c LISTEN ${SNAPSHOT_DIR}/secops_ports_avant_${HOSTNAME}.txt || echo 0)
echo "OK ports_avant=${PORTS} -> ${SNAPSHOT_DIR}/secops_ports_avant_${HOSTNAME}.txt"
"""

POST_PATCH_SCRIPT = r"""#!/bin/bash
# Généré par PatchCenter — comparaison post-patching (wiki SANEF "Patch Linux")
HOSTNAME=$(hostname)
SNAPSHOT_DIR="/tmp"
RAPPORT="/tmp/rapport_patching_${HOSTNAME}_$(date +%Y%m%d_%H%M).txt"
sudo -n systemctl list-units --type=service --state=running --no-pager \
    | awk '{print $1}' | grep '\.service$' \
    > ${SNAPSHOT_DIR}/secops_services_apres_${HOSTNAME}.txt
DISPARUS_SVC=$(comm -23 \
    <(sort ${SNAPSHOT_DIR}/secops_services_avant_${HOSTNAME}.txt) \
    <(sort ${SNAPSHOT_DIR}/secops_services_apres_${HOSTNAME}.txt) \
    | grep -v "user@")
APPARUS_SVC=$(comm -13 \
    <(sort ${SNAPSHOT_DIR}/secops_services_avant_${HOSTNAME}.txt) \
    <(sort ${SNAPSHOT_DIR}/secops_services_apres_${HOSTNAME}.txt) \
    | grep -Ev "setroubleshootd|user@")
{
echo "=== Rapport patching ${HOSTNAME} - $(date '+%Y-%m-%d %H:%M') ==="
echo
echo "--- SERVICES ---"
echo "Avant : $(wc -l < ${SNAPSHOT_DIR}/secops_services_avant_${HOSTNAME}.txt) | Après : $(wc -l < ${SNAPSHOT_DIR}/secops_services_apres_${HOSTNAME}.txt)"
if [ -z "$DISPARUS_SVC" ]; then echo "OK_services_disparus=0"; else echo "KO_services_disparus:"; echo "$DISPARUS_SVC"; fi
if [ -z "$APPARUS_SVC" ]; then echo "OK_services_apparus=0"; else echo "WARN_services_apparus:"; echo "$APPARUS_SVC"; fi
} | tee ${RAPPORT}
sudo -n ss -tlnup > ${SNAPSHOT_DIR}/secops_ports_apres_${HOSTNAME}.txt
sudo -n ss -tlnup | awk 'NR>1 && $7 != "" {
    match($7, /users:\(\("([^"]+)"/, arr)
    split($5, addr, ":")
    port = addr[length(addr)]
    if (arr[1] != "" && port+0 < 32768) print port, arr[1]
}' | sort -u > ${SNAPSHOT_DIR}/secops_ports_detail_apres_${HOSTNAME}.txt
PORTS_DISPARUS=$(comm -23 \
    <(sort ${SNAPSHOT_DIR}/secops_ports_detail_avant_${HOSTNAME}.txt) \
    <(sort ${SNAPSHOT_DIR}/secops_ports_detail_apres_${HOSTNAME}.txt))
PORTS_APPARUS=$(comm -13 \
    <(sort ${SNAPSHOT_DIR}/secops_ports_detail_avant_${HOSTNAME}.txt) \
    <(sort ${SNAPSHOT_DIR}/secops_ports_detail_apres_${HOSTNAME}.txt))
{
echo
echo "--- PORTS ---"
echo "Avant : $(grep -c LISTEN ${SNAPSHOT_DIR}/secops_ports_avant_${HOSTNAME}.txt) | Après : $(grep -c LISTEN ${SNAPSHOT_DIR}/secops_ports_apres_${HOSTNAME}.txt)"
if [ -z "$PORTS_DISPARUS" ]; then echo "OK_ports_disparus=0"; else echo "KO_ports_disparus:"; echo "$PORTS_DISPARUS"; fi
if [ -z "$PORTS_APPARUS" ]; then echo "OK_ports_apparus=0"; else echo "WARN_ports_apparus:"; echo "$PORTS_APPARUS"; fi
echo
echo "Rapport : ${RAPPORT}"
} | tee -a ${RAPPORT}
"""


def _push_and_run(client, remote_path: str, script_content: str, timeout: int) -> Dict[str, Any]:
    """Pousse le script (encode base64) puis l'exécute. Retourne {rc, stdout, stderr}."""
    b64 = base64.b64encode(script_content.encode("utf-8")).decode("ascii")
    cmd = (f"echo '{b64}' | base64 -d > {remote_path} && "
           f"chmod +x {remote_path} && bash {remote_path} 2>&1")
    return _exec(client, cmd, timeout)


def _parse_capture_report(stdout: str) -> Dict[str, Any]:
    """Parse les marqueurs OK_/KO_/WARN_ du script post-patch."""
    parsed = {
        "services_disparus": [], "services_apparus": [],
        "ports_disparus": [], "ports_apparus": [],
        "services_avant": None, "services_apres": None,
        "ports_avant": None, "ports_apres": None,
    }
    section = None  # 'svc_dis', 'svc_app', 'port_dis', 'port_app'
    for ln in stdout.splitlines():
        s = ln.strip()
        if s.startswith("KO_services_disparus"): section = "svc_dis"; continue
        if s.startswith("WARN_services_apparus"): section = "svc_app"; continue
        if s.startswith("KO_ports_disparus"): section = "port_dis"; continue
        if s.startswith("WARN_ports_apparus"): section = "port_app"; continue
        if s.startswith("OK_") or s.startswith("---") or not s:
            section = None
            continue
        if section == "svc_dis": parsed["services_disparus"].append(s)
        elif section == "svc_app": parsed["services_apparus"].append(s)
        elif section == "port_dis": parsed["ports_disparus"].append(s)
        elif section == "port_app": parsed["ports_apparus"].append(s)
    # Compteurs avant/après depuis lignes "Avant : X | Après : Y"
    for ln in stdout.splitlines():
        m = re.match(r"\s*Avant\s*:\s*(\d+).*Apr.s\s*:\s*(\d+)", ln)
        if not m:
            continue
        if parsed["services_avant"] is None:
            parsed["services_avant"] = int(m.group(1))
            parsed["services_apres"] = int(m.group(2))
        elif parsed["ports_avant"] is None:
            parsed["ports_avant"] = int(m.group(1))
            parsed["ports_apres"] = int(m.group(2))
    return parsed


def pre_patch_capture(hostname: str) -> Dict[str, Any]:
    """B3.4 — Capture services+ports avant patch (snapshot dans /tmp côté serveur)."""
    client, target, err = _open_ssh(hostname)
    if err:
        return {"ok": False, "detail": err, "target": target}
    try:
        r = _push_and_run(client, "/tmp/secops_pre_patching.sh",
                          PRE_PATCH_SCRIPT, timeout=60)
    finally:
        try:
            client.close()
        except Exception:
            pass
    ok = (r["rc"] == 0)
    return {
        "ok": ok, "rc": r["rc"], "target": target,
        "stdout": r["stdout"][-2000:],
        "stderr": r["stderr"][:500] if r["stderr"] else "",
    }


def post_patch_compare(hostname: str) -> Dict[str, Any]:
    """B3.5 — Compare services+ports avant/après patch + génère rapport.
       À exécuter après le yum update + reboot, le serveur doit avoir
       ses fichiers /tmp/secops_*_avant_*.txt déjà présents (= pre_patch_capture)."""
    client, target, err = _open_ssh(hostname)
    if err:
        return {"ok": False, "detail": err, "target": target}
    try:
        r = _push_and_run(client, "/tmp/secops_post_patching.sh",
                          POST_PATCH_SCRIPT, timeout=60)
    finally:
        try:
            client.close()
        except Exception:
            pass
    parsed = _parse_capture_report(r["stdout"])
    has_disparus = bool(parsed["services_disparus"]) or bool(parsed["ports_disparus"])
    has_apparus = bool(parsed["services_apparus"]) or bool(parsed["ports_apparus"])
    if r["rc"] != 0:
        status = "ko"
    elif has_disparus:
        status = "ko"
    elif has_apparus:
        status = "warn"
    else:
        status = "ok"
    return {
        "ok": (r["rc"] == 0),
        "status": status,
        "rc": r["rc"],
        "target": target,
        "report": parsed,
        "stdout": r["stdout"][-3000:],
        "stderr": r["stderr"][:500] if r["stderr"] else "",
    }


# ─── B3.6 — Reboot manuel sur clic + polling reconnexion ──────────────────
# IMPORTANT : reboot_host() n'est JAMAIS lancé automatiquement.
# Toujours déclenché par un clic utilisateur explicite avec double confirmation
# côté frontend.

def reboot_host(hostname: str) -> Dict[str, Any]:
    """Lance reboot avec délai +1 min (laisse le temps à SSH de retourner).
       Appelée uniquement après confirmation explicite côté UI."""
    client, target, err = _open_ssh(hostname)
    if err:
        return {"ok": False, "detail": err, "target": target}
    try:
        cmd = "sudo -n shutdown -r +1 'PatchCenter post-patch reboot' 2>&1"
        r = _exec(client, cmd, timeout=15)
    finally:
        try:
            client.close()
        except Exception:
            pass
    ok = (r["rc"] == 0)
    return {
        "ok": ok,
        "rc": r["rc"],
        "cmd": cmd,
        "target": target,
        "started_at": datetime.now().isoformat(timespec="seconds"),
        "stdout": r["stdout"][:500],
        "stderr": r["stderr"][:500] if r["stderr"] else "",
    }


def reboot_status(hostname: str) -> Dict[str, Any]:
    """Vérifie si le serveur est revenu : TCP/22 puis SSH 'uptime'.
       À appeler en boucle (toutes les 10s côté frontend)."""
    target = _resolve(hostname)
    if not target:
        return {"reachable": False, "tcp22": False, "ssh": False,
                "detail": "DNS résolution impossible", "target": None}
    # 1. TCP/22
    try:
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.settimeout(3)
        sock.connect((target, 22))
        sock.close()
    except Exception as e:
        return {"reachable": False, "tcp22": False, "ssh": False,
                "target": target, "detail": str(e)[:200]}
    # 2. SSH minimal — uptime
    if not PARAMIKO_OK:
        return {"reachable": True, "tcp22": True, "ssh": False, "target": target,
                "detail": "paramiko absent côté serveur PatchCenter"}
    client = _connect(target, hostname)
    if not client:
        return {"reachable": True, "tcp22": True, "ssh": False, "target": target,
                "detail": "TCP/22 OK mais SSH KO (probablement encore en boot)"}
    try:
        r = _exec(client, "uptime 2>&1", timeout=10)
    finally:
        try:
            client.close()
        except Exception:
            pass
    return {"reachable": True, "tcp22": True, "ssh": True, "target": target,
            "uptime": (r.get("stdout") or "").strip()[:300]}
