#!/usr/bin/env python3
"""
souleyez.core.tool_chaining - Intelligent tool chaining and workflow automation

Automatically triggers follow-up scans based on discovered services and findings.
"""
from typing import List, Dict, Any, Optional
from dataclasses import dataclass, field
from datetime import datetime
from urllib.parse import urlparse


# Category constants for chain rules
CATEGORY_CTF = "ctf"           # Lab/learning scenarios - vulnerable by design
CATEGORY_ENTERPRISE = "enterprise"  # Real-world enterprise testing
CATEGORY_GENERAL = "general"   # Standard recon that applies everywhere

# Category display icons
CATEGORY_ICONS = {
    CATEGORY_CTF: "🎯",
    CATEGORY_ENTERPRISE: "🏢",
    CATEGORY_GENERAL: "⚙️",
}

# Service groups for smarter condition matching
# When matching 'service:http', also match these services/ports
SERVICE_GROUPS = {
    'http': {
        'services': ['http', 'https', 'http-alt', 'http-proxy', 'https-alt'],
        'ports': [80, 443, 8080, 8000, 8443, 3000, 5000, 8888, 9000, 9090],
    },
    'smb': {
        'services': ['microsoft-ds', 'netbios-ssn', 'smb'],
        'ports': [445, 139],
    },
    'database': {
        'services': ['mysql', 'postgresql', 'ms-sql-s', 'oracle', 'mongodb'],
        'ports': [3306, 5432, 1433, 1521, 27017],
    },
    'ldap': {
        'services': ['ldap', 'ldaps'],
        'ports': [389, 636],
    },
    'rdp': {
        'services': ['ms-wbt-server', 'rdp'],
        'ports': [3389],
    },
    'ssh': {
        'services': ['ssh'],
        'ports': [22, 2222],
    },
    'ftp': {
        'services': ['ftp', 'ftps'],
        'ports': [21, 990],
    },
    # macOS-specific services
    'afp': {
        'services': ['afp', 'afpovertcp'],
        'ports': [548],
    },
    'ard': {
        'services': ['vnc', 'rfb', 'ard', 'apple-remote-desktop'],
        'ports': [5900, 3283],
    },
    'mdns': {
        'services': ['mdns', 'zeroconf', 'bonjour'],
        'ports': [5353],
    },
    # Router-specific services
    'upnp': {
        'services': ['upnp', 'ssdp'],
        'ports': [1900, 2869],
    },
    'tr069': {
        'services': ['tr-069', 'cwmp', 'acs'],
        'ports': [7547, 4567],
    },
    'telnet': {
        'services': ['telnet'],
        'ports': [23],
    },
}


def classify_os_device(os_string: str, services: list) -> dict:
    """
    Classify OS and device type from nmap output.

    Args:
        os_string: OS detection string from nmap (e.g., "Linux 4.x", "Mac OS X 10.15")
        services: List of service dicts with 'port', 'service_name' keys

    Returns:
        {
            'os_family': 'macos' | 'linux' | 'windows' | 'ios' | 'router' | 'unknown',
            'device_type': 'desktop' | 'server' | 'router' | 'mobile' | 'unknown',
            'vendor': Optional vendor string (e.g., 'apple', 'cisco', 'netgear')
        }
    """
    os_lower = (os_string or '').lower()
    ports = {s.get('port') for s in services if s.get('port')}
    service_names = {s.get('service_name', '').lower() for s in services}

    # macOS detection
    if any(x in os_lower for x in ['macos', 'mac os', 'darwin', 'os x', 'apple mac']):
        return {'os_family': 'macos', 'device_type': 'desktop', 'vendor': 'apple'}

    # iOS detection
    if any(x in os_lower for x in ['ios', 'iphone', 'ipad', 'ipod']):
        return {'os_family': 'ios', 'device_type': 'mobile', 'vendor': 'apple'}

    # Router detection - by service ports and OS strings
    router_ports = {1900, 7547, 8080, 8443}  # UPnP, TR-069, admin panels
    router_keywords = ['router', 'gateway', 'mikrotik', 'openwrt', 'dd-wrt', 'tomato',
                       'netgear', 'linksys', 'tp-link', 'asus rt', 'd-link', 'zyxel']
    if any(x in os_lower for x in router_keywords):
        vendor = None
        for v in ['netgear', 'linksys', 'tp-link', 'asus', 'd-link', 'zyxel', 'cisco', 'mikrotik']:
            if v in os_lower:
                vendor = v
                break
        return {'os_family': 'linux', 'device_type': 'router', 'vendor': vendor}

    # Check for router by typical service combination (UPnP + web + no SSH or common router services)
    if (ports & router_ports) and ('upnp' in service_names or 'ssdp' in service_names):
        return {'os_family': 'linux', 'device_type': 'router', 'vendor': None}

    # Windows detection
    if any(x in os_lower for x in ['windows', 'win32', 'win64', 'microsoft']):
        return {'os_family': 'windows', 'device_type': 'desktop', 'vendor': 'microsoft'}

    # Linux server/general detection
    if 'linux' in os_lower:
        # Check if it looks like a server (common server ports)
        server_ports = {22, 80, 443, 3306, 5432, 6379, 27017}
        if ports & server_ports:
            return {'os_family': 'linux', 'device_type': 'server', 'vendor': None}
        return {'os_family': 'linux', 'device_type': 'desktop', 'vendor': None}

    return {'os_family': 'unknown', 'device_type': 'unknown', 'vendor': None}


@dataclass
class ChainRule:
    """Defines when and how to chain tools together."""
    trigger_tool: str  # Tool that triggers this rule (e.g., 'nmap')
    trigger_condition: str  # What to look for (e.g., 'http_service', 'smb_service', 'finding:cve')
    target_tool: str  # Tool to run next (e.g., 'nuclei', 'gobuster')
    priority: int = 5  # Higher = more important (1-10)
    args_template: List[str] = field(default_factory=list)  # Args for target tool
    enabled: bool = True
    description: str = ""
    category: str = CATEGORY_GENERAL  # ctf, enterprise, or general
    trigger_count: int = 0  # How many times this rule has fired

    def matches(self, context: Dict[str, Any]) -> bool:
        """Check if this rule should trigger given the context."""
        # Context contains: service info, findings, host data, etc.

        # Support AND conditions (e.g., 'has:domains+port:88')
        if '&' in self.trigger_condition:
            conditions = [c.strip() for c in self.trigger_condition.split('&')]
            # All conditions must match
            return all(self._check_single_condition(cond, context) for cond in conditions)

        return self._check_single_condition(self.trigger_condition, context)

    def _check_single_condition(self, condition: str, context: Dict[str, Any]) -> bool:
        """Check if a single condition matches. Supports negation with '!' prefix."""
        # Check for negation
        negated = condition.startswith('!')
        if negated:
            condition = condition[1:]  # Remove '!' prefix

        result = False

        if ':' in condition:
            # Complex condition like 'service:http', 'port:445', 'finding:cve'
            cond_type, cond_value = condition.split(':', 1)

            if cond_type == 'service':
                services = context.get('services', [])

                # Check for combined services (e.g., 'mysql+http')
                if '+' in cond_value:
                    required_services = [s.strip().lower() for s in cond_value.split('+')]
                    found_services = [s.get('service_name', '').lower() for s in services]
                    result = all(req in found_services for req in required_services)
                else:
                    # Single service match
                    cond_lower = cond_value.lower()
                    
                    # Check if this service has a group definition
                    if cond_lower in SERVICE_GROUPS:
                        group = SERVICE_GROUPS[cond_lower]
                        # Match by service name OR by port
                        result = any(
                            s.get('service_name', '').lower() in group['services'] or
                            s.get('port') in group['ports']
                            for s in services
                        )
                    else:
                        # Exact match for services not in groups
                        result = any(s.get('service_name', '').lower() == cond_lower for s in services)

            elif cond_type == 'port':
                services = context.get('services', [])
                port_num = int(cond_value)
                # Only match open ports (not closed or filtered)
                result = any(
                    s.get('port') == port_num and
                    s.get('state', '').lower() in ['open', 'open|filtered']
                    for s in services
                )

            elif cond_type == 'finding':
                findings = context.get('findings', [])
                if cond_value == 'any':
                    result = len(findings) > 0
                else:
                    # Check for specific finding type or keyword
                    result = any(cond_value.lower() in str(f.get('title', '')).lower() for f in findings)

            elif cond_type == 'has':
                # Check if context has a specific key with data
                result = bool(context.get(cond_value))

            elif cond_type == 'category':
                # Check directory category (database_admin, wordpress, drupal, vulnerable_app, custom_php)
                result = context.get('directory_category', '').lower() == cond_value.lower()

            elif cond_type == 'os':
                # Check OS family (macos, linux, windows, ios)
                # e.g., 'os:macos', 'os:linux', 'os:windows'
                os_family = context.get('os_family', '').lower()
                result = cond_value.lower() in os_family

            elif cond_type == 'device':
                # Check device type (router, desktop, server, mobile)
                # e.g., 'device:router', 'device:desktop'
                device_type = context.get('device_type', '').lower()
                result = cond_value.lower() == device_type

            elif cond_type == 'vendor':
                # Check vendor (apple, cisco, netgear, etc.)
                # e.g., 'vendor:apple', 'vendor:cisco'
                vendor = context.get('vendor', '') or ''
                result = cond_value.lower() in vendor.lower()

        # Apply negation if needed
        return not result if negated else result

    def generate_command(self, context: Dict[str, Any]) -> Dict[str, Any]:
        """Generate the command to run based on context."""
        target = context.get('target', '')
        args = self.args_template.copy()

        # For finding-based triggers, use the finding's URL if available
        # Prefer cleaner paths: exact matches over partial, directories over files
        if 'finding:' in self.trigger_condition:
            findings = context.get('findings', [])
            cond_value = self.trigger_condition.split(':', 1)[1].lower()
            matching_findings = []

            for finding in findings:
                if cond_value in str(finding.get('title', '')).lower():
                    if finding.get('url'):
                        matching_findings.append(finding)

            if matching_findings:
                # Sort to prefer: 1) exact path match, 2) no file extension, 3) shorter URL
                def score_finding(f):
                    url = f.get('url', '')
                    path = url.split('?')[0]  # Remove query string
                    # Prefer paths ending with the pattern (exact match)
                    exact_match = path.rstrip('/').endswith('/' + cond_value) or path.rstrip('/') == '/' + cond_value
                    # Prefer paths without file extensions
                    has_extension = '.' in path.split('/')[-1]
                    # Prefer shorter paths
                    return (not exact_match, has_extension, len(url))

                matching_findings.sort(key=score_finding)
                target = matching_findings[0]['url']

        # For domain-based triggers or AD tools, use domain as target
        domain = ''
        dc_ip = target  # Default DC IP is the target IP
        domains = context.get('domains', [])

        # Check if we have domain context (either from trigger condition or database enrichment)
        if domains and ('has:domains' in self.trigger_condition or self.target_tool in ['impacket-getnpusers']):
            # Use first discovered domain
            domain_info = domains[0]
            domain = domain_info.get('domain', '')
            dc_ip = domain_info.get('ip', target)
            # For AD tools like GetNPUsers, target should be "DOMAIN/"
            if domain and self.target_tool in ['impacket-getnpusers']:
                target = f"{domain}/"

        # Find the relevant port for this service
        port = ''
        if 'port:' in self.trigger_condition:
            port_num = self.trigger_condition.split(':')[1]
            port = port_num
        elif 'service:' in self.trigger_condition:
            service_name = self.trigger_condition.split(':')[1].lower()
            # Find port for this service - check both service name AND SERVICE_GROUPS ports
            for svc in context.get('services', []):
                svc_name = svc.get('service_name', '').lower()
                svc_port = svc.get('port')

                # Direct service name match
                if svc_name == service_name:
                    port = str(svc_port)
                    break

                # Check SERVICE_GROUPS - if condition is 'service:http' and port is in http ports
                if service_name in SERVICE_GROUPS:
                    group = SERVICE_GROUPS[service_name]
                    if svc_port in group.get('ports', []):
                        port = str(svc_port)
                        break

        # Calculate subnet for {subnet} placeholder (e.g., 10.0.0.88 → 10.0.0.0/24)
        subnet = ''
        if '{subnet}' in str(args):
            try:
                import ipaddress
                # Use the original target (dc_ip might be modified)
                original_target = context.get('target', target)
                # Remove domain prefix if present (e.g., "DOMAIN/" → use dc_ip)
                ip_to_use = dc_ip if '/' in target else original_target
                ip_obj = ipaddress.ip_address(ip_to_use)
                # Assume /24 subnet (common for small networks)
                network = ipaddress.ip_network(f"{ip_to_use}/24", strict=False)
                subnet = str(network)
            except:
                subnet = ''

        # Get database and table from context (for SQLMap chaining)
        database = context.get('database', '')
        table = context.get('table', '')

        # Get POST data for SQLMap POST injections
        post_data = context.get('post_data') or ''

        # Extract path from target URL for {path} placeholder (used by Hydra)
        path = ''
        if target.startswith(('http://', 'https://')):
            try:
                parsed = urlparse(target)
                path = parsed.path or '/'
            except:
                path = '/'

        # Replace placeholders in args
        args = [arg.replace('{target}', target).replace('{port}', port)
                   .replace('{domain}', domain).replace('{dc_ip}', dc_ip)
                   .replace('{subnet}', subnet).replace('{database}', database)
                   .replace('{table}', table).replace('{path}', path)
                   .replace('{post_data}', post_data)
                for arg in args]

        # For SQLMap with POST injections, add --data if we have POST data
        if self.target_tool == 'sqlmap' and post_data and '--data' not in str(args):
            # Insert --data after -u argument
            for i, arg in enumerate(args):
                if arg == '-u':
                    # Insert --data=POST_DATA after the URL (i.e., after args[i+1])
                    args.insert(i + 2, f'--data={post_data}')
                    break

        # Replace {subnet} in target as well if needed
        if '{subnet}' in target:
            target = subnet

        return {
            'tool': self.target_tool,
            'target': target,
            'args': args,
            'priority': self.priority,
            'reason': f"Auto-triggered by {self.trigger_tool}: {self.description}"
        }


class ToolChaining:
    """Manages automatic tool chaining and workflow automation."""

    _instance = None  # Singleton instance

    # Cache for SQLMap injection points (for fallback)
    # Key: target host, Value: list of injection points
    _injection_points_cache = {}

    # SQLMap limits and timeouts
    MAX_DATABASES_TO_ENUMERATE = 15  # Enumerate more databases for thorough engagements
    
    # Phase-specific timeouts (seconds)
    SQLMAP_TIMEOUT_DBS_PHASE = 300      # 5 minutes to enumerate databases
    SQLMAP_TIMEOUT_TABLES_PHASE = 600   # 10 minutes per database for tables
    SQLMAP_TIMEOUT_DUMP_PHASE = 900     # 15 minutes per table dump
    
    # Row limit for dumps (already using --stop flag, this is for documentation)
    SQLMAP_MAX_ROWS_PER_TABLE = 100

    def __new__(cls):
        """Ensure only one instance exists (singleton pattern)."""
        if cls._instance is None:
            cls._instance = super().__new__(cls)
            cls._instance._initialized = False
        return cls._instance

    def __init__(self):
        """Initialize with default chain rules."""
        # Only initialize once
        if self._initialized:
            return

        self.rules: List[ChainRule] = []
        self.enabled: bool = self._load_enabled_state()  # Load from persistent storage
        self.approval_mode: bool = self._load_approval_mode()  # Load approval mode state
        self._init_default_rules()
        self.load_rules_state()  # Load saved enabled/disabled states
        self._initialized = True

    def _init_default_rules(self):
        """Set up default chaining rules."""

        # Web service discovered → run web scanners
        self.rules.extend([
            # Modern vulnerability scanner (Nuclei) - PRIORITY
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='service:http',
                target_tool='nuclei',
                priority=9,
                args_template=['-rate-limit', '50', '-c', '10', '-timeout', '10'],
                description='Web server detected (HTTP/HTTPS), scanning with Nuclei'
            ),
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='service:http',
                target_tool='gobuster',
                priority=7,
                args_template=['dir', '-u', 'http://{target}:{port}', '-w', 'data/wordlists/web_dirs_common.txt', '-x', 'js,json,php,asp,aspx,html,txt,bak,old,zip', '--no-error', '--timeout', '30s', '-t', '5', '--delay', '20ms', '--exclude-length', '75002'],
                description='Web server detected (HTTP/HTTPS), discovering directories and files'
            ),
            # Nikto - web server vulnerability scanner (complements nuclei)
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='service:http',
                target_tool='nikto',
                priority=8,
                args_template=['-nointeractive', '-timeout', '10'],
                description='Web server detected, scanning for server misconfigurations with Nikto'
            ),
            # Dalfox - XSS scanner triggered after gobuster finds pages
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='has:results',
                target_tool='dalfox',
                priority=6,
                args_template=['--deep-domxss', '--format', 'json', '--skip-bav'],
                description='Web pages discovered, scanning for XSS vulnerabilities with Dalfox'
            ),
            # Dalfox - XSS scan after ffuf fuzzing
            ChainRule(
                trigger_tool='ffuf',
                trigger_condition='has:results',
                target_tool='dalfox',
                priority=6,
                args_template=['--deep-domxss', '--format', 'json', '--skip-bav'],
                description='Fuzzing found pages, scanning for XSS vulnerabilities with Dalfox'
            ),
            # Dalfox - XSS scan after nikto finds pages
            ChainRule(
                trigger_tool='nikto',
                trigger_condition='has:results',
                target_tool='dalfox',
                priority=6,
                args_template=['--deep-domxss', '--format', 'json', '--skip-bav'],
                description='Nikto found pages, scanning for XSS vulnerabilities with Dalfox'
            ),
            # Dalfox - Deep XSS scan if nuclei hints at XSS
            ChainRule(
                trigger_tool='nuclei',
                trigger_condition='finding:xss',
                target_tool='dalfox',
                priority=7,
                args_template=['--deep-domxss', '--format', 'json'],
                description='Nuclei detected potential XSS, deep scanning with Dalfox'
            ),
            # Nikto - Catch alternative HTTP ports
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='port:8080',
                target_tool='nikto',
                priority=7,
                args_template=['-nointeractive', '-timeout', '10'],
                description='HTTP on port 8080 detected, scanning with Nikto'
            ),
            # Dalfox - XSS scan WordPress pages after wpscan
            ChainRule(
                trigger_tool='wpscan',
                trigger_condition='has:results',
                target_tool='dalfox',
                priority=6,
                args_template=['--deep-domxss', '--format', 'json', '--skip-bav'],
                description='WPScan found pages, scanning for XSS vulnerabilities with Dalfox'
            ),
        ])

        # SMB service discovered → enumerate shares
        self.rules.extend([
            # Modern Windows/AD tool (CrackMapExec/NetExec) - PRIORITY
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='service:smb',
                target_tool='crackmapexec',
                priority=10,
                args_template=['smb', '--shares'],
                description='SMB service detected, enumerating with CrackMapExec'
            ),
            # Legacy tool (enum4linux) - lower priority
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='service:smb',
                target_tool='enum4linux',
                priority=8,
                enabled=True,
                args_template=['-a', '{target}'],
                description='SMB service detected, enumerating shares and users (runs after CrackMapExec)'
            ),
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='service:smb',
                target_tool='smbmap',
                priority=7,
                args_template=['-H', '{target}'],
                description='SMB service detected, mapping shares'
            ),
        ])

        # Active Directory attacks - smart chaining workflow
        self.rules.extend([
            # Stage 1: Domain discovered → Verify Kerberos is running (TCP scan - no root needed)
            ChainRule(
                trigger_tool='crackmapexec',
                trigger_condition='has:domains',
                target_tool='nmap',
                priority=9,
                args_template=['-sT', '-p', '88', '--reason'],
                description='Domain discovered, checking for Kerberos (TCP port 88)'
            ),
            ChainRule(
                trigger_tool='enum4linux',
                trigger_condition='has:domains',
                target_tool='nmap',
                priority=9,
                args_template=['-sT', '-p', '88', '--reason'],
                description='Domain discovered, checking for Kerberos (TCP port 88)'
            ),
            # Stage 2: Kerberos port found → AS-REP Roasting attack
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='port:88',
                target_tool='impacket-getnpusers',
                priority=8,
                args_template=['-no-pass', '-usersfile', 'data/wordlists/all_users.txt', '-dc-ip', '{dc_ip}'],
                description='Kerberos detected, attempting AS-REP Roasting for accounts with pre-auth disabled'
            ),
            # Stage 3: Domain member detected (has domain but NOT a DC) → Find the actual DC
            ChainRule(
                trigger_tool='crackmapexec',
                trigger_condition='has:domains & !port:88',
                target_tool='nmap',
                priority=8,
                args_template=['-p', '88,389,636,3268,3269', '-sT', '-Pn', '--open', '{subnet}'],
                description='Domain member detected, scanning subnet for Domain Controller'
            ),
            ChainRule(
                trigger_tool='enum4linux',
                trigger_condition='has:domains & !port:88',
                target_tool='nmap',
                priority=8,
                args_template=['-p', '88,389,636,3268,3269', '-sT', '-Pn', '--open', '{subnet}'],
                description='Domain member detected, scanning subnet for Domain Controller'
            ),
        ])

        # FTP service discovered → check for anonymous access
        self.rules.append(
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='service:ftp',
                target_tool='msf_auxiliary',
                priority=7,
                args_template=['auxiliary/scanner/ftp/anonymous'],
                description='FTP service detected, checking anonymous access'
            )
        )

        # MySQL/PostgreSQL discovered → check default creds
        self.rules.extend([
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='service:mysql',
                target_tool='msf_auxiliary',
                priority=6,
                args_template=['auxiliary/scanner/mysql/mysql_login'],
                description='MySQL detected, checking default credentials'
            ),
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='service:postgresql',
                target_tool='msf_auxiliary',
                priority=6,
                args_template=['auxiliary/scanner/postgres/postgres_login'],
                description='PostgreSQL detected, checking default credentials'
            ),
        ])

        # SSH service discovered → enumerate users and check default creds
        self.rules.extend([
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='service:ssh',
                target_tool='msf_auxiliary',
                priority=6,
                args_template=['auxiliary/scanner/ssh/ssh_enumusers', 'USER_FILE=data/wordlists/soul_users.txt'],
                description='SSH detected, enumerating users'
            ),
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='service:ssh',
                target_tool='msf_auxiliary',
                priority=5,
                # SSH_CLIENT_KEX allows legacy key exchange for old/vulnerable servers (Metasploitable, etc.)
                args_template=['auxiliary/scanner/ssh/ssh_login', 'USER_FILE=data/wordlists/soul_users.txt', 'STOP_ON_SUCCESS=false', 'BLANK_PASSWORDS=true', 'USER_AS_PASS=true', 'VERBOSE=true', 'SSH_CLIENT_KEX=diffie-hellman-group14-sha1,diffie-hellman-group1-sha1'],
                description='SSH detected, checking weak credentials'
            ),
        ])

        # SMB service discovered → enumerate shares and version
        self.rules.extend([
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='service:smb',
                target_tool='msf_auxiliary',
                priority=7,
                args_template=['auxiliary/scanner/smb/smb_version'],
                description='SMB detected, identifying version and OS'
            ),
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='service:smb',
                target_tool='msf_auxiliary',
                priority=6,
                args_template=['auxiliary/scanner/smb/smb_enumshares'],
                description='SMB detected, enumerating shares'
            ),
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='service:smb',
                target_tool='msf_auxiliary',
                priority=8,
                args_template=['auxiliary/scanner/smb/smb_ms17_010'],
                description='SMB detected, checking for EternalBlue (MS17-010)'
            ),
        ])

        # RPC service discovered → enumerate endpoints and named pipes
        self.rules.extend([
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='port:135',
                target_tool='msf_auxiliary',
                priority=7,
                args_template=['auxiliary/scanner/dcerpc/endpoint_mapper'],
                description='RPC detected, enumerating DCERPC endpoints'
            ),
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='port:135',
                target_tool='msf_auxiliary',
                priority=6,
                args_template=['auxiliary/scanner/smb/pipe_auditor'],
                description='RPC detected, enumerating named pipes'
            ),
        ])

        # SMTP service discovered → enumerate users
        self.rules.append(
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='service:smtp',
                target_tool='msf_auxiliary',
                priority=5,
                args_template=['auxiliary/scanner/smtp/smtp_enum'],
                description='SMTP detected, enumerating users'
            )
        )

        # RDP service discovered → check for vulnerabilities
        self.rules.extend([
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='service:ms-wbt-server',
                target_tool='msf_auxiliary',
                priority=8,
                args_template=['auxiliary/scanner/rdp/cve_2019_0708_bluekeep'],
                description='RDP detected, checking for BlueKeep (CVE-2019-0708)'
            ),
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='service:ms-wbt-server',
                target_tool='msf_auxiliary',
                priority=5,
                args_template=['auxiliary/scanner/rdp/rdp_scanner'],
                description='RDP detected, scanning service'
            ),
        ])

        # WinRM service discovered → enumerate with CrackMapExec
        self.rules.extend([
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='port:5985',
                target_tool='crackmapexec',
                priority=9,
                args_template=['winrm', '{target}', '--port', '5985'],
                description='WinRM (HTTP) detected, enumerating with CrackMapExec'
            ),
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='port:5986',
                target_tool='crackmapexec',
                priority=9,
                args_template=['winrm', '{target}', '--port', '5986'],
                description='WinRM (HTTPS) detected, enumerating with CrackMapExec'
            ),
        ])

        # LDAP service discovered → enumerate Active Directory
        self.rules.extend([
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='service:ldap',
                target_tool='crackmapexec',
                priority=7,
                args_template=['ldap', '{target}'],
                description='LDAP/LDAPS detected, enumerating Active Directory'
            ),
        ])

        # Telnet service discovered → check default creds
        self.rules.append(
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='service:telnet',
                target_tool='msf_auxiliary',
                priority=6,
                args_template=['auxiliary/scanner/telnet/telnet_login', 'USER_FILE=data/wordlists/soul_users.txt', 'STOP_ON_SUCCESS=false', 'BLANK_PASSWORDS=true', 'USER_AS_PASS=true', 'VERBOSE=true'],
                description='Telnet detected, checking weak credentials'
            )
        )

        # MSSQL service discovered → check default creds and enumerate
        self.rules.extend([
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='service:ms-sql-s',
                target_tool='msf_auxiliary',
                priority=6,
                args_template=['auxiliary/scanner/mssql/mssql_login'],
                description='MSSQL detected, checking default credentials'
            ),
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='service:ms-sql-s',
                target_tool='msf_auxiliary',
                priority=5,
                args_template=['auxiliary/scanner/mssql/mssql_ping'],
                description='MSSQL detected, discovering instances'
            ),
        ])

        # VNC service discovered → check for no-auth and weak passwords
        self.rules.extend([
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='service:vnc',
                target_tool='msf_auxiliary',
                priority=7,
                args_template=['auxiliary/scanner/vnc/vnc_none_auth'],
                description='VNC detected, checking for no authentication'
            ),
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='service:vnc',
                target_tool='msf_auxiliary',
                priority=5,
                args_template=['auxiliary/scanner/vnc/vnc_login'],
                description='VNC detected, checking weak passwords'
            ),
        ])

        # MySQL + HTTP service discovered → test web app for SQL injection
        self.rules.append(
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='service:mysql+http',
                target_tool='sqlmap',
                priority=8,
                enabled=False,  # This rule is not needed
                args_template=['-u', 'http://{target}/', '--batch', '--crawl=2', '--risk=2', '--level=3',
                              '--threads=5'],
                description='MySQL + HTTP detected, testing web app for SQL injection'
            )
        )

        # Web path with parameters found → test for SQL injection
        self.rules.append(
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='has:paths_with_params',
                target_tool='sqlmap',
                priority=9,
                args_template=['-u', '{target}', '--batch', '--crawl=2', '--risk=2', '--level=3',
                              '--threads=5'],
                description='Parametrized URL found, testing for SQL injection'
            )
        )

        # Gobuster discovered PHP files → crawl base URL once to find parametrized URLs
        # NOTE: Special handling in auto_chain() limits to ONE crawl per base URL
        self.rules.append(
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='has:php_files',
                target_tool='sqlmap',
                priority=7,
                args_template=['-u', '{target}', '--batch', '--crawl=2', '--risk=2', '--level=3', '--forms',
                              '--threads=5'],
                description='PHP files discovered, crawling site to find parametrized pages and forms'
            )
        )

        # SMART API DISCOVERY CHAIN
        # Replaced broken direct SQLMap rules with intelligent two-step approach
        # Old rules tried to test /api and /rest directories directly (no parameters)
        # New approach: ffuf discovers actual endpoints, then SQLMap tests them
        # Step 1: gobuster found /api → ffuf discovers actual endpoints
        self.rules.append(
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='finding:api',
                target_tool='ffuf',
                priority=8,
                args_template=[
                    '-u', '{target}/FUZZ',
                    '-w', 'data/wordlists/api_endpoints.txt',
                    '-t', '5', '-p', '0.02',
                    '-mc', '200,201,301,302,401,403,405'
                ],
                description='API directory found, discovering actual endpoints with parameters',
                category=CATEGORY_CTF
            )
        )
        
        # Step 1b: gobuster found /rest → ffuf discovers actual endpoints
        self.rules.append(
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='finding:rest',
                target_tool='ffuf',
                priority=8,
                args_template=[
                    '-u', '{target}/FUZZ',
                    '-w', 'data/wordlists/api_endpoints.txt',
                    '-t', '5', '-p', '0.02',
                    '-mc', '200,201,301,302,401,403,405'
                ],
                description='REST directory found, discovering actual endpoints with parameters',
                category=CATEGORY_CTF
            )
        )
        # Step 2: ffuf finds parameters → SQLMap tests (already exists at line ~1140)

        # Gobuster found interesting directories → deep fuzz with ffuf
        # Triggers only for high-value targets to avoid job explosion
        interesting_paths = [
            'admin', 'backup', 'api', 'upload', 'uploads', 'files',
            'config', 'panel', 'dashboard', 'manager', 'console',
            'dev', 'test', 'staging', 'beta', 'private', 'secret',
            'old', 'tmp', 'temp', 'logs', 'data', 'db', 'database',
            'phpmyadmin', 'phpMyAdmin'
        ]
        
        for path_keyword in interesting_paths:
            # Get the project root directory dynamically
            import os
            # __file__ is in souleyez/core/tool_chaining.py
            # We need to go up 3 levels: tool_chaining.py -> core -> souleyez -> project_root
            project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
            wordlist_path = os.path.join(project_root, 'data', 'wordlists', 'web_files_common.txt')
            
            self.rules.append(
                ChainRule(
                    trigger_tool='gobuster',
                    trigger_condition=f'finding:{path_keyword}',
                    target_tool='ffuf',
                    priority=6,
                    enabled=True,
                    args_template=['-u', '{target}/FUZZ',
                                  '-w', wordlist_path,
                                  '-e', '.php,.html,.txt,.zip,.bak',
                                  '-p', '0.1', '-t', '10'],
                    description=f'Interesting path "{path_keyword}" found, deep fuzzing for sensitive files'
                )
            )

        # REMOVED: Gobuster redirect rule - now handled by nmap directly
        # self.rules.append(
        #     ChainRule(
        #         trigger_tool='gobuster',
        #         trigger_condition='has:redirects_with_mysql',
        #         target_tool='sqlmap',
        #         priority=9,
        #         args_template=['-u', '{target}', '--batch', '--risk=1', '--level=1'],
        #         description='Redirects found on host with MySQL, testing for SQL injection'
        #     )
        # )

        # Writable SMB shares found → check for exploitability
        self.rules.append(
            ChainRule(
                trigger_tool='smbmap',
                trigger_condition='has:writable_shares',
                target_tool='msf_auxiliary',
                priority=10,
                args_template=['auxiliary/scanner/smb/smb_version'],
                description='Writable SMB shares found, checking for vulnerabilities'
            )
        )

        # === NEW TOOL CHAINING RULES ===

        # theHarvester completed → run WHOIS on target domain
        self.rules.append(
            ChainRule(
                trigger_tool='theharvester',
                trigger_condition='has:target',
                target_tool='whois',
                priority=6,
                args_template=[],  # Plugin adds target automatically
                description='Domains discovered, gathering registration information'
            )
        )

        # theHarvester discovered URLs with parameters → test for SQL injection
        self.rules.append(
            ChainRule(
                trigger_tool='theharvester',
                trigger_condition='has:urls_with_params',
                target_tool='sqlmap',
                priority=9,
                args_template=['-u', '{target}', '--batch', '--crawl=2', '--risk=2', '--level=3',
                              '--threads=5'],
                description='Parametrized URLs discovered, testing for SQL injection'
            )
        )
        
        # DISABLED: Don't run SQLMap on base URLs without parameters (no injection points to test)
        # SQLMap should only run when we have:
        # 1. URLs with query parameters (has:urls_with_params) - handled above
        # 2. Forms discovered by Gobuster
        # 3. MySQL+HTTP combo detected by nmap
        # 
        # self.rules.append(
        #     ChainRule(
        #         trigger_tool='theharvester',
        #         trigger_condition='has:base_urls',
        #         target_tool='sqlmap',
        #         priority=8,
        #         args_template=['-u', '{target}', '--batch', '--crawl=2', '--risk=2', '--level=2'],
        #         description='Base URLs discovered, crawling and testing for SQL injection'
        #     )
        # )

        # theHarvester discovered base URLs → scan with Nuclei (modern) and Gobuster (directory enum)
        # NOTE: Gobuster added for OSINT-only workflows (external assessments without nmap)
        # If nmap is used, it will also trigger Gobuster (duplicate detection prevents double-scanning)
        self.rules.extend([
            ChainRule(
                trigger_tool='theharvester',
                trigger_condition='has:base_urls',
                target_tool='nuclei',
                priority=9,
                args_template=['-rate-limit', '50', '-c', '10', '-timeout', '10'],
                description='Base URLs discovered, scanning with Nuclei'
            ),
            ChainRule(
                trigger_tool='theharvester',
                trigger_condition='has:base_urls',
                target_tool='gobuster',
                priority=8,
                args_template=['dir', '-u', '{target}', '-w', 'data/wordlists/web_dirs_common.txt', '-x', 'js,json,php,asp,aspx,html,txt,bak,old,zip', '-t', '5', '--delay', '20ms', '--no-error', '-k', '--timeout', '30s', '--exclude-length', '75002'],
                description='Base URLs discovered, discovering directories and files'
            )
        ])

        # === NEW: SQLMap Progressive Exploitation Rules ===

        # SQLMap Rule 1: SQL injection confirmed → enumerate databases
        self.rules.append(
            ChainRule(
                trigger_tool='sqlmap',
                trigger_condition='has:sql_injection_confirmed',
                target_tool='sqlmap',
                priority=10,
                args_template=['-u', '{target}', '--dbs', '--batch'],
                description='SQL injection confirmed, enumerating databases'
            )
        )

        # SQLMap Rule 2: Databases enumerated → enumerate tables
        # NOTE: Special handling in auto_chain() creates one job PER database
        self.rules.append(
            ChainRule(
                trigger_tool='sqlmap',
                trigger_condition='has:databases_enumerated',
                target_tool='sqlmap',
                priority=9,
                args_template=['-u', '{target}', '-D', '{database}', '--tables', '--batch'],
                description='Databases enumerated, enumerating tables'
            )
        )
        
        # SQLMap Rule 2.5: Tables enumerated → enumerate columns
        # NOTE: Special handling in auto_chain() creates one job PER table
        self.rules.append(
            ChainRule(
                trigger_tool='sqlmap',
                trigger_condition='has:tables_enumerated',
                target_tool='sqlmap',
                priority=8,
                args_template=['-u', '{target}', '-D', '{database}', '-T', '{table}', '--columns', '--batch'],
                description='Tables enumerated, enumerating columns'
            )
        )

        # SQLMap Rule 3: Columns enumerated → dump data (DISABLED by default)
        # WARNING: Can create massive data dumps - enable with caution
        self.rules.append(
            ChainRule(
                trigger_tool='sqlmap',
                trigger_condition='has:columns_enumerated',
                target_tool='sqlmap',
                priority=7,
                args_template=['-u', '{target}', '--batch', '-D', '{database}', '-T', '{table}', '--dump', '--threads=5'],
                description='Columns enumerated, dumping data (DISABLED - enable manually)',
                enabled=False  # CRITICAL: Disabled by default
            )
        )

        # === END SQLMap Progressive Exploitation ===

        # === SQLMap Post-Exploitation Rules ===

        # Rule 72: SQL injection confirmed → check privileges
        self.rules.append(
            ChainRule(
                trigger_tool='sqlmap',
                trigger_condition='has:sql_injection_confirmed',
                target_tool='sqlmap',
                priority=9,
                args_template=['-u', '{target}', '--privileges', '--batch'],
                description='SQL injection confirmed, checking database privileges',
                category=CATEGORY_CTF
            )
        )

        # Rule 73: SQL injection confirmed → check if DBA
        self.rules.append(
            ChainRule(
                trigger_tool='sqlmap',
                trigger_condition='has:sql_injection_confirmed',
                target_tool='sqlmap',
                priority=9,
                args_template=['-u', '{target}', '--is-dba', '--batch'],
                description='SQL injection confirmed, checking DBA status',
                category=CATEGORY_CTF
            )
        )

        # Rule 74: DBA confirmed → dump users & password hashes
        # Enabled - valuable for credential harvesting after confirming DBA access
        self.rules.append(
            ChainRule(
                trigger_tool='sqlmap',
                trigger_condition='has:is_dba',
                target_tool='sqlmap',
                priority=8,
                args_template=['-u', '{target}', '--users', '--passwords', '--batch'],
                description='DBA access confirmed, dumping database users and password hashes',
                category=CATEGORY_CTF
            )
        )

        # Rule 75: SQL injection confirmed → read /etc/passwd and /etc/shadow (Linux)
        # DISABLED by default - aggressive file read operations
        self.rules.append(
            ChainRule(
                trigger_tool='sqlmap',
                trigger_condition='has:sql_injection_confirmed',
                target_tool='sqlmap',
                priority=7,
                args_template=['-u', '{target}', '--file-read=/etc/passwd,/etc/shadow', '--batch'],
                description='SQL injection confirmed, attempting to read /etc/passwd and /etc/shadow (DISABLED)',
                category=CATEGORY_CTF,
                enabled=False
            )
        )

        # Rule 76: SQL injection confirmed → read win.ini (Windows)
        # DISABLED by default - aggressive file read operations
        self.rules.append(
            ChainRule(
                trigger_tool='sqlmap',
                trigger_condition='has:sql_injection_confirmed',
                target_tool='sqlmap',
                priority=7,
                args_template=['-u', '{target}', '--file-read=C:/Windows/win.ini', '--batch'],
                description='SQL injection confirmed, attempting to read win.ini (DISABLED)',
                category=CATEGORY_CTF,
                enabled=False
            )
        )

        # Rule 77: DBA access → execute OS command
        # DISABLED by default - requires explicit user enablement
        self.rules.append(
            ChainRule(
                trigger_tool='sqlmap',
                trigger_condition='has:is_dba',
                target_tool='sqlmap',
                priority=6,
                args_template=['-u', '{target}', '--os-cmd=whoami', '--batch'],
                description='DBA access confirmed, executing OS command (DISABLED)',
                category=CATEGORY_CTF,
                enabled=False
            )
        )

        # Rule 78: DBA access → get OS shell (DISABLED by default - dangerous)
        self.rules.append(
            ChainRule(
                trigger_tool='sqlmap',
                trigger_condition='has:is_dba',
                target_tool='sqlmap',
                priority=5,
                args_template=['-u', '{target}', '--os-shell', '--batch'],
                description='DBA access confirmed, spawning OS shell (DANGEROUS)',
                category=CATEGORY_CTF,
                enabled=False  # CRITICAL: Disabled by default
            )
        )

        # === END SQLMap Post-Exploitation Rules ===

        # === SQLMap Cross-Tool Chain Rules ===

        # SQLi confirmed → nuclei webapp vulnerability scan
        self.rules.append(
            ChainRule(
                trigger_tool='sqlmap',
                trigger_condition='has:sql_injection_confirmed',
                target_tool='nuclei',
                priority=7,
                args_template=['-tags', 'sqli,injection,rce,lfi', '-severity', 'critical,high', '-rate-limit', '50', '-c', '10'],
                description='SQL injection confirmed, scanning for related webapp vulnerabilities'
            )
        )

        # Credentials dumped → hydra login testing
        # Uses credentials extracted from database dumps
        self.rules.append(
            ChainRule(
                trigger_tool='sqlmap',
                trigger_condition='has:credentials_dumped',
                target_tool='hydra',
                priority=8,
                args_template=['-C', '{credentials_file}', '-t', '4', 'http-post-form'],
                description='Database credentials dumped, testing on discovered login forms'
            )
        )

        # Credentials dumped → SSH testing with dumped creds
        self.rules.append(
            ChainRule(
                trigger_tool='sqlmap',
                trigger_condition='has:credentials_dumped',
                target_tool='hydra',
                priority=7,
                args_template=['-C', '{credentials_file}', '-t', '4', 'ssh'],
                description='Database credentials dumped, testing SSH access'
            )
        )

        # === END SQLMap Cross-Tool Chain Rules ===

        # === NEW: Windows/AD Attack Chain ===

        # Windows hosts detected → run Responder for passive credential capture
        self.rules.append(
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='service:smb',
                target_tool='responder',
                priority=9,
                args_template=['-v'],
                description='Windows hosts detected, capturing credentials via LLMNR/NBT-NS poisoning',
                enabled=False  # Requires manual interface selection, disabled by default
            )
        )

        # Responder captured hashes → crack with hashcat
        self.rules.append(
            ChainRule(
                trigger_tool='responder',
                trigger_condition='has:credentials_captured',
                target_tool='hashcat',
                priority=10,
                args_template=['-m', '5600', '-a', '0', 'data/wordlists/top100.txt'],
                description='NTLMv2 hashes captured, cracking with hashcat'
            )
        )

        # Domain credentials discovered → run Bloodhound
        # NOTE: Special handling needed to detect domain credentials
        self.rules.append(
            ChainRule(
                trigger_tool='crackmapexec',
                trigger_condition='has:domain_credentials',
                target_tool='bloodhound',
                priority=10,
                args_template=['-c', 'All'],
                description='Domain credentials obtained, mapping Active Directory',
                enabled=False  # Requires valid domain creds and DC IP
            )
        )

        # === END Windows/AD Attack Chain ===

        # === NEW: OWASP Injection Chain Rules ===

        # Gobuster found forms/endpoints → test with Nuclei XSS templates
        self.rules.append(
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='has:html_forms',
                target_tool='nuclei',
                priority=8,
                args_template=['-tags', 'xss,rxss', '-severity', 'critical,high,medium', '-rate-limit', '50', '-c', '10'],
                description='HTML forms found, testing for XSS vulnerabilities'
            )
        )

        # Gobuster found template endpoints → test with Nuclei SSTI
        self.rules.extend([
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='finding:template',
                target_tool='nuclei',
                priority=8,
                args_template=['-tags', 'ssti', '-severity', 'critical,high', '-rate-limit', '50', '-c', '10'],
                description='Template engine detected, testing for SSTI'
            ),
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='finding:render',
                target_tool='nuclei',
                priority=8,
                args_template=['-tags', 'ssti', '-severity', 'critical,high', '-rate-limit', '50', '-c', '10'],
                description='Render endpoint detected, testing for SSTI'
            )
        ])

        # Gobuster found file parameters → test with Nuclei LFI
        self.rules.append(
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='has:file_params',
                target_tool='nuclei',
                priority=8,
                args_template=['-tags', 'lfi,rfi', '-severity', 'critical,high', '-rate-limit', '50', '-c', '10'],
                description='File parameters found, testing for LFI/RFI'
            )
        )

        # SQLMap failed (no SQL injection) → try command injection with Nuclei
        self.rules.append(
            ChainRule(
                trigger_tool='sqlmap',
                trigger_condition='no:sql_injection',
                target_tool='nuclei',
                priority=7,
                args_template=['-tags', 'rce,cmdi', '-severity', 'critical,high', '-rate-limit', '50', '-c', '10'],
                description='No SQL injection found, testing for command injection'
            )
        )

        # Gobuster found endpoints → discover parameters with ffuf, then test
        self.rules.append(
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='has:endpoints',
                target_tool='ffuf',
                priority=6,
                args_template=['-u', '{target}?FUZZ=test', '-w', 'data/wordlists/web_files_common.txt', '-fc', '404', '-p', '0.1', '-t', '10'],
                description='Endpoints found, fuzzing for hidden parameters'
            )
        )

        # ffuf found parameters → test with SQLMap
        self.rules.append(
            ChainRule(
                trigger_tool='ffuf',
                trigger_condition='has:parameters_found',
                target_tool='sqlmap',
                priority=9,
                args_template=['-u', '{target}', '--batch', '--level=2', '--risk=2'],
                description='Parameters discovered, testing for SQL injection'
            )
        )

        # ffuf found parameters → test with Nuclei XSS
        self.rules.append(
            ChainRule(
                trigger_tool='ffuf',
                trigger_condition='has:parameters_found',
                target_tool='nuclei',
                priority=8,
                args_template=['-tags', 'xss,rxss', '-severity', 'critical,high,medium', '-rate-limit', '50', '-c', '10'],
                description='Parameters discovered, testing for XSS'
            )
        )

        # Gobuster found API endpoints → parameter fuzzing with ffuf
        self.rules.extend([
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='finding:api',
                target_tool='ffuf',
                priority=7,
                args_template=['-u', '{target}?FUZZ=test', '-w', 'data/wordlists/web_files_common.txt', '-fc', '404', '-p', '0.1', '-t', '10'],
                description='API endpoint found, fuzzing for parameters'
            ),
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='finding:api',
                target_tool='nuclei',
                priority=8,
                args_template=['-tags', 'api', '-severity', 'critical,high', '-rate-limit', '50', '-c', '10'],
                description='API endpoint found, testing API security'
            )
        ])

        # === END OWASP Injection Chain Rules ===

        # === Directory Category Chain Rules ===
        # These rules trigger based on the category of discovered directories
        # Categories: database_admin, wordpress, drupal, vulnerable_app, custom_php

        # Database Admin (phpMyAdmin, Adminer) → Nuclei CVE scan
        self.rules.append(
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='category:database_admin',
                target_tool='nuclei',
                priority=9,
                args_template=['-tags', 'phpmyadmin,exposure,cve', '-severity', 'critical,high,medium', '-rate-limit', '50', '-c', '10'],
                description='Database admin panel detected, scanning for CVEs'
            )
        )

        # Database Admin → SearchSploit for known exploits
        self.rules.append(
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='category:database_admin',
                target_tool='searchsploit',
                priority=8,
                args_template=['--json', 'phpMyAdmin'],
                description='Database admin panel detected, searching for exploits'
            )
        )

        # Database Admin → Hydra default credentials
        self.rules.append(
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='category:database_admin',
                target_tool='hydra',
                priority=8,
                args_template=['http-post-form', '{path}:pma_username=^USER^&pma_password=^PASS^:F=denied',
                              '-C', 'data/wordlists/default_credentials.txt', '-t', '1'],
                description='Database admin panel detected, testing default credentials'
            )
        )

        # WordPress → WPScan enumeration
        self.rules.append(
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='category:wordpress',
                target_tool='wpscan',
                priority=9,
                args_template=['--enumerate', 'vp,vt,u', '--plugins-detection', 'aggressive', '--random-user-agent'],
                description='WordPress detected, enumerating plugins and themes'
            )
        )

        # WordPress → Nuclei CVE scan
        self.rules.append(
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='category:wordpress',
                target_tool='nuclei',
                priority=8,
                args_template=['-tags', 'wordpress,cve', '-severity', 'critical,high,medium', '-rate-limit', '50', '-c', '10'],
                description='WordPress detected, scanning for CVEs'
            )
        )

        # Drupal → Nuclei CVE scan (Drupalgeddon, etc.)
        self.rules.append(
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='category:drupal',
                target_tool='nuclei',
                priority=9,
                args_template=['-tags', 'drupal,drupalgeddon,cve', '-severity', 'critical,high,medium', '-rate-limit', '50', '-c', '10'],
                description='Drupal detected, scanning for CVEs'
            )
        )

        # Drupal → SearchSploit for known exploits
        self.rules.append(
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='category:drupal',
                target_tool='searchsploit',
                priority=8,
                args_template=['--json', 'Drupal'],
                description='Drupal detected, searching for exploits'
            )
        )

        # Vulnerable App (DVWA, Mutillidae, etc.) → SQLMap crawl
        self.rules.append(
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='category:vulnerable_app',
                target_tool='sqlmap',
                priority=9,
                args_template=['-u', '{target}', '--batch', '--crawl=2', '--forms', '--level=2', '--risk=2', '--smart',
                              '--threads=5'],
                description='Vulnerable app detected, scan forms for SQL injection'
            )
        )

        # Custom PHP → SQLMap standard crawl
        self.rules.append(
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='category:custom_php',
                target_tool='sqlmap',
                priority=7,
                args_template=['-u', '{target}', '--batch', '--crawl=2', '--forms', '--level=2', '--risk=2', '--smart',
                              '--threads=5'],
                description='Custom PHP app detected, scan forms for SQL injection'
            )
        )

        # === END Directory Category Chain Rules ===

        # theHarvester completed → discover subdomains with DNSRecon
        # NOTE: whois → dnsrecon is disabled to avoid duplicates (theHarvester already chains to dnsrecon)
        self.rules.extend([
            ChainRule(
                trigger_tool='theharvester',
                trigger_condition='has:target',
                target_tool='dnsrecon',
                priority=8,
                args_template=['-d', '{target}', '-t', 'std'],
                description='Domain identified, enumerating DNS records'
            ),
            # DISABLED: whois → dnsrecon (redundant, theHarvester already chains to dnsrecon)
            # ChainRule(
            #     trigger_tool='whois',
            #     trigger_condition='has:target',
            #     target_tool='dnsrecon',
            #     priority=7,
            #     args_template=['-d', '{target}', '-t', 'std'],
            #     description='Domain registered, enumerating DNS records'
            # ),
        ])

        # DNSRecon found subdomains/hosts → scan them with nmap
        self.rules.append(
            ChainRule(
                trigger_tool='dnsrecon',
                trigger_condition='has:subdomains',
                target_tool='nmap',
                priority=9,
                args_template=['-sV', '-sC', '-T4', '{target}'],
                description='Subdomains discovered, scanning for services'
            )
        )

        # SearchSploit: Trigger on nmap service detection with product/version
        # This will be handled in special auto_chain logic below
        # (nmap → searchsploit is too complex for simple rules)

        # WordPress detected → run WPScan
        # Note: {target} is passed separately to the plugin (plugin adds --url)
        # Plugin will strip path to get base URL (e.g., http://host/wp-content → http://host/)
        self.rules.extend([
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='finding:wp-admin',
                target_tool='wpscan',
                priority=10,
                args_template=['--enumerate', 'vp,vt,u', '--random-user-agent'],
                description='WordPress admin panel found, scanning for vulnerabilities'
            ),
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='finding:wp-content',
                target_tool='wpscan',
                priority=9,
                args_template=['--enumerate', 'vp,vt,u', '--random-user-agent'],
                description='WordPress installation found, scanning for vulnerabilities'
            ),
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='finding:xmlrpc',
                target_tool='wpscan',
                priority=9,
                args_template=['--enumerate', 'u', '--random-user-agent'],
                description='WordPress XML-RPC found, enumerating users'
            ),
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='finding:wp-json',
                target_tool='wpscan',
                priority=9,
                args_template=['--enumerate', 'u', '--random-user-agent'],
                description='WordPress REST API found, enumerating users'
            ),
        ])

        # SSH service discovered → optional brute-force (disabled by default for safety)
        # Note: {target} is passed separately to the plugin, not in args_template
        # WARNING: Hydra SSH may fail on legacy servers (OpenSSH < 6.0) with key exchange errors
        #          Use MSF ssh_login instead (has SSH_CLIENT_KEX for legacy algorithm support)
        self.rules.append(
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='service:ssh',
                target_tool='hydra',
                priority=4,
                args_template=['-L', 'data/wordlists/all_users.txt', '-P', 'data/wordlists/top20_quick.txt', '-t', '1', '-w', '3', '-f', 'ssh'],
                description='SSH detected, testing default credentials',
                enabled=False  # Disabled by default - MSF ssh_login preferred for compatibility
            )
        )

        # FTP service discovered → test anonymous and brute-force
        # Note: {target} is passed separately to the plugin, not in args_template
        self.rules.extend([
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='service:ftp',
                target_tool='hydra',
                priority=5,
                args_template=['-l', 'anonymous', '-p', 'anonymous@', '-t', '1', 'ftp'],
                description='FTP detected, testing anonymous login',
                enabled=False  # Disabled by default
            ),
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='service:ftp',
                target_tool='hydra',
                priority=4,
                args_template=['-L', 'data/wordlists/all_users.txt', '-P', 'data/wordlists/top20_quick.txt', '-t', '1', '-w', '3', '-f', 'ftp'],
                description='FTP detected, testing default credentials',
                enabled=False  # Disabled by default
            ),
        ])

        # WPScan found users → optional brute-force WordPress login (disabled by default)
        # Note: {target} is passed separately to the plugin, not in args_template
        self.rules.append(
            ChainRule(
                trigger_tool='wpscan',
                trigger_condition='has:users',
                target_tool='hydra',
                priority=6,
                args_template=['-L', 'data/wordlists/soul_users.txt', '-P', 'data/wordlists/soul_pass.txt', '-t', '2', '-w', '2', '-vV', 'http-post-form', '/wp-login.php:log=^USER^&pwd=^PASS^&wp-submit=Log+In:F=is incorrect'],
                description='WordPress users enumerated, testing credentials',
                enabled=False  # Disabled by default - can cause lockouts
            )
        )

        # WordPress login found → enumerate valid usernames with Hydra
        # Triggers on wp-login, wp-admin, xmlrpc, and wp-json (all indicate WordPress)
        # Note: {target} is passed separately to the plugin, not in args_template
        self.rules.extend([
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='finding:wp-login',
                target_tool='hydra',
                priority=7,
                args_template=['-L', 'data/wordlists/soul_users.txt', '-p', 'test', '-t', '2', '-w', '2', '-vV', 'http-post-form', '/wp-login.php:log=^USER^&pwd=^PASS^&wp-submit=Log+In:F=Invalid username'],
                description='WordPress login found, enumerating valid usernames',
                enabled=False  # Disabled by default - brute-force rule
            ),
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='finding:wp-admin',
                target_tool='hydra',
                priority=7,
                args_template=['-L', 'data/wordlists/soul_users.txt', '-p', 'test', '-t', '2', '-w', '2', '-vV', 'http-post-form', '/wp-login.php:log=^USER^&pwd=^PASS^&wp-submit=Log+In:F=Invalid username'],
                description='WordPress admin found, enumerating valid usernames',
                enabled=False  # Disabled by default - brute-force rule
            ),
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='finding:xmlrpc',
                target_tool='hydra',
                priority=7,
                args_template=['-L', 'data/wordlists/soul_users.txt', '-p', 'test', '-t', '2', '-w', '2', '-vV', 'http-post-form', '/wp-login.php:log=^USER^&pwd=^PASS^&wp-submit=Log+In:F=Invalid username'],
                description='WordPress XML-RPC found, enumerating valid usernames via login',
                enabled=False  # Disabled by default - brute-force rule
            ),
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='finding:wp-json',
                target_tool='hydra',
                priority=7,
                args_template=['-L', 'data/wordlists/soul_users.txt', '-p', 'test', '-t', '2', '-w', '2', '-vV', 'http-post-form', '/wp-login.php:log=^USER^&pwd=^PASS^&wp-submit=Log+In:F=Invalid username'],
                description='WordPress REST API found, enumerating valid usernames via login',
                enabled=False  # Disabled by default - brute-force rule
            ),
        ])

        # Hydra found valid usernames → crack passwords for those users
        # Special handling in auto_chain creates temp file from validated usernames
        # Note: {target} is passed separately, {usernames_file} is created by auto_chain
        self.rules.append(
            ChainRule(
                trigger_tool='hydra',
                trigger_condition='has:usernames',
                target_tool='hydra',
                priority=8,
                args_template=['-L', '{usernames_file}', '-P', 'data/wordlists/soul_pass.txt', '-t', '2', '-w', '2', '-vV', 'http-post-form', '/wp-login.php:log=^USER^&pwd=^PASS^&wp-submit=Log+In:F=is incorrect'],
                description='Valid usernames found, cracking passwords',
                enabled=False  # Disabled by default - brute-force rule
            )
        )

        # =====================================================================
        # CREDENTIAL ACCESS & LATERAL MOVEMENT AUTO-CHAINING
        # =====================================================================
        
        # Kerberos (port 88) detected → GetNPUsers (AS-REP Roasting)
        self.rules.append(
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='port:88',
                target_tool='impacket-getnpusers',
                priority=8,
                args_template=[],
                description='Kerberos detected, attempting AS-REP Roasting',
                enabled=True
            )
        )

        # =====================================================================
        # LEGACY/CTF VULNERABLE SERVICES - HIGH-VALUE EXPLOIT TARGETS
        # =====================================================================
        # These rules target known vulnerable service versions commonly found
        # in CTF environments, labs, and legacy systems.

        # vsftpd 2.3.4 backdoor (CVE-2011-2523)
        # Triggers backdoor shell on port 6200 when username contains :)
        self.rules.append(
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='version:vsftpd 2.3.4',
                target_tool='msf_exploit',
                priority=10,
                args_template=['exploit/unix/ftp/vsftpd_234_backdoor'],
                description='vsftpd 2.3.4 detected - BACKDOOR AVAILABLE (CVE-2011-2523)',
                category=CATEGORY_CTF
            )
        )

        # Samba 3.0.x usermap_script RCE (CVE-2007-2447)
        # Command injection in username field
        self.rules.append(
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='version:Samba 3.0',
                target_tool='msf_exploit',
                priority=10,
                args_template=['exploit/multi/samba/usermap_script'],
                description='Samba 3.0.x detected - usermap_script RCE available (CVE-2007-2447)',
                category=CATEGORY_CTF
            )
        )

        # UnrealIRCd 3.2.8.1 backdoor (CVE-2010-2075)
        # DEBUG command triggers backdoor
        self.rules.append(
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='service:irc',
                target_tool='msf_exploit',
                priority=9,
                args_template=['exploit/unix/irc/unreal_ircd_3281_backdoor'],
                description='IRC service detected - checking for UnrealIRCd backdoor (CVE-2010-2075)',
                category=CATEGORY_CTF
            )
        )

        # distccd RCE (CVE-2004-2687)
        # Distributed compiler daemon command execution
        self.rules.append(
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='service:distccd',
                target_tool='msf_exploit',
                priority=9,
                args_template=['exploit/unix/misc/distcc_exec'],
                description='distccd detected - command execution available (CVE-2004-2687)',
                category=CATEGORY_CTF
            )
        )
        self.rules.append(
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='port:3632',
                target_tool='msf_exploit',
                priority=8,
                args_template=['exploit/unix/misc/distcc_exec'],
                description='Port 3632 detected - checking for distccd RCE',
                category=CATEGORY_CTF
            )
        )

        # Java RMI exploitation
        self.rules.extend([
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='service:java-rmi',
                target_tool='msf_auxiliary',
                priority=8,
                args_template=['auxiliary/scanner/misc/java_rmi_server'],
                description='Java RMI detected - enumerating registry',
                category=CATEGORY_CTF
            ),
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='port:1099',
                target_tool='msf_auxiliary',
                priority=7,
                args_template=['auxiliary/scanner/misc/java_rmi_server'],
                description='Port 1099 detected - checking for Java RMI',
                category=CATEGORY_CTF
            ),
        ])

        # NFS enumeration
        self.rules.extend([
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='service:nfs',
                target_tool='msf_auxiliary',
                priority=7,
                args_template=['auxiliary/scanner/nfs/nfsmount'],
                description='NFS detected - enumerating exports',
                category=CATEGORY_CTF
            ),
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='port:2049',
                target_tool='msf_auxiliary',
                priority=6,
                args_template=['auxiliary/scanner/nfs/nfsmount'],
                description='Port 2049 detected - checking for NFS exports',
                category=CATEGORY_CTF
            ),
        ])

        # R-services (rsh, rlogin, rexec) - legacy trust-based services
        self.rules.extend([
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='port:512',
                target_tool='msf_auxiliary',
                priority=7,
                args_template=['auxiliary/scanner/rservices/rexec_login'],
                description='rexec (512) detected - checking for trust exploitation',
                category=CATEGORY_CTF
            ),
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='port:513',
                target_tool='msf_auxiliary',
                priority=7,
                args_template=['auxiliary/scanner/rservices/rlogin_login'],
                description='rlogin (513) detected - checking for trust exploitation',
                category=CATEGORY_CTF
            ),
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='port:514',
                target_tool='msf_auxiliary',
                priority=7,
                args_template=['auxiliary/scanner/rservices/rsh_login'],
                description='rsh (514) detected - checking for trust exploitation',
                category=CATEGORY_CTF
            ),
        ])

        # Telnet service - check for default/weak credentials
        # NOTE: Disabled - duplicates GENERAL rule at line ~585
        # self.rules.append(
        #     ChainRule(
        #         trigger_tool='nmap',
        #         trigger_condition='service:telnet',
        #         target_tool='msf_auxiliary',
        #         priority=6,
        #         args_template=['auxiliary/scanner/telnet/telnet_login', ...],
        #         description='Telnet detected - checking for default credentials',
        #         category=CATEGORY_CTF
        #     )
        # )

        # UPnP enumeration - IoT/router discovery
        self.rules.append(
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='service:upnp',
                target_tool='msf_auxiliary',
                priority=6,
                args_template=['auxiliary/scanner/upnp/ssdp_msearch'],
                description='UPnP detected - enumerating devices',
                category=CATEGORY_CTF
            )
        )

        # X11 open access check
        self.rules.append(
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='port:6000',
                target_tool='msf_auxiliary',
                priority=6,
                args_template=['auxiliary/scanner/x11/open_x11'],
                description='X11 (6000) detected - checking for open access',
                category=CATEGORY_CTF
            )
        )

        # Cockpit / Port 9090 - Linux admin panel
        self.rules.append(
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='port:9090',
                target_tool='nuclei',
                priority=8,
                args_template=['-u', 'https://{target}:9090', '-rate-limit', '50', '-c', '10'],
                description='Port 9090 detected (Cockpit/admin panel) - scanning with Nuclei',
                category=CATEGORY_CTF
            )
        )

        # Bindshell detection - common backdoor port
        self.rules.append(
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='port:1524',
                target_tool='msf_auxiliary',
                priority=10,
                args_template=['auxiliary/scanner/misc/bmc_click_here_to_continue'],
                description='Port 1524 detected - POSSIBLE BINDSHELL (Metasploitable)',
                category=CATEGORY_CTF
            )
        )

        # AJP/Tomcat Ghostcat (CVE-2020-1938)
        self.rules.append(
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='port:8009',
                target_tool='msf_auxiliary',
                priority=8,
                args_template=['auxiliary/admin/http/tomcat_ghostcat'],
                description='AJP (8009) detected - checking for Ghostcat (CVE-2020-1938)',
                category=CATEGORY_CTF
            )
        )

        # ProFTPD mod_copy (CVE-2015-3306) - file copy without auth
        self.rules.append(
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='version:ProFTPD 1.3',
                target_tool='msf_exploit',
                priority=8,
                args_template=['exploit/unix/ftp/proftpd_modcopy_exec'],
                description='ProFTPD 1.3.x detected - checking for mod_copy RCE (CVE-2015-3306)',
                category=CATEGORY_CTF
            )
        )

        # =====================================================================
        # END LEGACY/CTF VULNERABLE SERVICES
        # =====================================================================

        # =====================================================================
        # JUICE SHOP / MODERN WEB APP DETECTION
        # =====================================================================
        # Rules for detecting and attacking OWASP Juice Shop and similar
        # modern JavaScript web applications (Node.js, Angular, React, Vue)

        # Juice Shop API endpoints indicate vulnerable app
        self.rules.extend([
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='finding:api',
                target_tool='nuclei',
                priority=9,
                args_template=['-tags', 'exposure,misconfiguration,cve', '-rate-limit', '50', '-c', '10'],
                description='API endpoint found - scanning for Juice Shop/modern app vulnerabilities',
                category=CATEGORY_CTF
            ),
            # REST endpoints suggest modern JS app
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='finding:rest',
                target_tool='nuclei',
                priority=9,
                args_template=['-tags', 'exposure,token,jwt', '-rate-limit', '50', '-c', '10'],
                description='REST API found - checking for JWT/token vulnerabilities',
                category=CATEGORY_CTF
            ),
            # FTP directory exposure (common in Juice Shop)
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='finding:ftp',
                target_tool='nuclei',
                priority=8,
                args_template=['-tags', 'exposure,lfi', '-rate-limit', '50', '-c', '10'],
                description='FTP directory found - checking for file disclosure',
                category=CATEGORY_CTF
            ),
            # Metrics endpoint (Prometheus/monitoring exposure)
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='finding:metrics',
                target_tool='nuclei',
                priority=8,
                args_template=['-tags', 'exposure,misconfiguration', '-rate-limit', '50', '-c', '10'],
                description='Metrics endpoint found - checking for information disclosure',
                category=CATEGORY_CTF
            ),
            # Angular/React source maps
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='finding:main.js',
                target_tool='nuclei',
                priority=7,
                args_template=['-tags', 'exposure,js', '-rate-limit', '50', '-c', '10'],
                description='JavaScript bundle found - checking for source map exposure',
                category=CATEGORY_CTF
            ),
        ])

        # =====================================================================
        # END JUICE SHOP / MODERN WEB APP DETECTION
        # =====================================================================

        # =====================================================================
        # ACTIVE DIRECTORY ATTACK CHAINS
        # Real-world enterprise penetration testing patterns
        # =====================================================================
        self.rules.extend([
            # Kerberos → Kerberoasting (AS-REP roasting)
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='port:88 & has:domains',
                target_tool='impacket-getnpusers',
                priority=9,
                args_template=['{domain}/', '-dc-ip', '{dc_ip}', '-no-pass', '-usersfile', 'data/wordlists/usernames.txt'],
                description='Kerberos detected with domain - checking for AS-REP roastable users',
                category=CATEGORY_ENTERPRISE
            ),
            # LDAP → BloodHound collection
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='service:ldap & has:domains',
                target_tool='bloodhound-python',
                priority=8,
                args_template=['-c', 'All', '-d', '{domain}', '-dc', '{dc_ip}', '-ns', '{dc_ip}'],
                enabled=False,  # Disabled by default - needs creds
                description='LDAP detected - BloodHound collection (requires valid creds)',
                category=CATEGORY_ENTERPRISE
            ),
            # SMB signing disabled → Relay attack potential
            ChainRule(
                trigger_tool='crackmapexec',
                trigger_condition='finding:signing:False',
                target_tool='responder',
                priority=8,
                args_template=['-I', '{target}', '-rdw'],
                enabled=False,  # Manual - requires network positioning
                description='SMB signing disabled - relay attacks possible (run Responder manually)',
                category=CATEGORY_ENTERPRISE
            ),
            # LDAP → Domain enumeration
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='service:ldap',
                target_tool='ldapsearch',
                priority=7,
                args_template=['-x', '-H', 'ldap://{target}', '-b', '', '-s', 'base', 'namingContexts'],
                description='LDAP detected - enumerating naming contexts',
                category=CATEGORY_ENTERPRISE
            ),
            # WinRM → Password spray candidate
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='port:5985',
                target_tool='crackmapexec',
                priority=6,
                args_template=['winrm', '{target}', '-u', 'administrator', '-p', 'password'],
                enabled=False,  # Disabled - needs valid creds
                description='WinRM detected - potential for password spray (disabled by default)',
                category=CATEGORY_ENTERPRISE
            ),
            # RDP → Check for NLA
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='service:rdp',
                target_tool='rdp-sec-check',
                priority=6,
                args_template=['{target}'],
                description='RDP detected - checking security configuration',
                category=CATEGORY_ENTERPRISE
            ),
        ])

        # =====================================================================
        # CLOUD MISCONFIGURATION DETECTION
        # AWS, Azure, GCP reconnaissance patterns
        # =====================================================================
        self.rules.extend([
            # HTTP with cloud metadata hints → SSRF checks
            ChainRule(
                trigger_tool='nuclei',
                trigger_condition='finding:aws',
                target_tool='nuclei',
                priority=9,
                args_template=['-tags', 'ssrf,aws,cloud', '-rate-limit', '50', '-c', '10'],
                description='AWS indicators found - checking for SSRF/metadata access',
                category=CATEGORY_ENTERPRISE
            ),
            ChainRule(
                trigger_tool='nuclei',
                trigger_condition='finding:azure',
                target_tool='nuclei',
                priority=9,
                args_template=['-tags', 'ssrf,azure,cloud', '-rate-limit', '50', '-c', '10'],
                description='Azure indicators found - checking for SSRF/metadata access',
                category=CATEGORY_ENTERPRISE
            ),
            # S3 bucket enumeration
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='finding:s3',
                target_tool='nuclei',
                priority=8,
                args_template=['-tags', 's3,bucket,exposure', '-rate-limit', '50', '-c', '10'],
                description='S3 reference found - checking for bucket misconfigurations',
                category=CATEGORY_ENTERPRISE
            ),
            # Cloud storage URLs
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='finding:blob.core.windows.net',
                target_tool='nuclei',
                priority=8,
                args_template=['-tags', 'azure,storage,exposure', '-rate-limit', '50', '-c', '10'],
                description='Azure Blob reference found - checking for storage misconfigurations',
                category=CATEGORY_ENTERPRISE
            ),
            # API Gateway detection
            ChainRule(
                trigger_tool='nuclei',
                trigger_condition='finding:api-gateway',
                target_tool='nuclei',
                priority=7,
                args_template=['-tags', 'api,exposure,aws', '-rate-limit', '50', '-c', '10'],
                description='API Gateway detected - checking for misconfigurations',
                category=CATEGORY_ENTERPRISE
            ),
        ])

        # =====================================================================
        # MODERN WEB APPLICATION PATTERNS
        # GraphQL, OAuth, JWT, API security
        # =====================================================================
        self.rules.extend([
            # GraphQL endpoint discovery → Introspection check
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='finding:graphql',
                target_tool='nuclei',
                priority=9,
                args_template=['-tags', 'graphql,introspection,exposure', '-rate-limit', '50', '-c', '10'],
                description='GraphQL endpoint found - checking for introspection enabled',
                category=CATEGORY_ENTERPRISE
            ),
            # OAuth endpoints → Authorization bypass checks
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='finding:oauth',
                target_tool='nuclei',
                priority=8,
                args_template=['-tags', 'oauth,token,misconfiguration', '-rate-limit', '50', '-c', '10'],
                description='OAuth endpoint found - checking for authorization issues',
                category=CATEGORY_ENTERPRISE
            ),
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='finding:authorize',
                target_tool='nuclei',
                priority=8,
                args_template=['-tags', 'oauth,oidc,redirect', '-rate-limit', '50', '-c', '10'],
                description='Authorization endpoint found - checking for redirect issues',
                category=CATEGORY_ENTERPRISE
            ),
            # JWT in responses → Token analysis
            ChainRule(
                trigger_tool='nuclei',
                trigger_condition='finding:jwt',
                target_tool='nuclei',
                priority=8,
                args_template=['-tags', 'jwt,token,exposure', '-rate-limit', '50', '-c', '10'],
                description='JWT token found - checking for weak algorithms/secrets',
                category=CATEGORY_ENTERPRISE
            ),
            # Swagger/OpenAPI exposure
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='finding:swagger',
                target_tool='nuclei',
                priority=8,
                args_template=['-tags', 'swagger,openapi,exposure', '-rate-limit', '50', '-c', '10'],
                description='Swagger/OpenAPI found - checking for sensitive endpoint exposure',
                category=CATEGORY_ENTERPRISE
            ),
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='finding:openapi',
                target_tool='nuclei',
                priority=8,
                args_template=['-tags', 'swagger,openapi,api', '-rate-limit', '50', '-c', '10'],
                description='OpenAPI spec found - analyzing for security issues',
                category=CATEGORY_ENTERPRISE
            ),
            # WebSocket endpoints
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='finding:websocket',
                target_tool='nuclei',
                priority=7,
                args_template=['-tags', 'websocket,exposure', '-rate-limit', '50', '-c', '10'],
                description='WebSocket endpoint found - checking for security issues',
                category=CATEGORY_ENTERPRISE
            ),
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='finding:socket.io',
                target_tool='nuclei',
                priority=7,
                args_template=['-tags', 'websocket,socketio', '-rate-limit', '50', '-c', '10'],
                description='Socket.io found - checking for real-time communication issues',
                category=CATEGORY_ENTERPRISE
            ),
            # API versioning patterns
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='finding:v1',
                target_tool='gobuster',
                priority=6,
                args_template=['dir', '-u', 'http://{target}:{port}/v2/', '-w', 'data/wordlists/web_dirs_common.txt', '--no-error', '-t', '5', '--delay', '20ms', '--exclude-length', '75002'],
                description='API v1 found - checking for deprecated API versions',
                category=CATEGORY_ENTERPRISE
            ),
            # Debug/development endpoints
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='finding:debug',
                target_tool='nuclei',
                priority=9,
                args_template=['-tags', 'debug,exposure,misconfiguration', '-rate-limit', '50', '-c', '10'],
                description='Debug endpoint found - high priority security check',
                category=CATEGORY_ENTERPRISE
            ),
            ChainRule(
                trigger_tool='gobuster',
                trigger_condition='finding:console',
                target_tool='nuclei',
                priority=8,
                args_template=['-tags', 'console,admin,exposure', '-rate-limit', '50', '-c', '10'],
                description='Console endpoint found - checking for exposed admin interfaces',
                category=CATEGORY_ENTERPRISE
            ),
        ])

        # =====================================================================
        # ROUTER TESTING CHAIN RULES
        # Home router detection → enumeration → exploitation
        # =====================================================================
        self.rules.extend([
            # Router detection triggers RouterSploit scan
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='device:router',
                target_tool='routersploit',
                priority=9,
                description='Router detected - scanning for vulnerabilities with RouterSploit',
                category=CATEGORY_GENERAL
            ),
            # UPnP service → UPnP enumeration
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='service:upnp',
                target_tool='upnp',
                priority=8,
                description='UPnP service detected - enumerating device info',
                category=CATEGORY_GENERAL
            ),
            # UPnP found → UPnP abuse (port mapping)
            ChainRule(
                trigger_tool='upnp',
                trigger_condition='has:upnp_services',
                target_tool='upnp_abuse',
                priority=7,
                args_template=['list'],
                description='UPnP enumerated - checking port mappings',
                category=CATEGORY_GENERAL
            ),
            # TR-069 port → TR-069 detection
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='port:7547',
                target_tool='tr069',
                priority=8,
                description='TR-069/CWMP port detected - ISP management check',
                category=CATEGORY_GENERAL
            ),
            # Router with HTTP → HTTP brute force
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='device:router & service:http',
                target_tool='router_http_brute',
                priority=7,
                description='Router web admin detected - testing default credentials',
                category=CATEGORY_GENERAL
            ),
            # Router with SSH → SSH brute force
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='device:router & port:22',
                target_tool='router_ssh_brute',
                priority=6,
                description='Router SSH detected - testing default credentials',
                category=CATEGORY_GENERAL
            ),
            # Router with Telnet → Telnet brute force
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='device:router & port:23',
                target_tool='router_telnet_brute',
                priority=7,
                description='Router Telnet detected - testing default credentials',
                category=CATEGORY_GENERAL
            ),
            # RouterSploit found vulns → exploit
            ChainRule(
                trigger_tool='routersploit',
                trigger_condition='has:vulnerabilities',
                target_tool='routersploit_exploit',
                priority=10,
                description='RouterSploit found vulnerabilities - attempting exploitation',
                category=CATEGORY_GENERAL
            ),
            # Router with DNS → DNS hijack check
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='device:router & port:53',
                target_tool='dns_hijack',
                priority=5,
                description='Router DNS detected - checking for DNS hijacking',
                category=CATEGORY_GENERAL
            ),
        ])

        # =====================================================================
        # macOS TESTING CHAIN RULES
        # Apple device detection → enumeration → exploitation
        # =====================================================================
        self.rules.extend([
            # macOS detection → AFP enumeration
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='service:afp',
                target_tool='afp',
                priority=8,
                description='AFP (Apple Filing) detected - enumerating shares',
                category=CATEGORY_GENERAL
            ),
            # macOS detection → VNC/ARD enumeration
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='os:macos & port:5900',
                target_tool='ard',
                priority=8,
                description='macOS Screen Sharing detected - enumerating VNC/ARD',
                category=CATEGORY_GENERAL
            ),
            # VNC port without OS detection
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='service:ard',
                target_tool='ard',
                priority=7,
                description='VNC/ARD service detected - enumerating',
                category=CATEGORY_GENERAL
            ),
            # mDNS discovery
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='port:5353',
                target_tool='mdns',
                priority=6,
                description='mDNS/Bonjour detected - discovering Apple services',
                category=CATEGORY_GENERAL
            ),
            # AFP shares found → AFP brute force
            ChainRule(
                trigger_tool='afp',
                trigger_condition='has:shares',
                target_tool='afp_brute',
                priority=7,
                description='AFP shares discovered - testing credentials',
                category=CATEGORY_GENERAL
            ),
            # VNC auth required → VNC brute force
            ChainRule(
                trigger_tool='ard',
                trigger_condition='has:vnc_auth_required',
                target_tool='vnc_brute',
                priority=7,
                description='VNC auth required - testing passwords',
                category=CATEGORY_GENERAL
            ),
            # VNC brute success → VNC access
            ChainRule(
                trigger_tool='vnc_brute',
                trigger_condition='has:credentials',
                target_tool='vnc_access',
                priority=9,
                args_template=['--screenshot'],
                description='VNC credentials found - capturing screenshot',
                category=CATEGORY_GENERAL
            ),
            # macOS SSH → SSH brute force
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='os:macos & port:22',
                target_tool='macos_ssh',
                priority=7,
                description='macOS SSH detected - testing credentials',
                category=CATEGORY_GENERAL
            ),
        ])

        # =====================================================================
        # END REAL-WORLD CHAIN RULES
        # =====================================================================

    def add_rule(self, rule: ChainRule):
        """Add a custom chaining rule."""
        self.rules.append(rule)

    def remove_rule(self, trigger_tool: str, target_tool: str):
        """Remove a chaining rule."""
        self.rules = [r for r in self.rules if not (r.trigger_tool == trigger_tool and r.target_tool == target_tool)]

    def toggle_chaining(self) -> bool:
        """
        Toggle auto-chaining on/off.

        Returns:
            New state (True = enabled, False = disabled)
        """
        self.enabled = not self.enabled
        self._save_enabled_state()  # Persist state to disk
        return self.enabled

    @staticmethod
    def _get_state_file() -> str:
        """Get path to auto-chain state file."""
        import os
        from pathlib import Path
        data_dir = Path.home() / '.souleyez'
        data_dir.mkdir(exist_ok=True)
        return str(data_dir / '.autochain_enabled')

    @staticmethod
    def check_enabled_status() -> bool:
        """
        Lightweight check of auto-chaining status without instantiating ToolChaining.

        This is much faster than creating a ToolChaining() instance and avoids
        potential lock contention issues.

        Returns:
            True if auto-chaining is enabled, False otherwise
        """
        import os
        from pathlib import Path
        data_dir = Path.home() / '.souleyez'
        state_file = data_dir / '.autochain_enabled'

        if state_file.exists():
            try:
                with open(state_file, 'r') as f:
                    return f.read().strip().lower() == 'true'
            except:
                pass
        return False  # Default: disabled (PRO feature)

    def _load_enabled_state(self) -> bool:
        """Load enabled state from file. Defaults to False if file doesn't exist."""
        import os
        state_file = self._get_state_file()
        if os.path.exists(state_file):
            try:
                with open(state_file, 'r') as f:
                    return f.read().strip().lower() == 'true'
            except:
                pass
        return False  # Default: disabled (PRO feature)

    def _save_enabled_state(self):
        """Save enabled state to file."""
        state_file = self._get_state_file()
        try:
            with open(state_file, 'w') as f:
                f.write('true' if self.enabled else 'false')
        except Exception as e:
            from souleyez.log_config import get_logger
            logger = get_logger(__name__)
            logger.error(f"Failed to save auto-chain state: {e}")

    def is_enabled(self) -> bool:
        """Check if auto-chaining is enabled."""
        return self.enabled

    def enable_chaining(self):
        """Enable auto-chaining."""
        self.enabled = True
        self._save_enabled_state()

    def disable_chaining(self):
        """
        Disable auto-chaining.

        Note: Does NOT clear pending chainable jobs to avoid database locks.
        The worker will respect the disabled state and skip chaining on new jobs.
        """
        self.enabled = False
        self._save_enabled_state()

    # ========== APPROVAL MODE METHODS ==========

    @staticmethod
    def _get_approval_mode_file() -> str:
        """Get path to approval mode state file."""
        import os
        from pathlib import Path
        data_dir = Path.home() / '.souleyez'
        data_dir.mkdir(exist_ok=True)
        return str(data_dir / '.approval_mode')

    def _load_approval_mode(self) -> bool:
        """Load approval mode state from file. Defaults to False (auto mode)."""
        import os
        state_file = self._get_approval_mode_file()
        if os.path.exists(state_file):
            try:
                with open(state_file, 'r') as f:
                    return f.read().strip().lower() == 'true'
            except:
                pass
        return False  # Default: auto mode (no approval required)

    def _save_approval_mode(self):
        """Save approval mode state to file."""
        state_file = self._get_approval_mode_file()
        try:
            with open(state_file, 'w') as f:
                f.write('true' if self.approval_mode else 'false')
        except Exception as e:
            from souleyez.log_config import get_logger
            logger = get_logger(__name__)
            logger.error(f"Failed to save approval mode state: {e}")

    def is_approval_mode(self) -> bool:
        """Check if approval mode is enabled (chains require approval before execution)."""
        return self.approval_mode

    def enable_approval_mode(self):
        """Enable approval mode - chains will queue for user approval instead of auto-executing."""
        self.approval_mode = True
        self._save_approval_mode()

    def disable_approval_mode(self):
        """Disable approval mode - chains will auto-execute (default behavior)."""
        self.approval_mode = False
        self._save_approval_mode()

    def toggle_approval_mode(self) -> bool:
        """Toggle approval mode and return new state."""
        self.approval_mode = not self.approval_mode
        self._save_approval_mode()
        return self.approval_mode

    def execute_approved_chains(self, engagement_id: int = None) -> List[int]:
        """
        Execute all approved pending chains.

        Args:
            engagement_id: Filter by engagement (None = all)

        Returns:
            List of job IDs created
        """
        from souleyez.core.pending_chains import (
            get_approved_chains, mark_chain_executed
        )
        from souleyez.engine.background import enqueue_job

        job_ids = []
        approved = get_approved_chains(engagement_id)

        for chain in approved:
            try:
                job_id = enqueue_job(
                    tool=chain['tool'],
                    target=chain['target'],
                    args=chain.get('args', []),
                    label='approved-chain',
                    engagement_id=chain.get('engagement_id'),
                    parent_id=chain.get('parent_job_id'),
                    reason=chain.get('rule_description', 'Approved chain'),
                    metadata=chain.get('metadata')
                )
                mark_chain_executed(chain['id'], job_id)
                job_ids.append(job_id)
            except Exception as e:
                from souleyez.log_config import get_logger
                logger = get_logger(__name__)
                logger.error(f"Failed to execute approved chain {chain['id']}: {e}")

        return job_ids

    def save_rules(self):
        """Save current rule states (enabled/disabled, priority, trigger_count) to disk."""
        import json
        from pathlib import Path

        rules_file = Path.home() / '.souleyez' / 'chain_rules_state.json'

        # Save enabled/disabled state plus priority and trigger counts
        # NOTE: args_template is NOT saved because multiple rules can share the same key
        # but have different args. Saving would corrupt rules on reload.
        rules_state = {}
        for rule in self.rules:
            rule_key = f"{rule.trigger_tool}→{rule.target_tool}:{rule.trigger_condition}"
            # Store as dict to support multiple fields
            rules_state[rule_key] = {
                'enabled': rule.enabled,
                'priority': rule.priority,
                'trigger_count': rule.trigger_count
            }

        try:
            with open(rules_file, 'w') as f:
                json.dump(rules_state, f, indent=2)
        except Exception as e:
            from souleyez.log_config import get_logger
            logger = get_logger(__name__)
            logger.error(f"Failed to save rule states: {e}")

    def load_rules_state(self):
        """Load saved rule states (enabled/disabled, priority, trigger_count) from disk."""
        import json
        from pathlib import Path

        rules_file = Path.home() / '.souleyez' / 'chain_rules_state.json'

        if not rules_file.exists():
            return

        try:
            with open(rules_file, 'r') as f:
                rules_state = json.load(f)

            # Apply saved state to matching rules
            for rule in self.rules:
                rule_key = f"{rule.trigger_tool}→{rule.target_tool}:{rule.trigger_condition}"
                if rule_key in rules_state:
                    state = rules_state[rule_key]
                    # Handle both old format (boolean) and new format (dict)
                    if isinstance(state, bool):
                        # Legacy format: just enabled boolean
                        rule.enabled = state
                    elif isinstance(state, dict):
                        # New format: dict with enabled, priority, trigger_count
                        # NOTE: args_template is NOT loaded from saved state because multiple
                        # rules can share the same key but have different args. Loading saved
                        # args would corrupt all rules with the same condition.
                        if 'enabled' in state:
                            rule.enabled = state['enabled']
                        if 'priority' in state:
                            rule.priority = state['priority']
                        if 'trigger_count' in state:
                            rule.trigger_count = state['trigger_count']
        except Exception as e:
            from souleyez.log_config import get_logger
            logger = get_logger(__name__)
            logger.error(f"Failed to load rule states: {e}")

    def add_custom_rule(self, rule_dict: Dict[str, Any]) -> bool:
        """
        Add a custom user-created chain rule.
        
        Args:
            rule_dict: Dictionary with rule definition
        
        Returns:
            bool: True if added successfully
        """
        try:
            # Create ChainRule from dict
            rule = ChainRule(
                trigger_tool=rule_dict['trigger_tool'],
                trigger_condition=rule_dict['condition'],
                target_tool=rule_dict['target_tool'],
                priority=rule_dict.get('priority', 5),
                args_template=rule_dict.get('args', []),
                enabled=rule_dict.get('enabled', True),
                description=rule_dict.get('description', ''),
                category=rule_dict.get('category', CATEGORY_GENERAL)
            )
            
            # Add to rules list
            self.rules.append(rule)
            
            # Save to persistent storage
            self._save_custom_rules()
            
            # Also save rules state
            self.save_rules()
            
            return True
            
        except Exception as e:
            from souleyez.log_config import get_logger
            logger = get_logger(__name__)
            logger.error(f"Failed to add custom rule: {e}")
            return False

    def _save_custom_rules(self):
        """Save custom rules to persistent storage."""
        import json
        from pathlib import Path
        
        custom_rules_file = Path.home() / '.souleyez' / 'custom_chain_rules.json'
        
        # Note: For now, we'll save ALL rules and rely on save_rules() for state
        # In the future, we could mark rules as custom vs built-in
        
        try:
            custom_rules_file.parent.mkdir(parents=True, exist_ok=True)
            # Just create the file for now - actual custom rules will be tracked separately
            if not custom_rules_file.exists():
                with open(custom_rules_file, 'w') as f:
                    json.dump([], f)
        except Exception as e:
            from souleyez.log_config import get_logger
            logger = get_logger(__name__)
            logger.error(f"Failed to save custom rules: {e}")

    def get_rules_for_tool(self, tool: str) -> List[ChainRule]:
        """Get all rules triggered by a specific tool."""
        return [r for r in self.rules if r.trigger_tool == tool and r.enabled]

    def evaluate_chains(self, tool: str, context: Dict[str, Any]) -> List[Dict[str, Any]]:
        """
        Evaluate which tools should be chained given the context.

        Args:
            tool: The tool that just completed
            context: Context data (services, findings, host info, etc.)

        Returns:
            List of commands to execute, sorted by priority
        """
        # Check if auto-chaining is globally enabled
        if not self.enabled:
            return []

        commands = []
        rules = self.get_rules_for_tool(tool)

        for rule in rules:
            if rule.matches(context):
                rule.trigger_count += 1
                cmd = rule.generate_command(context)
                # Add rule_id (1-indexed position in rules list) for job tracking
                try:
                    rule_idx = self.rules.index(rule) + 1  # 1-indexed to match UI
                    cmd['rule_id'] = rule_idx
                except ValueError:
                    pass  # Rule not found in list (shouldn't happen)
                commands.append(cmd)

        # Sort by priority (highest first)
        commands.sort(key=lambda x: x.get('priority', 0), reverse=True)

        # Persist updated trigger counts if any rules matched
        if commands:
            self.save_rules()

        return commands

    def _deduplicate_web_targets(self, hosts_dict: Dict[str, list], parse_results: Dict[str, Any],
                                  job: Dict[str, Any]) -> list:
        """
        Deduplicate web targets to prevent redundant scanning.

        Groups IPs serving the same website and selects representative IP for scanning.
        When theHarvester discovers multiple IPs for a domain, they often serve identical
        content. This method detects such cases and reduces scan jobs by ~87%.

        Args:
            hosts_dict: {ip: [services]} from nmap results
            parse_results: Full parse results including domain context
            job: Parent job info

        Returns:
            List of deduplicated web targets:
            [
                {
                    'representative_ip': '198.185.159.144',
                    'all_ips': ['198.185.159.144', '198.185.159.145', ...],
                    'domain': 'cybersoulsecurity.com',
                    'services': [...],  # Services from representative IP
                    'scan_https_only': bool,  # True if HTTP redirects to HTTPS
                },
                ...
            ]
        """
        from souleyez.log_config import get_logger
        from souleyez.core.web_utils import check_http_redirect
        from souleyez.engine.background import get_job

        logger = get_logger(__name__)

        # Extract domain context from parent job or parse results
        def get_domain_context():
            # Check if parent job is theHarvester
            # BUG FIX: Look up the parent job from database, not current job
            # CRITICAL FIX: Use 'parent_id' not 'parent_job_id' (see background.py line 181)
            parent_job_id = job.get('parent_id')

            # ADD DEBUG LOGGING
            logger.debug(f"[WEB-DEDUP] Current job: id={job.get('id')}, tool={job.get('tool')}, parent_id={parent_job_id}")

            if parent_job_id:
                parent_job = get_job(parent_job_id)

                # ADD DEBUG LOGGING
                if parent_job:
                    logger.debug(f"[WEB-DEDUP] Parent job found: id={parent_job.get('id')}, tool={parent_job.get('tool')}, target={parent_job.get('target')}")
                else:
                    logger.warning(f"[WEB-DEDUP] Parent job #{parent_job_id} not found in job queue")

                if parent_job:
                    parent_tool = parent_job.get('tool', '').lower()

                    # FIX: Check for theHarvester (case-insensitive via .lower() above)
                    if parent_tool == 'theharvester':
                        domain = parent_job.get('target', '')
                        if domain and not domain.replace('.', '').replace('-', '').replace('_', '').replace('/', '').isdigit():
                            logger.info(f"[WEB-DEDUP] Found parent theHarvester job (#{parent_job_id}) for domain: {domain}")
                            return domain
                        else:
                            logger.debug(f"[WEB-DEDUP] Parent theHarvester target '{domain}' looks like IP, not domain")
            else:
                logger.debug("[WEB-DEDUP] No parent_id in current job")

            # Check parse results for domains
            domains = parse_results.get('domains', [])
            if domains:
                if isinstance(domains, list) and len(domains) > 0:
                    if isinstance(domains[0], dict):
                        return domains[0].get('domain')
                    elif isinstance(domains[0], str):
                        return domains[0]

            # Check subdomains
            subdomains = parse_results.get('subdomains', [])
            if subdomains and isinstance(subdomains, list) and len(subdomains) > 0:
                if isinstance(subdomains[0], dict):
                    return subdomains[0].get('subdomain')
                elif isinstance(subdomains[0], str):
                    # Extract base domain from subdomain
                    parts = subdomains[0].split('.')
                    if len(parts) >= 2:
                        return '.'.join(parts[-2:])

            return None

        domain_context = get_domain_context()

        # If no domain context, return all IPs as separate targets (fallback behavior)
        if not domain_context:
            logger.debug("[WEB-DEDUP] No domain context found, falling back to per-IP scanning")
            return [
                {
                    'representative_ip': ip,
                    'all_ips': [ip],
                    'domain': None,
                    'services': services,
                    'scan_https_only': False
                }
                for ip, services in hosts_dict.items()
            ]

        # Group IPs by domain and extract web services
        web_ips = []
        for ip, services in hosts_dict.items():
            # Filter for web services (http/https and common web ports)
            web_services = [
                s for s in services
                if (s.get('service_name', '').lower() in ['http', 'https', 'http-proxy', 'ssl/http'] or
                    s.get('port') in [80, 443, 8080, 8000, 8443, 3000, 5000])
            ]

            if web_services:
                web_ips.append({
                    'ip': ip,
                    'services': web_services
                })

        if not web_ips:
            logger.debug("[WEB-DEDUP] No web services found in nmap results")
            return []

        # Since all IPs came from same domain scan, group them together
        logger.info(f"[WEB-DEDUP] Found {len(web_ips)} IPs with web services for domain '{domain_context}'")

        # Select first IP as representative
        representative = web_ips[0]
        representative_ip = representative['ip']
        all_ips = [item['ip'] for item in web_ips]

        logger.info(f"[WEB-DEDUP] Selected {representative_ip} as representative for {len(all_ips)} IPs")

        # Check HTTP redirect behavior to avoid duplicate HTTP+HTTPS scans
        has_http = any(
            s.get('port') == 80 or (s.get('port') in [8080, 8000, 3000, 5000] and
                                    s.get('service_name', '').lower() == 'http')
            for s in representative['services']
        )
        has_https = any(
            s.get('port') == 443 or (s.get('port') in [8443] or
                                     s.get('service_name', '').lower() in ['https', 'ssl/http'])
            for s in representative['services']
        )

        scan_https_only = False
        filtered_services = representative['services']

        if has_http and has_https:
            logger.info(f"[WEB-DEDUP] Checking if HTTP→HTTPS redirect exists for {representative_ip}")

            # Get the actual HTTP port (might not be 80)
            http_port = None
            for s in representative['services']:
                if s.get('service_name', '').lower() == 'http' or s.get('port') == 80:
                    http_port = s.get('port', 80)
                    break

            if http_port:
                redirect_info = check_http_redirect(representative_ip, port=http_port, timeout=3)

                if redirect_info.get('error'):
                    logger.warning(f"[WEB-DEDUP] Redirect check failed: {redirect_info['error']}, scanning both HTTP and HTTPS")
                elif redirect_info.get('redirects_to_https'):
                    logger.info(f"[WEB-DEDUP] HTTP→HTTPS redirect confirmed, will scan HTTPS only")
                    scan_https_only = True
                    # Filter out HTTP services, keep only HTTPS
                    filtered_services = [
                        s for s in representative['services']
                        if not (s.get('service_name', '').lower() == 'http' and s.get('port') in [80, 8080, 8000, 3000, 5000])
                    ]
                else:
                    logger.info(f"[WEB-DEDUP] No HTTP→HTTPS redirect detected, scanning both protocols")

        web_target = {
            'representative_ip': representative_ip,
            'all_ips': all_ips,
            'domain': domain_context,
            'services': filtered_services,
            'scan_https_only': scan_https_only
        }

        logger.info(f"[WEB-DEDUP] Created deduplicated target: {representative_ip} (covers {len(all_ips)} IPs)")

        return [web_target]

    def auto_chain(self, job: Dict[str, Any], parse_results: Dict[str, Any]) -> List[int]:
        """
        Automatically chain tools based on job results.

        Args:
            job: Completed job dict
            parse_results: Results from parsing (hosts, services, findings, etc.)

        Returns:
            List of new job IDs that were created
        """
        tool = job.get('tool', '').lower()
        target = job.get('target', '')

        if not target:
            return []

        engagement_id = job.get('engagement_id')
        job_ids = []

        # For scans that discover multiple hosts (like nmap), we need to chain per-host
        # Group services by host IP
        services = parse_results.get('services', [])
        
        hosts_dict = {}

        # IPs to skip - placeholders that shouldn't be used as scan targets
        invalid_ips = {'0.0.0.0', '127.0.0.1', '::1', '::'}  # nosec B104 - not binding, filtering invalid IPs

        for service in services:
            host_ip = service.get('ip', '') or service.get('host', '')
            # Skip invalid/placeholder IPs
            if host_ip and host_ip not in invalid_ips:
                if host_ip not in hosts_dict:
                    hosts_dict[host_ip] = []
                hosts_dict[host_ip].append(service)

        # If we have per-host services, create separate chain contexts for each host
        if hosts_dict:
            # === Web Target Deduplication for nmap ===
            # When nmap discovers multiple IPs (e.g., from theHarvester), deduplicate web scanning
            # to avoid redundant nuclei/gobuster jobs on identical websites
            if tool == 'nmap':
                from souleyez.log_config import get_logger
                logger = get_logger(__name__)

                # Get deduplicated web targets
                web_targets = self._deduplicate_web_targets(hosts_dict, parse_results, job)

                # Separate web services from non-web services for each IP
                web_service_names = ['http', 'https', 'http-proxy', 'ssl/http']
                web_ports = [80, 443, 8080, 8000, 8443, 3000, 5000]

                # Track which IPs are included in web deduplication
                web_dedup_ips = set()
                for web_target in web_targets:
                    web_dedup_ips.update(web_target['all_ips'])

                # Process deduplicated web targets
                for web_target in web_targets:
                    representative_ip = web_target['representative_ip']
                    all_ips = web_target['all_ips']

                    context = {
                        'target': representative_ip,
                        'tool': tool,
                        'services': web_target['services'],
                        'findings': parse_results.get('findings', []),
                        'hosts': [representative_ip],
                        'writable_shares': parse_results.get('writable_shares', []),
                        'paths_with_params': parse_results.get('paths_with_params', []),
                        'domains': parse_results.get('domains', []),
                        'subdomains': parse_results.get('subdomains', []),
                        # NEW: Add deduplication metadata
                        'associated_ips': all_ips,
                        'representative_ip': representative_ip,
                        'domain_context': web_target.get('domain'),
                    }

                    # Enrich context with OS/device classification
                    os_device = classify_os_device(
                        parse_results.get('os_info', ''),
                        web_target['services']
                    )
                    context['os_family'] = os_device['os_family']
                    context['device_type'] = os_device['device_type']
                    context['vendor'] = os_device['vendor']

                    # Enrich context with domain from database if not in parse results
                    if not context['domains'] and engagement_id:
                        try:
                            from souleyez.storage.hosts import HostManager
                            hm = HostManager()
                            host = hm.get_host_by_ip(engagement_id, representative_ip)
                            if host and host.get('domain'):
                                context['domains'] = [{
                                    'domain': host['domain'],
                                    'ip': representative_ip
                                }]
                        except Exception as e:
                            pass

                    # Evaluate and enqueue web scanning chains
                    commands = self.evaluate_chains(tool, context)

                    # Enrich commands with deduplication metadata
                    for cmd in commands:
                        cmd['target'] = representative_ip
                        if 'metadata' not in cmd:
                            cmd['metadata'] = {}
                        cmd['metadata']['associated_ips'] = all_ips
                        cmd['metadata']['representative_ip'] = representative_ip
                        if web_target.get('domain'):
                            cmd['metadata']['domain_context'] = web_target['domain']

                    job_ids.extend(self._enqueue_commands(
                        commands, tool, engagement_id, representative_ip,
                        parent_job_id=job.get('id')
                    ))

                    logger.info(f"[WEB-DEDUP] Enqueued {len(commands)} web scan jobs for {representative_ip} (covers {len(all_ips)} IPs)")

                # Process non-web services for ALL IPs (not deduplicated)
                for host_ip, host_services in hosts_dict.items():
                    # Filter out web services - only process non-web services
                    non_web_services = [
                        s for s in host_services
                        if not (s.get('service_name', '').lower() in web_service_names or
                                s.get('port') in web_ports)
                    ]

                    if non_web_services:
                        context = {
                            'target': host_ip,
                            'tool': tool,
                            'services': non_web_services,
                            'findings': parse_results.get('findings', []),
                            'hosts': [host_ip],
                            'writable_shares': parse_results.get('writable_shares', []),
                            'paths_with_params': parse_results.get('paths_with_params', []),
                            'domains': parse_results.get('domains', []),
                            'subdomains': parse_results.get('subdomains', []),
                        }

                        # Enrich context with OS/device classification
                        os_device = classify_os_device(
                            parse_results.get('os_info', ''),
                            non_web_services
                        )
                        context['os_family'] = os_device['os_family']
                        context['device_type'] = os_device['device_type']
                        context['vendor'] = os_device['vendor']

                        # Enrich context with domain from database
                        if not context['domains'] and engagement_id:
                            try:
                                from souleyez.storage.hosts import HostManager
                                hm = HostManager()
                                host = hm.get_host_by_ip(engagement_id, host_ip)
                                if host and host.get('domain'):
                                    context['domains'] = [{
                                        'domain': host['domain'],
                                        'ip': host_ip
                                    }]
                            except Exception as e:
                                pass

                        # Evaluate and enqueue non-web chains (SMB, SSH, RDP, etc.)
                        commands = self.evaluate_chains(tool, context)
                        for cmd in commands:
                            cmd['target'] = host_ip
                        job_ids.extend(self._enqueue_commands(
                            commands, tool, engagement_id, host_ip,
                            parent_job_id=job.get('id')
                        ))
            else:
                # Non-nmap tools: use original per-host processing
                for host_ip, host_services in hosts_dict.items():
                    context = {
                        'target': host_ip,
                        'tool': tool,
                        'services': host_services,
                        'findings': parse_results.get('findings', []),
                        'hosts': [host_ip],
                        'writable_shares': parse_results.get('writable_shares', []),
                        'paths_with_params': parse_results.get('paths_with_params', []),
                        'domains': parse_results.get('domains', []),
                        'subdomains': parse_results.get('subdomains', []),
                    }

                    # Enrich context with OS/device classification
                    os_device = classify_os_device(
                        parse_results.get('os_info', ''),
                        host_services
                    )
                    context['os_family'] = os_device['os_family']
                    context['device_type'] = os_device['device_type']
                    context['vendor'] = os_device['vendor']

                    # Enrich context with domain from database if not in parse results
                    if not context['domains'] and engagement_id:
                        try:
                            from souleyez.storage.hosts import HostManager
                            hm = HostManager()
                            host = hm.get_host_by_ip(engagement_id, host_ip)
                            if host and host.get('domain'):
                                context['domains'] = [{
                                    'domain': host['domain'],
                                    'ip': host_ip
                                }]
                        except Exception as e:
                            pass

                    # Evaluate and enqueue for this specific host
                    commands = self.evaluate_chains(tool, context)
                    for cmd in commands:
                        cmd['target'] = host_ip
                    job_ids.extend(self._enqueue_commands(commands, tool, engagement_id, host_ip, parent_job_id=job.get('id')))

                # === SearchSploit: Search for exploits based on service versions ===
                if tool in ['nmap'] and host_services:
                    import re
                    seen_searches = set()  # Deduplicate search terms
                    
                    for service in host_services:
                        product = service.get('product') or service.get('service_product')
                        version = service.get('version') or service.get('service_version')
                        
                        # Clean nmap metadata from version
                        if version:
                            # Remove "syn-ack ttl XX" prefix
                            for prefix in ['syn-ack ttl', 'ttl', 'syn-ack']:
                                if version.startswith(prefix):
                                    parts = version.split()
                                    cleaned_parts = []
                                    skip_next = False
                                    for i, part in enumerate(parts):
                                        if skip_next:
                                            skip_next = False
                                            continue
                                        if part in ['syn-ack', 'ttl']:
                                            if part == 'ttl':
                                                skip_next = True
                                            continue
                                        cleaned_parts = parts[i:]
                                        break
                                    version = ' '.join(cleaned_parts) if cleaned_parts else version
                                    break
                        
                        # Only search if we have both product and version
                        if product and version:
                            # Parse and clean version for better Exploit-DB matching
                            version_pattern = r'\b(v?\d+\.\d+[\w\.\-]*)\b'
                            match = re.search(version_pattern, version)
                            
                            if match:
                                parsed_version = match.group(1)
                                
                                # Clean version - remove distro-specific suffixes
                                clean_version = parsed_version
                                clean_version = re.sub(r'-\d*ubuntu\d+$', '', clean_version)
                                clean_version = re.sub(r'-\d*debian\d+$', '', clean_version, flags=re.IGNORECASE)
                                # Remove trailing letters after patch numbers (5.0.51a -> 5.0.51)
                                clean_version = re.sub(r'([0-9]+\.[0-9]+\.[0-9]+)[a-z].*$', r'\1', clean_version)
                                # Remove patch level suffixes (4.7p1 -> 4.7)
                                clean_version = re.sub(r'p\d+.*$', '', clean_version)
                                
                                # Extract service name from version string
                                version_start = match.start()
                                service_part = version[:version_start].strip()
                                
                                if service_part:
                                    search_term = f"{service_part} {clean_version}"
                                else:
                                    search_term = f"{product} {clean_version}"
                            else:
                                # No version pattern found, use as-is
                                search_term = f"{product} {version}"
                            
                            # Deduplicate: Skip if we've already queued this search
                            if search_term not in seen_searches:
                                seen_searches.add(search_term)
                                commands.append({
                                    'tool': 'searchsploit',
                                    'target': search_term,
                                    'args': ['--json'],
                                    'reason': f'Auto-triggered by {tool}: Service detected - {search_term}'
                                })
                    
                    # Enqueue searchsploit jobs
                    searchsploit_cmds = [c for c in commands if c.get('tool') == 'searchsploit']
                    if searchsploit_cmds:
                        job_ids.extend(self._enqueue_commands(searchsploit_cmds, tool, engagement_id, host_ip, parent_job_id=job.get('id')))
        else:
            # No per-host services, use original target (e.g., for OSINT tools)
            print(f"[DEBUG] Auto-chain: No services found, using original target for tool={tool}")

            # === Special handling for SQLMap progressive exploitation ===
            if tool == 'sqlmap':
                databases = parse_results.get('databases', [])
                injectable_url = parse_results.get('injectable_url', target)
                sql_injection_confirmed = parse_results.get('sql_injection_confirmed', False)

                # Check if this job was already in enumeration phase (avoid loops)
                job_args = job.get('args', [])
                is_dbs_phase = '--dbs' in job_args
                is_tables_phase = '--tables' in job_args or '-D' in job_args
                is_dump_phase = '--dump' in job_args

                # Get POST data - either from parse_results or from job args (if passed from previous stage)
                post_data = parse_results.get('injectable_post_data') or ''
                if not post_data:
                    # Check if --data was passed in job args
                    for arg in job_args:
                        if arg.startswith('--data='):
                            post_data = arg.split('=', 1)[1]
                            break
                
                # Get tables from parse results
                tables = parse_results.get('tables', {})

                # === Phase 3: Hybrid Column Enumeration → Auto-dump ===
                if is_tables_phase and not is_dump_phase and tables and len(tables) > 0:
                    # Just finished --tables phase, use hybrid approach
                    from souleyez.intelligence.sensitive_tables import (
                        prioritize_tables, 
                        is_system_database,
                        is_system_table,
                        is_sensitive_table_name
                    )
                    from souleyez.log_config import get_logger
                    logger = get_logger(__name__)

                    # Filter out system databases and tables
                    filtered_tables = {}
                    skipped_tables = 0
                    
                    for db_name, table_list in tables.items():
                        # Skip system databases entirely
                        if is_system_database(db_name):
                            skipped_tables += len(table_list)
                            continue
                        
                        # Filter out system tables
                        app_tables = [t for t in table_list if not is_system_table(t)]
                        if app_tables:
                            filtered_tables[db_name] = app_tables
                        
                        skipped_tables += (len(table_list) - len(app_tables))
                    
                    if skipped_tables > 0:
                        logger.info(f"SQLMap: Skipped {skipped_tables} system/metadata tables")
                    
                    if not filtered_tables:
                        logger.info("SQLMap: No application tables found after filtering")
                        return job_ids

                    # Split tables into known-sensitive vs needs-column-enum
                    sensitive_tables_dict = {}  # Dump immediately
                    unknown_tables_dict = {}    # Enumerate columns first

                    for db_name, table_list in filtered_tables.items():
                        for table_name in table_list:
                            if is_sensitive_table_name(table_name):
                                sensitive_tables_dict.setdefault(db_name, []).append(table_name)
                            else:
                                unknown_tables_dict.setdefault(db_name, []).append(table_name)

                    # 1. Dump known-sensitive tables immediately
                    if sensitive_tables_dict:
                        columns = parse_results.get('columns', {})
                        sensitive_tables = prioritize_tables(sensitive_tables_dict, columns)
                        
                        logger.info(
                            f"SQLMap: Detected {len(sensitive_tables)} known-sensitive tables "
                            f"({', '.join(t['table'] for t in sensitive_tables[:3])}...)"
                        )

                        # Create --dump jobs for each sensitive table
                        dump_jobs = []
                        for table_info in sensitive_tables:
                            db = table_info['database']
                            table = table_info['table']
                            category = table_info['category']

                            dump_args = [
                                '-u', injectable_url,
                                '--batch',
                                '-D', db,
                                '-T', table,
                                '--dump',
                                '--stop', '100',
                                '--threads', '1'
                            ]
                            # Add --data for POST injections
                            if post_data:
                                dump_args.insert(2, f'--data={post_data}')

                            dump_cmd = {
                                'tool': 'sqlmap',
                                'target': injectable_url,
                                'args': dump_args,
                                'label': f"Auto-chained: Dumping {category} table '{db}.{table}'",
                                'reason': f"Auto-triggered by sqlmap: Dumping {category} table '{db}.{table}'"
                            }
                            dump_jobs.append(dump_cmd)

                        job_ids.extend(self._enqueue_commands(dump_jobs, tool, engagement_id, injectable_url, parent_job_id=job.get('id')))

                    # 2. For unknown tables, only enumerate columns for SENSITIVE tables
                    # This reduces noise and focuses on high-value targets
                    from souleyez.intelligence.sensitive_tables import is_sensitive_table, is_system_table

                    MAX_TABLES_FOR_COLUMN_ENUM = 15  # Focused on sensitive tables only
                    tables_queued = 0
                    skipped_tables = 0

                    if unknown_tables_dict:
                        # First, filter to only sensitive tables
                        sensitive_unknown = {}
                        for db_name, table_list in unknown_tables_dict.items():
                            for table_name in table_list:
                                # Skip system tables
                                if is_system_table(table_name):
                                    skipped_tables += 1
                                    continue

                                # Check if table name suggests sensitive data
                                is_sens, category, priority = is_sensitive_table(table_name)
                                if is_sens and priority >= 5:
                                    if db_name not in sensitive_unknown:
                                        sensitive_unknown[db_name] = []
                                    sensitive_unknown[db_name].append((table_name, category, priority))
                                else:
                                    skipped_tables += 1

                        if sensitive_unknown:
                            # Sort by priority within each database
                            for db_name in sensitive_unknown:
                                sensitive_unknown[db_name].sort(key=lambda x: -x[2])  # Sort by priority desc

                            total_sensitive = sum(len(tables) for tables in sensitive_unknown.values())
                            logger.info(
                                f"SQLMap: Enumerating columns for {min(total_sensitive, MAX_TABLES_FOR_COLUMN_ENUM)} "
                                f"sensitive tables (skipped {skipped_tables} non-sensitive/system tables)"
                            )

                            for db_name, table_info_list in sensitive_unknown.items():
                                for table_name, category, priority in table_info_list:
                                    if tables_queued >= MAX_TABLES_FOR_COLUMN_ENUM:
                                        break

                                    # Create context for Rule 70 (has:tables_enumerated)
                                    context = {
                                        'target': injectable_url,
                                        'tool': tool,
                                        'tables_enumerated': True,
                                        'database': db_name,
                                        'table': table_name,
                                        'post_data': post_data,  # Preserve POST data for subsequent commands
                                    }

                                    # Let Rule 70 fire and create the --columns job
                                    commands = self.evaluate_chains(tool, context)

                                    # The rule uses {database} and {table} placeholders - already replaced by evaluate_chains
                                    if commands:
                                        job_ids.extend(self._enqueue_commands(commands, tool, engagement_id, injectable_url, parent_job_id=job.get('id')))
                                        tables_queued += 1
                                        logger.debug(f"  -> Queued column enum for {db_name}.{table_name} ({category}, priority={priority})")

                                if tables_queued >= MAX_TABLES_FOR_COLUMN_ENUM:
                                    break
                        else:
                            logger.info(
                                f"SQLMap: No sensitive tables found in {len(unknown_tables_dict)} databases "
                                f"(skipped {skipped_tables} non-sensitive/system tables)"
                            )

                    # Don't return early - continue to rule evaluation below if needed

                # === Phase 4: Columns enumerated → Dump if sensitive ===
                is_columns_phase = '--columns' in job_args
                if is_columns_phase and not is_dump_phase:
                    # Just finished --columns phase, check for sensitive columns
                    from souleyez.intelligence.sensitive_tables import has_sensitive_columns
                    from souleyez.log_config import get_logger
                    logger = get_logger(__name__)

                    columns = parse_results.get('columns', {})
                    if columns:
                        dump_jobs = []

                        for table_key, column_list in columns.items():
                            if has_sensitive_columns(column_list):
                                # Parse db.table from key
                                if '.' in table_key:
                                    db_name, table_name = table_key.split('.', 1)
                                else:
                                    # Fallback if no database prefix
                                    db_name = None
                                    table_name = table_key

                                logger.info(
                                    f"SQLMap: Sensitive columns detected in '{table_key}' "
                                    f"(columns: {', '.join([c if isinstance(c, str) else c.get('name', '?') for c in column_list[:3]])}...)"
                                )

                                # Create dump job for this table
                                args = [
                                    '-u', injectable_url,
                                    '--batch',
                                    '--forms',
                                    '--crawl=2'
                                ]

                                if db_name:
                                    args.extend(['-D', db_name])
                                args.extend(['-T', table_name])
                                args.extend(['--dump', '--stop', '100', '--threads', '1'])

                                dump_cmd = {
                                    'tool': 'sqlmap',
                                    'target': injectable_url,
                                    'args': args,
                                    'label': f"Auto-chained: Dumping table with sensitive columns '{table_key}'",
                                    'reason': f"Auto-triggered by sqlmap: Sensitive columns found in '{table_key}'"
                                }
                                dump_jobs.append(dump_cmd)

                        if dump_jobs:
                            job_ids.extend(self._enqueue_commands(dump_jobs, tool, engagement_id, injectable_url, parent_job_id=job.get('id')))

                    # Also trigger Rule 71 (has:columns_enumerated) for tracking
                    # Rule 71 is disabled by default, but fires for "Fired" count if enabled
                    for table_key in columns.keys():
                        if '.' in table_key:
                            db_name, table_name = table_key.split('.', 1)
                        else:
                            db_name = None
                            table_name = table_key

                        context = {
                            'target': injectable_url,
                            'tool': tool,
                            'columns_enumerated': True,
                            'database': db_name or '',
                            'table': table_name,
                        }
                        # Let Rule 71 fire (if enabled)
                        commands = self.evaluate_chains(tool, context)
                        if commands:
                            job_ids.extend(self._enqueue_commands(commands, tool, engagement_id, injectable_url, parent_job_id=job.get('id')))

                    return job_ids

                # For --dump phase, just return (nothing more to chain)
                if is_dump_phase:
                    return job_ids

                # Check if SQL injection was just confirmed (trigger --dbs or --tables)
                # But DON'T trigger if we just ran --dbs (prevent --dbs → --dbs loop)
                if sql_injection_confirmed and not databases and not is_dbs_phase:
                    # SQL injection found but databases not yet enumerated

                    # Cache all injection points for fallback
                    all_points = parse_results.get('all_injection_points', [])
                    if all_points:
                        host = urlparse(injectable_url).netloc
                        ToolChaining._injection_points_cache[host] = all_points
                        from souleyez.log_config import get_logger
                        logger = get_logger(__name__)
                        logger.info(f"SQLMap: Cached {len(all_points)} injection points for {host}")

                    # Check if DBMS is SQLite - skip --dbs, go directly to --tables
                    dbms = (parse_results.get('dbms') or '').lower()
                    is_sqlite = 'sqlite' in dbms

                    post_data = parse_results.get('injectable_post_data') or ''

                    if is_sqlite:
                        # SQLite doesn't support --dbs, go directly to --tables
                        from souleyez.log_config import get_logger
                        logger = get_logger(__name__)
                        logger.info("SQLMap: SQLite detected, skipping --dbs and using --tables directly")

                        # Create --tables job directly (SQLite has implicit single database)
                        tables_args = [
                            '-u', injectable_url,
                            '--tables',
                            '--batch',
                            '--threads', '1'
                        ]
                        if post_data:
                            tables_args.insert(2, f'--data={post_data}')

                        tables_cmd = {
                            'tool': 'sqlmap',
                            'target': injectable_url,
                            'args': tables_args,
                            'label': 'Auto-chain: SQLite table enumeration',
                            'reason': 'SQLite detected - skipping --dbs, enumerating tables directly'
                        }
                        job_ids.extend(self._enqueue_commands([tables_cmd], tool, engagement_id, injectable_url, parent_job_id=job.get('id')))
                    else:
                        # Non-SQLite: use normal --dbs chain via evaluate_chains()
                        context = {
                            'target': injectable_url,
                            'tool': tool,
                            'sql_injection_confirmed': True,
                            'databases_enumerated': False,
                            'post_data': post_data,  # For POST injections
                        }
                        commands = self.evaluate_chains(tool, context)
                        job_ids.extend(self._enqueue_commands(commands, tool, engagement_id, injectable_url, parent_job_id=job.get('id')))

                # Check if databases were enumerated (trigger --tables per database)
                elif databases and len(databases) > 0:
                    # Filter out system databases (zero pentest value)
                    from souleyez.intelligence.sensitive_tables import is_system_database
                    import re

                    def is_garbage_db_name(name: str) -> bool:
                        """Detect SQLMap marker strings or broken extraction results."""
                        if not name:
                            return True
                        # SQLMap markers are typically 40-char random alphanumeric strings
                        if len(name) >= 30 and re.match(r'^[a-zA-Z0-9]+$', name):
                            # Real DB names rarely exceed 30 chars of pure alphanumeric
                            return True
                        # Skip hex-looking strings
                        if re.match(r'^0x[0-9a-fA-F]+$', name):
                            return True
                        # Skip pure numbers (SQLMap sometimes returns counts instead of names)
                        if re.match(r'^\d+$', name):
                            return True
                        # Skip very short names (1-2 chars are rarely real DBs)
                        if len(name) <= 2:
                            return True
                        return False

                    # Track what we filtered
                    garbage_dbs = [db for db in databases if is_garbage_db_name(db)]
                    system_dbs = [db for db in databases if is_system_database(db) and not is_garbage_db_name(db)]
                    app_databases = [db for db in databases
                                     if not is_system_database(db) and not is_garbage_db_name(db)]

                    from souleyez.log_config import get_logger
                    logger = get_logger(__name__)

                    if garbage_dbs:
                        logger.warning(
                            f"SQLMap: Filtered {len(garbage_dbs)} garbage database names "
                            f"('{', '.join(garbage_dbs[:3])}') - extraction may have failed"
                        )

                    if not app_databases:
                        if garbage_dbs:
                            # Extraction failed - try fallback to different injection point
                            host = urlparse(injectable_url).netloc
                            all_points = ToolChaining._injection_points_cache.get(host, [])
                            current_url = injectable_url

                            # Find next untried injection point
                            # Check job label for previously tried URLs
                            job_label = job.get('label', '')
                            tried_urls = set()
                            if 'tried:' in job_label:
                                tried_part = job_label.split('tried:')[1].split(')')[0]
                                tried_urls = set(u.strip() for u in tried_part.split(',') if u.strip())
                            tried_urls.add(current_url)

                            next_point = None
                            for point in all_points:
                                if point['url'] not in tried_urls:
                                    next_point = point
                                    break

                            if next_point:
                                logger.info(
                                    f"SQLMap: Extraction failed on {current_url}, "
                                    f"trying fallback: {next_point['url']}"
                                )

                                # Create new --dbs job with alternate injection point
                                fallback_args = [
                                    '-u', next_point['url'],
                                    '--dbs', '--batch'
                                ]

                                # Add POST data if needed
                                if next_point.get('post_data'):
                                    fallback_args.insert(2, f"--data={next_point['post_data']}")

                                fallback_cmd = {
                                    'tool': 'sqlmap',
                                    'target': next_point['url'],
                                    'args': fallback_args,
                                    'label': f"sqlmap (fallback, tried:{','.join(tried_urls)})",
                                    'reason': f"Fallback --dbs after extraction failed on {current_url}"
                                }

                                # Pass all_injection_points to the fallback job
                                # so it can continue fallback chain if needed
                                job_ids.extend(self._enqueue_commands(
                                    [fallback_cmd], tool, engagement_id,
                                    next_point['url'], parent_job_id=job.get('id')
                                ))
                                return job_ids
                            else:
                                logger.error(
                                    f"SQLMap: All {len(all_points) or 1} injection points exhausted - "
                                    f"none support data extraction"
                                )
                        else:
                            logger.info(
                                f"SQLMap: Skipped {len(databases)} system databases "
                                f"({', '.join(databases[:3])}{'...' if len(databases) > 3 else ''})"
                            )
                        return job_ids
                    
                    # Create one --tables job PER application database (limit to first 5)
                    db_limit = min(len(app_databases), self.MAX_DATABASES_TO_ENUMERATE)

                    for db_name in app_databases[:db_limit]:
                        # Create context with this specific database
                        context = {
                            'target': injectable_url,
                            'tool': tool,
                            'databases_enumerated': True,
                            'tables_enumerated': False,
                            'database': db_name,  # Required for {database} placeholder
                            'post_data': post_data,  # Preserve POST data for subsequent commands
                        }

                        # Evaluate chains - will match the --tables rule
                        commands = self.evaluate_chains(tool, context)

                        # Replace {database} placeholder in args with actual database name
                        for cmd in commands:
                            # Replace '-D', '{database}' with '-D', 'actual_db_name'
                            if '{database}' in cmd.get('args', []):
                                cmd['args'] = [
                                    arg if arg != '{database}' else db_name
                                    for arg in cmd['args']
                                ]

                            # Update reason to include database name
                            cmd['reason'] = f"Auto-triggered by sqlmap: Enumerating tables in database '{db_name}'"

                        # Enqueue jobs for this database
                        job_ids.extend(self._enqueue_commands(commands, tool, engagement_id, injectable_url, parent_job_id=job.get('id')))

                    # Log if we hit the limit or skipped system databases
                    if len(app_databases) > db_limit or len(app_databases) < len(databases):
                        from souleyez.log_config import get_logger
                        logger = get_logger(__name__)
                        
                        skipped_count = len(databases) - len(app_databases)
                        if skipped_count > 0:
                            logger.info(
                                f"SQLMap: Skipped {skipped_count} system databases, "
                                f"enumerating {min(db_limit, len(app_databases))} application databases"
                            )
                        
                        if len(app_databases) > db_limit:
                            logger.info(f"SQLMap auto-chaining limited to first {db_limit} of {len(app_databases)} application databases")

                return job_ids
            # === END SQLMap special handling ===

            # === Evaluate finding-based chain rules for all tools (including gobuster) ===
            # This enables finding:api, finding:rest, etc. to trigger SQLMap/other tools
            # SKIP for gobuster and theharvester when special handling will apply (prevents duplicate jobs)
            skip_finding_based = (
                (tool == 'gobuster' and
                 (parse_results.get('php_files') or parse_results.get('high_value_dirs')))
                or tool == 'theharvester'  # Skip generic evaluation, use special handling at line 1933
            )

            if not hosts_dict and not skip_finding_based:  # Tools without per-host services (gobuster, osint, etc.)
                from souleyez.log_config import get_logger
                logger = get_logger(__name__)

                context = {
                    'target': target,
                    'tool': tool,
                    'services': [],
                    'findings': parse_results.get('findings', []),
                    'hosts': [target],
                    'php_files': parse_results.get('php_files', []),
                    'high_value_dirs': parse_results.get('high_value_dirs', []),
                    'paths_with_params': parse_results.get('paths_with_params', []),
                    'domains': parse_results.get('domains', []),
                    'subdomains': parse_results.get('subdomains', []),
                    'parameters_found': parse_results.get('parameters_found', []),  # ffuf → sqlmap chain
                }

                # Evaluate generic chain rules (findings, etc.)
                logger.info(f"Evaluating finding-based chain rules for {tool}")
                logger.info(f"  - Findings: {len(context['findings'])}")

                commands = self.evaluate_chains(tool, context)
                if commands:
                    logger.info(f"  - Matched {len(commands)} finding-based chain rule(s)")
                    job_ids.extend(self._enqueue_commands(commands, tool, engagement_id, target, parent_job_id=job.get('id')))
                else:
                    logger.info(f"  - No finding-based chain rules matched")
            # === END finding-based chain evaluation ===

            # === Special handling for Gobuster: PHP files → crawl once, redirects + MySQL ===
            if tool == 'gobuster':
                from souleyez.log_config import get_logger
                logger = get_logger(__name__)

                # === Wildcard Auto-Retry Logic ===
                # If gobuster detected wildcard responses, auto-retry with --exclude-length
                if parse_results.get('wildcard_detected') and parse_results.get('exclude_length'):
                    # Check if this is already a retry attempt (prevent loops)
                    is_retry = job.get('metadata', {}).get('retry_attempt', 0) > 0

                    # Check if args already contain --exclude-length (prevent double-retry)
                    args = job.get('args', [])
                    has_exclude_length = '--exclude-length' in args

                    if not is_retry and not has_exclude_length:
                        exclude_length = parse_results['exclude_length']
                        logger.info(f"🔄 Wildcard detected (Length: {exclude_length}), creating auto-retry with --exclude-length")

                        # Create retry job with original args + --exclude-length flag
                        retry_args = args.copy()
                        retry_args.extend(['--exclude-length', exclude_length])

                        # Build retry job command
                        retry_command = {
                            'tool': 'gobuster',
                            'target': target,
                            'args': retry_args,
                            'reason': f'Auto-retry: gobuster (wildcard detected, excluding length {exclude_length})',
                            'metadata': {
                                'retry_attempt': 1,
                                'retry_parent_job_id': job.get('id'),
                                'wildcard_exclude_length': exclude_length
                            }
                        }

                        # Enqueue the retry job
                        retry_job_ids = self._enqueue_commands([retry_command], tool, engagement_id, target, parent_job_id=job.get('id'))
                        if retry_job_ids:
                            job_ids.extend(retry_job_ids)
                            logger.info(f"✓ Retry job created with --exclude-length {exclude_length}")

                        # Skip further processing of this failed wildcard scan
                        # Let the retry job complete and process properly
                        return job_ids
                # === END Wildcard Auto-Retry ===

                # PHP files found → trigger SQLMap intelligently
                # High-value targets get direct testing, others get base URL crawl
                php_files = parse_results.get('php_files', [])
                high_value_dirs = parse_results.get('high_value_dirs', [])

                logger.info(f"[DEBUG] Gobuster auto-chain analysis:")
                logger.info(f"[DEBUG]   - Found {len(php_files)} PHP file(s)")
                logger.info(f"[DEBUG]   - Found {len(high_value_dirs)} high-value director(y/ies): {high_value_dirs}")

                if php_files:
                    for php_url, status_code in php_files[:5]:
                        logger.info(f"[DEBUG]     PHP: {php_url} (status: {status_code})")
                    if len(php_files) > 5:
                        logger.info(f"[DEBUG]     ... and {len(php_files) - 5} more PHP files")

                if high_value_dirs:
                    for hvd in high_value_dirs:
                        logger.info(f"[DEBUG]     HIGH-VALUE DIR: {hvd}")

                # Extract base_url for use in all auto-chain commands
                # This must be defined before php_files or high_value_dirs processing
                base_url = None
                if parse_results.get('target_url'):
                    # Best case: parser provided target_url
                    base_url = parse_results['target_url']
                    if not base_url.endswith('/'):
                        base_url += '/'
                elif high_value_dirs:
                    # Fallback: extract from first high-value directory
                    parsed = urlparse(high_value_dirs[0])
                    base_url = f"{parsed.scheme}://{parsed.netloc}/"
                elif php_files:
                    # Fallback: extract from first PHP file
                    # php_files is now a list of tuples (url, status_code)
                    parsed = urlparse(php_files[0][0])
                    base_url = f"{parsed.scheme}://{parsed.netloc}/"
                else:
                    # Last resort: construct from original target
                    # This handles cases where target is just an IP
                    if target.startswith('http'):
                        parsed = urlparse(target)
                        base_url = f"{parsed.scheme}://{parsed.netloc}/"
                    else:
                        # Just an IP/domain, assume HTTP
                        base_url = f"http://{target}/"

                if php_files:

                    # Define high-value keywords that indicate sensitive functionality
                    high_value_keywords = [
                        'admin', 'login', 'signin', 'signup', 'register', 'auth',
                        'payroll', 'payment', 'checkout', 'account', 'user', 'profile',
                        'dashboard', 'panel', 'console', 'manager', 'config', 'settings',
                        'upload', 'edit', 'delete', 'update', 'modify', 'search',
                        'query', 'sql', 'db', 'database', 'api', 'rest',
                        'password', 'passwd', 'pwd', 'credential', 'token',
                        'phpmyadmin', 'mutillidae', 'dvwa', 'bwapp', 'webgoat',  # Vulnerable web apps
                        'backup', 'old', 'dev', 'staging', 'beta'  # Development artifacts
                    ]

                    # Info-disclosure files that don't accept input (exclude from sqlmap)
                    info_disclosure_keywords = [
                        'phpinfo', 'info.php', 'test.php', 'debug.php'
                    ]

                    # Separate high-value targets from regular files
                    high_value_targets = []
                    regular_files = []

                    for php_file_tuple in php_files:
                        # php_files is now a list of tuples (url, status_code)
                        php_url, status_code = php_file_tuple
                        filename = php_url.lower()

                        # Skip 403 responses - access denied means we can't test for SQLi
                        if status_code == 403:
                            logger.info(f"[DEBUG] Skipping 403 Forbidden: {php_url}")
                            continue

                        # Skip Apache config files (.htaccess, .htpasswd, etc.)
                        # These match 'passwd' keyword but are not dynamic endpoints
                        path_part = php_url.split('/')[-1] if '/' in php_url else php_url
                        if path_part.startswith('.ht'):
                            logger.info(f"[DEBUG] Skipping Apache config file: {php_url}")
                            continue

                        # Skip info-disclosure files (no user input = no SQL injection)
                        is_info_disclosure = any(keyword in filename for keyword in info_disclosure_keywords)
                        if is_info_disclosure:
                            logger.info(f"[DEBUG] Skipping sqlmap for info-disclosure file: {php_url}")
                            continue

                        if any(keyword in filename for keyword in high_value_keywords):
                            high_value_targets.append(php_file_tuple)
                            logger.info(f"[DEBUG] Marked as high-value target: {php_url} (status: {status_code})")
                        else:
                            regular_files.append(php_file_tuple)
                            logger.info(f"[DEBUG] Marked as regular PHP file: {php_url} (status: {status_code})")

                    # Report findings
                    if high_value_targets:
                        logger.info(f"[DEBUG] Found {len(high_value_targets)} high-value PHP file(s) - targeting directly")
                        for target_url, status_code in high_value_targets[:5]:  # Show first 5
                            logger.info(f"[DEBUG]   → {target_url} (status: {status_code})")
                        if len(high_value_targets) > 5:
                            logger.info(f"[DEBUG]   ... and {len(high_value_targets) - 5} more")

                    # Show user feedback about what will be crawled
                    # Note: base_url is already extracted above at the start of gobuster handling
                    if regular_files:
                        logger.info(f"[DEBUG] Found {len(regular_files)} other PHP file(s) - will crawl base URL: {base_url}")

                    # === 1. Direct targeting for high-value PHP files ===
                    # Create individual sqlmap jobs for each high-value target
                    if high_value_targets:
                        from souleyez.engine.background import enqueue_job

                        # Deduplicate: Skip PHP files that are under high-value directories
                        # (the directory crawl will discover them anyway)
                        deduplicated_targets = []
                        skipped_targets = []

                        for target_tuple in high_value_targets:
                            target_url, status_code = target_tuple
                            # Check if this file is under any high-value directory
                            is_under_dir = False
                            for dir_url in high_value_dirs:
                                # Ensure dir_url ends with / for proper prefix matching
                                normalized_dir = dir_url if dir_url.endswith('/') else dir_url + '/'
                                if target_url.startswith(normalized_dir):
                                    is_under_dir = True
                                    skipped_targets.append(target_url)
                                    break

                            if not is_under_dir:
                                deduplicated_targets.append(target_tuple)

                        # Further deduplicate: Only scan ONE high-value target per base URL
                        # (since --crawl=2 will discover other files on same domain anyway)
                        base_url_targets = {}  # Maps base_url -> list of target tuples

                        for target_tuple in deduplicated_targets:
                            target_url, status_code = target_tuple
                            parsed = urlparse(target_url)
                            base = f"{parsed.scheme}://{parsed.netloc}/"
                            if base not in base_url_targets:
                                base_url_targets[base] = []
                            base_url_targets[base].append(target_tuple)

                        # Priority order for choosing best target per domain
                        # Higher priority = appears earlier in the list
                        priority_keywords = [
                            'login', 'admin', 'auth', 'signin', 'signup', 'register',
                            'payroll', 'payment', 'checkout', 'account', 'dashboard',
                            'manager', 'console', 'panel', 'upload', 'edit'
                        ]

                        final_targets = []
                        skipped_same_domain = []

                        for base, targets in base_url_targets.items():
                            if len(targets) == 1:
                                final_targets.append(targets[0])
                            else:
                                # Multiple targets on same domain - pick the best one
                                best_target = None
                                best_priority = 999

                                for target_tuple in targets:
                                    target_url, status_code = target_tuple
                                    filename = target_url.lower()
                                    # Find highest priority keyword in filename
                                    for i, keyword in enumerate(priority_keywords):
                                        if keyword in filename:
                                            if i < best_priority:
                                                best_priority = i
                                                best_target = target_tuple
                                            break

                                # If no priority keyword found, prioritize by status code
                                if best_target is None:
                                    # Status code priority: 200 > 401 > 403
                                    status_priority = {200: 1, 401: 2, 403: 3}
                                    best_target = min(targets, key=lambda t: status_priority.get(t[1], 999))

                                final_targets.append(best_target)
                                # Mark others as skipped
                                for target_tuple in targets:
                                    if target_tuple != best_target:
                                        target_url, _ = target_tuple
                                        skipped_same_domain.append(target_url)

                        # Log same-domain deduplication
                        if skipped_same_domain:
                            logger.info(f"Skipped {len(skipped_same_domain)} file(s) on same domain (crawl will discover them):")
                            for skipped in skipped_same_domain[:3]:
                                logger.info(f"  → {skipped}")
                            if len(skipped_same_domain) > 3:
                                logger.info(f"  ... and {len(skipped_same_domain) - 3} more")

                        # Log deduplication results
                        if skipped_targets:
                            logger.info(f"Skipped {len(skipped_targets)} PHP file(s) already covered by directory crawls:")
                            for skipped in skipped_targets[:3]:
                                logger.info(f"  → {skipped}")
                            if len(skipped_targets) > 3:
                                logger.info(f"  ... and {len(skipped_targets) - 3} more")

                        logger.info(f"Creating {len(final_targets)} direct sqlmap job(s) for high-value PHP files")

                        # Build command dictionaries for _enqueue_commands (which has duplicate detection)
                        sqlmap_commands = []
                        for target_tuple in final_targets:
                            target_url, status_code = target_tuple
                            sqlmap_commands.append({
                                'tool': 'sqlmap',
                                'target': target_url,
                                'args': ['--batch', '--crawl=2', '--risk=2', '--level=3', '--forms'],
                                'reason': f'high-value PHP file detected (status: {status_code})'
                            })
                            logger.info(f"  ✓ Queuing direct sqlmap test: {target_url} (status: {status_code})")

                        # Use _enqueue_commands which handles duplicate detection
                        job_ids.extend(self._enqueue_commands(sqlmap_commands, tool, engagement_id, base_url, parent_job_id=job.get('id')))

                # === 2. Crawl high-value directories (mutillidae, dvwa, etc.) ===
                # Check if gobuster found any high-value vulnerable app directories
                # NOTE: high_value_dirs already extracted above, no need to get again
                if high_value_dirs:
                    logger.info(f"Processing {len(high_value_dirs)} high-value directory/directories via chain rules")

                    # Use chain rules to determine which tools to run for each directory
                    for dir_url in high_value_dirs:
                        category_info = self._categorize_high_value_directory(dir_url)
                        category = category_info['category']

                        logger.info(f"  Directory: {dir_url}")
                        logger.info(f"  Category: {category}")

                        # Build context with category for chain rules to match
                        dir_context = {
                            'target': dir_url,
                            'directory_category': category,
                            'directory_url': dir_url,
                        }

                        # Evaluate chain rules - rules with category:X conditions will match
                        commands = self.evaluate_chains(tool, dir_context)

                        if commands:
                            for cmd in commands:
                                logger.info(f"    → {cmd['tool']} (rule #{cmd.get('rule_id', '?')})")

                            # Enqueue commands (already have rule_id set by evaluate_chains)
                            job_ids.extend(self._enqueue_commands(commands, tool, engagement_id, base_url, parent_job_id=job.get('id')))

                # === 3. Base URL crawl for regular files ===
                # Only crawl if there are regular files AND no high-value directories AND no high-value targets
                # (Prefer crawling from specific vulnerable apps or high-value files over base URL)
                elif php_files and not high_value_dirs:
                    # Check if there are regular (non-high-value) PHP files
                    # php_files is a list of tuples (url, status_code)
                    regular_files = [f for f in php_files if not any(kw in f[0].lower() for kw in high_value_keywords)]
                    if regular_files:
                        logger.info(f"No high-value directories or targets found, creating base URL crawl for {len(regular_files)} regular file(s)")
                        logger.info(f"  Base URL: {base_url}")

                        # Create context for SQLMap crawl (only once for the base URL)
                        crawl_context = {
                            'target': base_url,
                            'tool': tool,
                            'php_files': [base_url],  # Single item to trigger rule
                        }

                        # Evaluate chains - will match our crawl rule
                        crawl_commands = self.evaluate_chains(tool, crawl_context)

                        # Filter to only SQLMap commands
                        sqlmap_only = [
                            cmd for cmd in crawl_commands
                            if cmd['tool'] == 'sqlmap' and '--crawl' in ' '.join(cmd.get('args', []))
                        ]

                        logger.info(f"  Enqueueing {len(sqlmap_only)} base URL crawl job(s)")
                        # Enqueue single crawl job for base URL
                        job_ids.extend(self._enqueue_commands(
                            sqlmap_only,
                            tool,
                            engagement_id,
                            base_url,
                            parent_job_id=job.get('id')
                        ))

            # === Special handling for Gobuster: redirects + MySQL presence ===
            if tool == 'gobuster':
                # Query database for redirects found by this job
                # parse_results doesn't include the actual paths, only summary stats
                redirects_count = parse_results.get('redirects_found', 0)
                
                if redirects_count > 0:
                    # Query database for actual redirect URLs
                    from souleyez.storage.database import get_db
                    db = get_db()
                    conn = db.get_connection()
                    
                    # Get redirects from web_paths table
                    redirects_data = conn.execute("""
                        SELECT url, redirect, status_code
                        FROM web_paths
                        WHERE redirect IS NOT NULL
                        AND url LIKE ?
                        ORDER BY id DESC
                        LIMIT 10
                    """, (f"%{target}%",)).fetchall()
                    
                    conn.close()
                    
                    redirects = [{'url': r['url'], 'redirect': r['redirect']} for r in redirects_data]

                if redirects:
                    # Check if host has MySQL (port 3306 or service='mysql')
                    from souleyez.storage.hosts import HostManager
                    hm = HostManager()

                    # Get host from target URL (use target_url from parse_results if available)
                    actual_target = parse_results.get('target_url', target)
                    parsed_target = urlparse(actual_target)
                    host_ip = parsed_target.netloc.split(':')[0] if parsed_target.netloc else actual_target.split(':')[0]

                    # Get all services for this engagement
                    services = hm.get_all_services(engagement_id)

                    # Check if MySQL exists on this host
                    mysql_found = any(
                        (svc.get('ip_address') == host_ip or svc.get('hostname') == host_ip) and
                        (svc.get('port') == 3306 or svc.get('service_name', '').lower() == 'mysql')
                        for svc in services
                    )

                    if mysql_found:
                        print(f"  🔍 MySQL detected on {host_ip} - analyzing {len(redirects)} redirect(s)")

                        # Deduplicate redirects by destination IP
                        # Only test ONE redirect per unique destination IP
                        seen_ips = set()
                        unique_redirects = {}
                        skipped_count = 0
                        
                        # Known interesting directories worth testing even without parameters
                        interesting_dirs = [
                            'admin', 'phpmyadmin', 'mysql', 'database', 'db',
                            'api', 'auth', 'login', 'portal', 'dashboard',
                            'dav', 'webdav', 'twiki', 'wiki', 'cms',
                            'manager', 'console', 'control', 'panel', 'backup',
                            'upload', 'uploads', 'config', 'conf', 'settings'
                        ]
                        
                        for redirect_path in redirects[:10]:
                            redirect_url = redirect_path.get('redirect')

                            if not redirect_url:
                                continue

                            # Smart filtering: Skip boring directory redirects
                            # But allow interesting directories even without parameters
                            if redirect_url.endswith('/') and '?' not in redirect_url:
                                # Check if this is an interesting directory
                                is_interesting = any(
                                    pattern in redirect_url.lower() 
                                    for pattern in interesting_dirs
                                )
                                if not is_interesting:
                                    continue  # Skip boring directories only

                            # Parse redirect URL to extract destination IP
                            parsed_redirect = urlparse(redirect_url)
                            redirect_host = parsed_redirect.netloc.split(':')[0] if parsed_redirect.netloc else None
                            
                            # If we can't determine the host, skip it
                            if not redirect_host:
                                continue
                            
                            # Check if we've already queued a test for this destination IP
                            if redirect_host in seen_ips:
                                skipped_count += 1
                                continue
                            
                            # Mark this IP as seen and add to unique redirects
                            seen_ips.add(redirect_host)
                            unique_redirects[redirect_url] = redirect_path
                        
                        if skipped_count > 0:
                            print(f"  ⏭️  Skipped {skipped_count} duplicate redirect(s) (same destination IP)")
                        
                        if not unique_redirects:
                            print(f"  ℹ️  No suitable redirects to test")
                            return job_ids

                        print(f"  🎯 Testing {len(unique_redirects)} unique redirect destination(s)")

                        # Trigger SQLMap on unique redirect URLs
                        for redirect_url, redirect_path in unique_redirects.items():
                            # Check if redirect has query parameters (high chance of SQLi)
                            has_params = '?' in redirect_url
                            
                            # Only test redirects with parameters (like theHarvester does)
                            if not has_params:
                                continue

                            # Create context for SQLMap
                            sqlmap_context = {
                                'target': redirect_url,
                                'tool': tool,
                                'redirects_with_mysql': True,
                            }

                            # Evaluate chains - will match our new rule
                            sqlmap_commands = self.evaluate_chains(tool, sqlmap_context)

                            # Filter to only SQLMap commands
                            sqlmap_only = [
                                cmd for cmd in sqlmap_commands
                                if cmd['tool'] == 'sqlmap'
                            ]

                            # Update reason to be more specific
                            for cmd in sqlmap_only:
                                cmd['reason'] = (
                                    f"Auto-triggered by gobuster: Redirect with parameters found on host "
                                    f"with MySQL (port 3306)"
                                )

                            # Enqueue SQLMap jobs for this redirect
                            job_ids.extend(self._enqueue_commands(
                                sqlmap_only,
                                tool,
                                engagement_id,
                                redirect_url
                            ))

                # Don't return early - continue to general gobuster handling for ffuf rules
            # === END Gobuster special handling ===

            # === Special handling for Gobuster wildcard retry ===
            if tool == 'gobuster' and parse_results.get('wildcard_detected'):
                # Gobuster failed due to wildcard response, auto-retry with --exclude-length
                from souleyez.engine.background import enqueue_job
                from souleyez.log_config import get_logger
                logger = get_logger(__name__)

                # Check if this is already a retry (prevent loops)
                retry_attempt = job.get('metadata', {}).get('retry_attempt', 0)
                if retry_attempt >= 1:
                    logger.info(f"Gobuster wildcard: Already attempted retry, skipping")
                    return job_ids

                # Check if --exclude-length already in args (shouldn't happen, but safety check)
                job_args = job.get('args', [])
                if '--exclude-length' in job_args:
                    logger.info(f"Gobuster wildcard: --exclude-length already present, skipping retry")
                    return job_ids

                # Get the exclude length
                exclude_length = parse_results.get('exclude_length')
                if not exclude_length:
                    logger.warning(f"Gobuster wildcard detected but no exclude_length found")
                    return job_ids

                # Create retry job with --exclude-length
                retry_args = job_args + ['--exclude-length', exclude_length]

                logger.info(f"Gobuster wildcard: Auto-retrying with --exclude-length {exclude_length}")

                retry_job_id = enqueue_job(
                    tool='gobuster',
                    target=target,
                    args=retry_args,
                    label=f"Auto-retry: gobuster (wildcard {exclude_length}b)",
                    engagement_id=engagement_id,
                    parent_id=job.get('id'),
                    metadata={'retry_attempt': 1, 'retry_parent_job_id': job.get('id')}
                )

                job_ids.append(retry_job_id)
                logger.info(f"Created gobuster retry job #{retry_job_id}")

                return job_ids
            # === END Gobuster wildcard retry ===

            # === Special handling for ffuf: Create SQLMap jobs and recursive ffuf scans ===
            if tool == 'ffuf':
                from souleyez.engine.background import enqueue_job
                from souleyez.log_config import get_logger
                logger = get_logger(__name__)

                parameters_found = parse_results.get('parameters_found', [])
                if parameters_found:
                    logger.info(f"ffuf auto-chain: Found {len(parameters_found)} endpoint(s)")

                    # Status codes that indicate testable endpoints (for SQLMap)
                    testable_statuses = {200, 201, 204, 301, 302, 401, 403}
                    # Status 500 often indicates a directory/API that needs parameters
                    # These are candidates for recursive fuzzing
                    recursive_statuses = {500}

                    created_sqlmap_jobs = 0
                    created_ffuf_jobs = 0
                    max_sqlmap_jobs = 5  # Limit to prevent overwhelming the target
                    max_recursive_ffuf = 3  # Limit recursive depth/breadth

                    # Get current recursion depth from job metadata
                    current_depth = job.get('metadata', {}).get('ffuf_depth', 0)
                    max_depth = 2  # Don't go deeper than 2 levels (e.g., /rest/products/search)

                    for endpoint in parameters_found:
                        # Handle both dict and string formats
                        if isinstance(endpoint, dict):
                            endpoint_url = endpoint.get('url', '')
                            # Parser uses 'status_code', normalize both formats
                            status_code = endpoint.get('status_code') or endpoint.get('status', 0)
                        else:
                            endpoint_url = str(endpoint)
                            status_code = 200  # Assume testable if no status

                        if not endpoint_url:
                            continue

                        # === SQLMap for testable endpoints ===
                        if status_code in testable_statuses and created_sqlmap_jobs < max_sqlmap_jobs:
                            # For API endpoints without parameters, add test parameters
                            # This is critical for REST APIs that don't have HTML forms
                            test_url = endpoint_url
                            if '?' not in endpoint_url:
                                path_lower = endpoint_url.lower()
                                # Search/query endpoints need a query parameter
                                if any(kw in path_lower for kw in ['/search', '/find', '/query', '/filter']):
                                    test_url = endpoint_url + '?q=test'
                                # ID-based endpoints need an id parameter
                                elif any(kw in path_lower for kw in ['/user', '/product', '/item', '/order']):
                                    test_url = endpoint_url + '?id=1'

                            sqlmap_args = [
                                '-u', test_url,
                                '--batch',
                                '--level=2',
                                '--risk=2',
                                '--forms',
                                '--crawl=2',
                                '--threads=5'
                            ]

                            sqlmap_job_id = enqueue_job(
                                tool='sqlmap',
                                target=endpoint_url,
                                args=sqlmap_args,
                                label=f"Auto-chain: SQLMap testing {endpoint_url}",
                                engagement_id=engagement_id,
                                parent_id=job.get('id')
                            )

                            job_ids.append(sqlmap_job_id)
                            created_sqlmap_jobs += 1
                            logger.info(f"  SQLMap job #{sqlmap_job_id} for {endpoint_url}")

                        # === Recursive ffuf for 500 directories ===
                        elif status_code in recursive_statuses and current_depth < max_depth and created_ffuf_jobs < max_recursive_ffuf:
                            # 500 often means "this is a valid path but needs sub-path/params"
                            # Fuzz one level deeper: /rest/products → /rest/products/FUZZ
                            fuzz_url = endpoint_url.rstrip('/') + '/FUZZ'

                            ffuf_args = [
                                '-u', fuzz_url,
                                '-w', 'data/wordlists/api_endpoints.txt',
                                '-t', '5',
                                '-p', '0.02',
                                '-mc', '200,201,301,302,401,403,500'  # Include 500 for further recursion
                            ]

                            ffuf_job_id = enqueue_job(
                                tool='ffuf',
                                target=fuzz_url,
                                args=ffuf_args,
                                label=f"Auto-chain: ffuf recursive {endpoint_url}",
                                engagement_id=engagement_id,
                                parent_id=job.get('id'),
                                metadata={'ffuf_depth': current_depth + 1}
                            )

                            job_ids.append(ffuf_job_id)
                            created_ffuf_jobs += 1
                            logger.info(f"  ffuf recursive job #{ffuf_job_id} for {endpoint_url} (depth {current_depth + 1})")

                    if created_sqlmap_jobs > 0:
                        logger.info(f"ffuf auto-chain: Created {created_sqlmap_jobs} SQLMap job(s)")
                    if created_ffuf_jobs > 0:
                        logger.info(f"ffuf auto-chain: Created {created_ffuf_jobs} recursive ffuf job(s)")
            # === END ffuf special handling ===

            # Special handling for theHarvester: split into two phases
            if tool == 'theharvester':
                # PHASE 1: Always process the original domain for whois/dnsrecon
                # This runs REGARDLESS of whether URLs were found
                if target:  # target is the original domain (e.g., "vulnweb.com")
                    domain_context = {
                        'target': target,
                        'tool': tool,
                        'services': [],
                        'findings': [],
                        'hosts': [],
                        'domains': [target],  # Important: include original domain
                        'subdomains': parse_results.get('subdomains', []),
                    }

                    # Evaluate chains - will match whois/dnsrecon rules
                    domain_commands = self.evaluate_chains(tool, domain_context)

                    # Enqueue domain-specific tools (whois, dnsrecon)
                    job_ids.extend(self._enqueue_commands(
                        domain_commands,
                        tool,
                        engagement_id,
                        target,
                        parent_job_id=job.get('id')
                    ))

                # PHASE 2: Process URLs with parameters for SQLMap
                # PHASE 3: Process base URLs for Nuclei
                urls = parse_results.get('urls', [])

                if urls:
                    def normalize_url(url):
                        """
                        Normalize URL to prevent duplicate scans:
                        - Remove www. subdomain (www.example.com → example.com)
                        - Remove trailing dots from hostname (vulnweb.com. → vulnweb.com)
                        - Normalize scheme to https for dedup (http and https are same target)
                        - Keep the base URL structure

                        Returns: normalized_url
                        """
                        parsed = urlparse(url)
                        netloc = parsed.netloc.lower()

                        # Remove www. prefix
                        if netloc.startswith('www.'):
                            netloc = netloc[4:]

                        # Remove trailing dots from hostname
                        netloc = netloc.rstrip('.')

                        # Normalize scheme to https for dedup purposes
                        scheme = 'https'

                        # Rebuild URL with normalized netloc and scheme
                        normalized = f"{scheme}://{netloc}{parsed.path}"
                        if parsed.query:
                            normalized += f"?{parsed.query}"

                        return normalized
                    
                    # Normalize URLs for dedup but keep original URLs for scanning
                    # Format: {normalized_key: original_url}
                    url_map = {}

                    for url in urls:
                        normalized = normalize_url(url)
                        # Use normalized URL as key for dedup, but store ORIGINAL URL
                        if normalized not in url_map:
                            url_map[normalized] = url  # Keep original URL with original scheme

                    # Use original URLs for scanning (deduped by normalized key)
                    deduped_urls = list(url_map.values())
                    
                    print(f"  🔗 Normalized {len(urls)} URLs → {len(deduped_urls)} unique targets")
                    if len(urls) > len(deduped_urls):
                        print(f"  ⏭️  Skipped {len(urls) - len(deduped_urls)} duplicate variations")

                    # Track base URLs for web scanners (avoid duplicates)
                    scanned_base_urls = set()

                    for url in deduped_urls:
                        parsed_url = urlparse(url)

                        # SQLMap: Process URLs with query parameters
                        if parsed_url.query:
                            url_context = {
                                'target': url,
                                'tool': tool,
                                'services': [],
                                'findings': [],
                                'urls': [url],
                                'urls_with_params': [url],  # Triggers SQLMap rule
                            }

                            # Evaluate chains
                            url_commands = self.evaluate_chains(tool, url_context)

                            # CRITICAL: Filter to ONLY sqlmap
                            # whois/dnsrecon expect domains, not full URLs
                            sqlmap_only = [
                                cmd for cmd in url_commands
                                if cmd['tool'] == 'sqlmap'
                            ]

                            # Enqueue SQLMap jobs for this URL
                            job_ids.extend(self._enqueue_commands(
                                sqlmap_only,
                                tool,
                                engagement_id,
                                url,
                                parent_job_id=job.get('id')
                            ))

                        # Web scanners: Process base URLs only (no path or just "/")
                        path = parsed_url.path.rstrip('/')

                        if not path or path == '':
                            # Build base URL: scheme://netloc
                            base_url = f"{parsed_url.scheme}://{parsed_url.netloc}"

                            # Normalize for dedup check (removes trailing dots, normalizes scheme)
                            normalized_base = normalize_url(base_url)

                            # Skip if we already queued scans for this base URL
                            if normalized_base not in scanned_base_urls:
                                scanned_base_urls.add(normalized_base)

                                # Create context for web scanner chain rules
                                scan_context = {
                                    'target': base_url,
                                    'tool': tool,
                                    'base_urls': [base_url],  # Triggers nuclei/gobuster rules
                                }

                                # Evaluate chains
                                scan_commands = self.evaluate_chains(tool, scan_context)

                                # CRITICAL: Filter to ONLY web scanners
                                # whois/dnsrecon expect domains, not full URLs
                                web_scanner_only = [
                                    cmd for cmd in scan_commands
                                    if cmd['tool'] in ['nuclei', 'gobuster', 'sqlmap']
                                ]

                                # Enqueue web scanner jobs
                                job_ids.extend(self._enqueue_commands(
                                    web_scanner_only,
                                    tool,
                                    engagement_id,
                                    base_url,
                                    parent_job_id=job.get('id')
                                ))

                # PHASE 4: Auto-chain nmap scans for discovered IPs
                ips = parse_results.get('ips', [])

                if ips:
                    nmap_commands = []
                    for ip in ips:
                        nmap_commands.append({
                            'tool': 'nmap',
                            'target': ip,
                            'args': ['-F'],  # Fast scan (top 100 ports)
                            'reason': f'IP discovered by theHarvester, performing fast port scan'
                        })

                    # Enqueue all nmap jobs with parent_id
                    job_ids.extend(self._enqueue_commands(
                        nmap_commands,
                        tool,
                        engagement_id,
                        expected_target=None,  # IPs can be from any target
                        parent_job_id=job.get('id')
                    ))

                return job_ids

            # === Special handling for Impacket GetNPUsers → hashcat ===
            elif tool == 'impacket-getnpusers':
                asrep_hashes = parse_results.get('asrep_hashes', [])
                
                if asrep_hashes:
                    # Create temp file with AS-REP hashes for hashcat
                    import tempfile
                    import os
                    
                    # Create hash file (uses secure tempdir)
                    hash_file = tempfile.NamedTemporaryFile(
                        mode='w',
                        suffix='.txt',
                        prefix='asrep_hashes_',
                        delete=False
                    )
                    
                    for hash_entry in asrep_hashes:
                        hash_file.write(f"{hash_entry.get('hash', '')}\n")
                    
                    hash_file.close()

                    # Chain to hashcat with Kerberos 5 AS-REP mode (18200)
                    from souleyez.engine.background import enqueue_job

                    job_id = enqueue_job(
                        tool='hashcat',
                        target=hash_file.name,
                        args=['-m', '18200', '-a', '0', 'data/wordlists/top100.txt'],
                        label='CRACK_ASREP',
                        engagement_id=engagement_id,
                        parent_id=job.get('id')
                    )

                    job_ids.append(job_id)
                    
                    from souleyez.log_config import get_logger
                    logger = get_logger(__name__)
                    logger.info(f"Auto-chained: GetNPUsers → hashcat (cracking {len(asrep_hashes)} AS-REP hashes)")
                
                return job_ids
            
            # === Special handling for Impacket secretsdump → hashcat ===
            elif tool == 'impacket-secretsdump':
                hashes = parse_results.get('hashes', [])
                
                if hashes:
                    # Create temp file with NTLM hashes for hashcat
                    import tempfile
                    import os
                    
                    # Create hash file in format: username:hash (uses secure tempdir)
                    hash_file = tempfile.NamedTemporaryFile(
                        mode='w',
                        suffix='.txt',
                        prefix='ntlm_hashes_',
                        delete=False
                    )
                    
                    for hash_entry in hashes:
                        username = hash_entry.get('username', 'unknown')
                        nt_hash = hash_entry.get('nt_hash', '')
                        if nt_hash:
                            hash_file.write(f"{username}:{nt_hash}\n")
                    
                    hash_file.close()

                    # Chain to hashcat with NTLM mode (1000)
                    from souleyez.engine.background import enqueue_job

                    job_id = enqueue_job(
                        tool='hashcat',
                        target=hash_file.name,
                        args=['-m', '1000', '-a', '0', 'data/wordlists/top100.txt'],
                        label='CRACK_NTLM',
                        engagement_id=engagement_id,
                        parent_id=job.get('id')
                    )

                    job_ids.append(job_id)
                    
                    from souleyez.log_config import get_logger
                    logger = get_logger(__name__)
                    logger.info(f"Auto-chained: secretsdump → hashcat (cracking {len(hashes)} NTLM hashes)")
                
                return job_ids
            
            # === Special handling for CrackMapExec → secretsdump ===
            elif tool == 'crackmapexec':
                valid_admins = parse_results.get('valid_admin_credentials', [])
                findings = parse_results.get('findings', {})
                hosts = findings.get('hosts', [])
                
                if valid_admins and hosts:
                    from souleyez.engine.background import enqueue_job

                    # Use the first discovered host (typically the target)
                    target_host = hosts[0].get('ip', target) if hosts else target

                    for cred in valid_admins:
                        domain = cred.get('domain', '')
                        username = cred.get('username', '')
                        password = cred.get('password', '')

                        # Build secretsdump args in Impacket format: domain/username:password@host
                        if domain:
                            cred_str = f"{domain}/{username}:{password}@{target_host}"
                        else:
                            cred_str = f"{username}:{password}@{target_host}"

                        job_id = enqueue_job(
                            tool='impacket-secretsdump',
                            target=target_host,
                            args=[cred_str],
                            label='EXTRACT_CREDS',
                            engagement_id=engagement_id,
                            parent_id=job.get('id')
                        )

                        job_ids.append(job_id)
                    
                    from souleyez.log_config import get_logger
                    logger = get_logger(__name__)
                    logger.info(f"Auto-chained: CrackMapExec → secretsdump ({len(valid_admins)} admin credentials)")
                
                return job_ids

            # === Hydra special handling for username enumeration → password attack ===
            elif tool == 'hydra':
                usernames = parse_results.get('usernames', [])

                if usernames:
                    # Create temp file with validated usernames
                    import tempfile
                    import os

                    fd, usernames_file = tempfile.mkstemp(suffix='.txt', prefix='hydra_users_')
                    with os.fdopen(fd, 'w') as f:
                        f.write('\n'.join(usernames))

                    from souleyez.log_config import get_logger
                    logger = get_logger(__name__)
                    logger.info(f"Hydra found {len(usernames)} valid usernames, created temp file: {usernames_file}")

                    # Build context with usernames
                    context = {
                        'target': target,
                        'tool': tool,
                        'usernames': usernames,
                        'usernames_file': usernames_file,
                        'findings': parse_results.get('findings', []),
                    }

                    # Evaluate chains - will match has:usernames condition
                    commands = self.evaluate_chains(tool, context)

                    # Replace {usernames_file} placeholder in args
                    for cmd in commands:
                        cmd['args'] = [
                            usernames_file if arg == '{usernames_file}' else arg
                            for arg in cmd.get('args', [])
                        ]

                    job_ids.extend(self._enqueue_commands(commands, tool, engagement_id, target, parent_job_id=job.get('id')))

                return job_ids

            # All other tools (nmap, gobuster, nuclei, etc.)
            else:
                context = {
                    'target': target,
                    'tool': tool,
                    'services': [],
                    'findings': parse_results.get('findings', []),
                    'hosts': parse_results.get('hosts', []),
                    'writable_shares': parse_results.get('writable_shares', []),
                    'paths_with_params': parse_results.get('paths_with_params', []),
                    'domains': parse_results.get('domains', []),
                    'subdomains': parse_results.get('subdomains', []),
                }

                commands = self.evaluate_chains(tool, context)
                job_ids.extend(self._enqueue_commands(commands, tool, engagement_id, target, parent_job_id=job.get('id')))

                return job_ids

    def _categorize_high_value_directory(self, dir_url: str) -> Dict[str, Any]:
        """
        Categorize a high-value directory to determine appropriate security tools.

        Args:
            dir_url: Full URL to the directory (e.g., http://10.0.0.5/phpmyadmin)

        Returns:
            {
                'category': str,  # database_admin, wordpress, drupal, vulnerable_app, custom_php
                'description': str
            }

        Categories:
            - database_admin: phpMyAdmin, Adminer (CVE + default creds + exploits)
            - wordpress: WordPress sites (WPScan + CVE)
            - drupal: Drupal sites (CVE + exploits)
            - vulnerable_app: DVWA, Mutillidae, Juice Shop (aggressive SQLMap)
            - custom_php: Other PHP apps (standard SQLMap)
        """
        url_lower = dir_url.lower()

        # Database administration panels
        if 'phpmyadmin' in url_lower or 'adminer' in url_lower or 'pma' in url_lower:
            return {
                'category': 'database_admin',
                'description': 'Database administration panel (phpMyAdmin/Adminer)'
            }

        # WordPress
        if 'wordpress' in url_lower or 'wp-' in url_lower or '/wp/' in url_lower:
            return {
                'category': 'wordpress',
                'description': 'WordPress CMS'
            }

        # Drupal
        if 'drupal' in url_lower:
            return {
                'category': 'drupal',
                'description': 'Drupal CMS'
            }

        # Intentionally vulnerable applications (for testing/training)
        vulnerable_app_keywords = [
            'mutillidae', 'dvwa', 'bwapp', 'webgoat',
            'juice', 'juice-shop', 'juiceshop',
            'hackazon', 'pentesterlab'
        ]
        if any(kw in url_lower for kw in vulnerable_app_keywords):
            return {
                'category': 'vulnerable_app',
                'description': 'Intentionally vulnerable web application'
            }

        # Default: Custom PHP application
        return {
            'category': 'custom_php',
            'description': 'Custom PHP application'
        }

    def _enqueue_commands(self, commands: List[Dict[str, Any]], source_tool: str,
                          engagement_id: Optional[int], expected_target: str = None, parent_job_id: int = None) -> List[int]:
        """
        Helper to enqueue commands while avoiding duplicates.

        Args:
            commands: List of command dicts to enqueue
            source_tool: Tool that triggered the chain
            engagement_id: Engagement ID to use
            expected_target: Expected target (for logging)
            parent_job_id: ID of parent job that triggered this chain

        Returns:
            List of new job IDs created
        """
        def _normalize_url(url):
            """Normalize URL for deduplication.
            
            - Remove trailing dots from hostname (vulnweb.com. -> vulnweb.com)
            - Normalize scheme to https (http -> https for dedup purposes)
            - Remove default ports (:80 for http, :443 for https)
            """
            if not url or '://' not in url:
                return url
            try:
                from urllib.parse import urlparse, urlunparse
                parsed = urlparse(url)
                
                # Remove trailing dots from hostname
                hostname = parsed.netloc.rstrip('.')
                
                # Remove default ports (only exact matches for default ports)
                if parsed.scheme == 'http' and hostname.endswith(':80'):
                    hostname = hostname[:-3]
                elif parsed.scheme == 'https' and hostname.endswith(':443'):
                    hostname = hostname[:-4]
                
                # Normalize scheme to https for dedup (http://x and https://x are same target)
                scheme = 'https'
                
                return urlunparse(parsed._replace(scheme=scheme, netloc=hostname))
            except Exception:
                return url
        
        job_ids = []

        # Time window for blocking duplicate auto-chains (5 minutes)
        DUPLICATE_WINDOW_SECONDS = 300

        # === PRE-DEDUPLICATION: Remove duplicate commands within the same batch ===
        # This prevents duplicate rules (e.g., GENERAL + CTF with same condition) from
        # creating multiple identical jobs
        seen_keys = set()
        unique_commands = []
        for cmd in commands:
            cmd_target = cmd.get('target', '')
            # Normalize target for deduplication
            normalized_target = _normalize_url(cmd_target)

            # Normalize any URLs in args for deduplication key
            normalized_args = tuple(
                _normalize_url(arg) if isinstance(arg, str) and '://' in arg else arg
                for arg in cmd.get('args', [])
            )

            # Build unique key based on tool + target + args
            if cmd.get('tool') == 'msf_auxiliary' and cmd.get('args'):
                # For msf_auxiliary, key on tool + target + module (first arg)
                key = f"{cmd['tool']}|{normalized_target}|{cmd.get('args', [''])[0]}"
            else:
                # For other tools, key on tool + target + full args (normalized)
                key = f"{cmd['tool']}|{normalized_target}|{normalized_args}"

            if key not in seen_keys:
                seen_keys.add(key)
                unique_commands.append(cmd)
            else:
                # Skip duplicate command from same batch
                print(f"  ⏭️  Skipping duplicate command in batch: {cmd['tool']} for {cmd_target}")

        commands = unique_commands
        # === END PRE-DEDUPLICATION ===

        try:
            from souleyez.engine.background import enqueue_job, list_jobs, _lock

            for cmd in commands:
                cmd_target = cmd.get('target', '')
                
                # Normalize target URL for deduplication (removes trailing dots)
                normalized_target = _normalize_url(cmd_target)
                
                # Create a unique key for this job (tool + target + engagement)
                # For msf_auxiliary, include the module in the key since same tool can run different modules
                if cmd['tool'] == 'msf_auxiliary' and cmd.get('args'):
                    # Include first arg (the module name) in the key
                    module = cmd['args'][0] if cmd['args'] else ''
                    job_key = f"{cmd['tool']}|{normalized_target}|{engagement_id}|{module}"
                else:
                    job_key = f"{cmd['tool']}|{normalized_target}|{engagement_id}"
                
                # ATOMIC: Check and create inside lock to prevent race conditions
                with _lock:
                    # Re-read jobs INSIDE lock for latest state
                    existing_jobs = list_jobs(limit=1000)

                    # Check if similar job already exists (same tool + target + engagement, not error status)
                    # For msf_auxiliary and sqlmap, also check if the args match (different phases = different jobs)
                    # NOTE: Block if job is queued/running OR recently completed (< 5 min) with same args
                    similar_exists = False
                    for existing_job in existing_jobs:
                        # === DOMAIN-AWARE DUPLICATE DETECTION FOR WEB TOOLS ===
                        # Check for web scanning tools targeting same domain (even if different IPs)
                        # This prevents redundant scans when theHarvester creates separate nmap jobs per IP
                        if cmd['tool'] in ['nuclei', 'gobuster']:
                            # Check if both jobs target same tool and engagement
                            if (existing_job.get('tool') == cmd['tool']
                                and existing_job.get('engagement_id') == engagement_id):

                                # Check domain context from metadata
                                existing_domain = existing_job.get('metadata', {}).get('domain_context')
                                cmd_domain = cmd.get('metadata', {}).get('domain_context')

                                # If both have domain context and they match, this might be a duplicate
                                if existing_domain and cmd_domain and existing_domain == cmd_domain:
                                    # Check if args match (same scan type)
                                    existing_args = existing_job.get('args', [])
                                    cmd_args = cmd.get('args', [])

                                    if existing_args == cmd_args:
                                        status = existing_job.get('status')

                                        # Block if queued or running
                                        if status in ['queued', 'running']:
                                            similar_exists = True
                                            print(f"  ⏭️  Skipping {cmd['tool']} for {cmd_target}: domain '{cmd_domain}' already being scanned by job #{existing_job['id']} on {existing_job.get('target')} ({status})")
                                            break

                                        # Block if recently completed (< 5 min)
                                        elif status == 'done':
                                            finished_at = existing_job.get('finished_at')
                                            if finished_at:
                                                try:
                                                    finished_time = datetime.fromisoformat(finished_at.replace('Z', '+00:00'))
                                                    current_time = datetime.now(finished_time.tzinfo) if finished_time.tzinfo else datetime.now()
                                                    time_delta = (current_time - finished_time).total_seconds()

                                                    if time_delta < DUPLICATE_WINDOW_SECONDS:
                                                        similar_exists = True
                                                        minutes_ago = int(time_delta // 60)
                                                        seconds_ago = int(time_delta % 60)
                                                        time_str = f"{minutes_ago}m {seconds_ago}s ago" if minutes_ago > 0 else f"{seconds_ago}s ago"
                                                        print(f"  ⏭️  Skipping {cmd['tool']} for {cmd_target}: domain '{cmd_domain}' scanned {time_str} by job #{existing_job['id']} (duplicate)")
                                                        break
                                                except Exception:
                                                    # If timestamp parsing fails, don't block (fail open)
                                                    pass
                            # Continue to next existing_job if domain doesn't match or args differ
                            continue

                        # === STANDARD TARGET-BASED DUPLICATE DETECTION (existing code) ===
                        if (existing_job.get('tool') == cmd['tool']
                            and _normalize_url(existing_job.get('target')) == normalized_target
                            and existing_job.get('engagement_id') == engagement_id):

                            status = existing_job.get('status')

                            # Check if job is active (queued/running)
                            is_active = status in ['queued', 'running']

                            # Check if job recently completed with same args (prevent immediate duplicates)
                            is_recent_duplicate = False
                            if status == 'done':
                                finished_at = existing_job.get('finished_at')
                                if finished_at:
                                    try:
                                        # Parse ISO timestamp: "2025-11-11T23:34:56"
                                        finished_time = datetime.fromisoformat(finished_at.replace('Z', '+00:00'))
                                        current_time = datetime.now(finished_time.tzinfo) if finished_time.tzinfo else datetime.now()
                                        time_delta = (current_time - finished_time).total_seconds()

                                        # Only block if same args AND finished < 5 min ago
                                        if time_delta < DUPLICATE_WINDOW_SECONDS:
                                            existing_args = existing_job.get('args', [])
                                            cmd_args = cmd.get('args', [])
                                            if existing_args == cmd_args:
                                                is_recent_duplicate = True
                                                minutes_ago = int(time_delta // 60)
                                                seconds_ago = int(time_delta % 60)
                                                time_str = f"{minutes_ago}m {seconds_ago}s ago" if minutes_ago > 0 else f"{seconds_ago}s ago"
                                                print(f"  ⏭️  Skipping {cmd['tool']} for {cmd_target}: job #{existing_job['id']} completed {time_str} (duplicate)")
                                    except Exception:
                                        # If timestamp parsing fails, don't block (fail open)
                                        pass

                            # Block if active OR recent duplicate
                            if is_active or is_recent_duplicate:
                                if is_active:
                                    # For msf_auxiliary, check if module matches
                                    if cmd['tool'] == 'msf_auxiliary':
                                        existing_args = existing_job.get('args', [])
                                        cmd_args = cmd.get('args', [])
                                        if existing_args and cmd_args and existing_args[0] == cmd_args[0]:
                                            similar_exists = True
                                            print(f"  ⏭️  Skipping {cmd['tool']} for {cmd_target}: similar job #{existing_job['id']} already exists ({existing_job['status']})")
                                            break

                                    # For sqlmap, gobuster: check if args match (allow different ports/phases)
                                    elif cmd['tool'] in ['sqlmap', 'gobuster']:
                                        existing_args = existing_job.get('args', [])
                                        cmd_args = cmd.get('args', [])
                                        # Compare args - if they're the same, it's a duplicate
                                        if existing_args == cmd_args:
                                            similar_exists = True
                                            print(f"  ⏭️  Skipping {cmd['tool']} for {cmd_target}: similar job #{existing_job['id']} already exists ({existing_job['status']})")
                                            break
                                        # If args are different, allow it (different SQLMap phase)

                                    # For quick lookup tools (whois, dnsrecon), skip 5-min duplicate check
                                    # They're fast and each theHarvester run should trigger them
                                    elif cmd['tool'] in ['whois', 'dnsrecon']:
                                        # Only block if currently running (not if just completed)
                                        pass  # Don't set similar_exists, let it run

                                    # For all other tools, consider it a duplicate
                                    else:
                                        similar_exists = True
                                        print(f"  ⏭️  Skipping {cmd['tool']} for {cmd_target}: similar job #{existing_job['id']} already exists ({existing_job['status']})")
                                        break
                                else:
                                    # is_recent_duplicate (already printed message above)
                                    similar_exists = True
                                    break

                    # Create job if no duplicate found (still inside lock!)
                    if not similar_exists:
                        # Resolve wordlist paths in args before enqueueing
                        from souleyez.wordlists import resolve_args_wordlists
                        resolved_args = resolve_args_wordlists(cmd['args'])

                        # Check if approval mode is enabled
                        if self.is_approval_mode():
                            # Add to pending queue for user approval
                            from souleyez.core.pending_chains import add_pending_chain
                            print(f"  📋 Pending approval: {cmd['tool']} for {cmd_target}")
                            chain_id = add_pending_chain(
                                parent_job_id=parent_job_id,
                                rule_description=cmd.get('reason', f"Auto-chain from {source_tool}"),
                                tool=cmd['tool'],
                                target=cmd_target,
                                args=resolved_args,
                                priority=cmd.get('priority', 5),
                                engagement_id=engagement_id,
                                metadata=cmd.get('metadata')
                            )
                            # Return chain ID as negative to indicate pending (not executed)
                            job_ids.append(-chain_id)
                        else:
                            # Auto mode: enqueue immediately
                            print(f"  🔗 Chaining {cmd['tool']} for {cmd_target}: {cmd['reason']}")
                            # enqueue_job will acquire _lock again (nested lock is safe - same thread)
                            job_id = enqueue_job(
                                tool=cmd['tool'],
                                target=cmd_target,
                                args=resolved_args,
                                label=source_tool,
                                engagement_id=engagement_id,
                                parent_id=parent_job_id,
                                reason=cmd.get('reason', f"Auto-chain from {source_tool}"),
                                metadata=cmd.get('metadata'),  # Pass through deduplication metadata
                                rule_id=cmd.get('rule_id')  # Pass rule ID for tracking
                            )
                            job_ids.append(job_id)

                # Lock released here - next iteration gets fresh lock

        except Exception as e:
            print(f"  ⚠️  Failed to enqueue chained jobs: {e}")

        return job_ids

    def get_chain_summary(self) -> str:
        """Get a summary of all configured chain rules."""
        summary = "Tool Chaining Rules:\n"
        summary += "=" * 60 + "\n\n"

        # Group by trigger tool
        by_trigger = {}
        for rule in self.rules:
            if rule.trigger_tool not in by_trigger:
                by_trigger[rule.trigger_tool] = []
            by_trigger[rule.trigger_tool].append(rule)

        for trigger, rules in sorted(by_trigger.items()):
            summary += f"{trigger.upper()}:\n"
            for rule in sorted(rules, key=lambda r: r.priority, reverse=True):
                status = "✓" if rule.enabled else "✗"
                summary += f"  [{status}] Priority {rule.priority}: {rule.trigger_condition} → {rule.target_tool}\n"
                summary += f"      {rule.description}\n"
            summary += "\n"

        return summary


# Global instance
_chaining = None


def get_tool_chaining() -> ToolChaining:
    """Get the global tool chaining instance."""
    global _chaining
    if _chaining is None:
        _chaining = ToolChaining()
    return _chaining
