#!/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}


# Technology to Nuclei tags mapping
# Maps detected products/technologies to relevant nuclei template tags
TECH_TO_NUCLEI_TAGS = {
    # Web Servers
    'apache': ['apache', 'cve'],
    'nginx': ['nginx', 'cve'],
    'iis': ['iis', 'microsoft', 'cve'],
    'tomcat': ['tomcat', 'apache', 'cve'],
    'lighttpd': ['lighttpd', 'cve'],
    'caddy': ['caddy', 'cve'],

    # CMS / Frameworks
    'wordpress': ['wordpress', 'wp-plugin', 'cve'],
    'drupal': ['drupal', 'drupalgeddon', 'cve'],
    'joomla': ['joomla', 'cve'],
    'magento': ['magento', 'cve'],
    'typo3': ['typo3', 'cve'],
    'laravel': ['laravel', 'php', 'cve'],
    'django': ['django', 'python', 'cve'],
    'flask': ['flask', 'python', 'cve'],
    'rails': ['rails', 'ruby', 'cve'],
    'express': ['express', 'nodejs', 'cve'],
    'spring': ['spring', 'java', 'cve'],
    'struts': ['struts', 'java', 'cve'],

    # Languages / Runtimes
    'php': ['php', 'cve'],
    'node': ['nodejs', 'cve'],
    'python': ['python', 'cve'],
    'java': ['java', 'cve'],
    'asp.net': ['aspnet', 'microsoft', 'cve'],
    'coldfusion': ['coldfusion', 'adobe', 'cve'],

    # Databases
    'mysql': ['mysql', 'cve'],
    'postgresql': ['postgres', 'cve'],
    'mongodb': ['mongodb', 'cve'],
    'redis': ['redis', 'cve'],
    'elasticsearch': ['elasticsearch', 'cve'],
    'couchdb': ['couchdb', 'cve'],

    # Proxies / Load Balancers
    'haproxy': ['haproxy', 'cve'],
    'varnish': ['varnish', 'cve'],
    'squid': ['squid', 'cve'],
    'traefik': ['traefik', 'cve'],

    # Admin Panels
    'phpmyadmin': ['phpmyadmin', 'exposure', 'cve'],
    'webmin': ['webmin', 'cve'],
    'cpanel': ['cpanel', 'cve'],
    'plesk': ['plesk', 'cve'],
    'cockpit': ['cockpit', 'cve'],

    # Mail Servers
    'postfix': ['postfix', 'cve'],
    'exim': ['exim', 'cve'],
    'dovecot': ['dovecot', 'cve'],
    'exchange': ['exchange', 'microsoft', 'cve'],
    'zimbra': ['zimbra', 'cve'],

    # CI/CD / DevOps
    'jenkins': ['jenkins', 'cve'],
    'gitlab': ['gitlab', 'cve'],
    'grafana': ['grafana', 'cve'],
    'prometheus': ['prometheus', 'cve'],
    'kubernetes': ['kubernetes', 'k8s', 'cve'],
    'docker': ['docker', 'cve'],

    # Network / Router
    'cisco': ['cisco', 'cve'],
    'juniper': ['juniper', 'cve'],
    'mikrotik': ['mikrotik', 'routeros', 'cve'],
    'netgear': ['netgear', 'cve'],
    'dlink': ['dlink', 'cve'],
    'tplink': ['tplink', 'cve'],
    'asus': ['asus', 'cve'],
    'ubiquiti': ['ubiquiti', 'unifi', 'cve'],
    'fortinet': ['fortinet', 'fortigate', 'cve'],
    'pfsense': ['pfsense', 'cve'],
    'openwrt': ['openwrt', 'cve'],
}


def detect_nuclei_tags(services: List[Dict[str, Any]], os_info: str = '') -> str:
    """
    Detect appropriate nuclei tags based on discovered services and technologies.

    Analyzes service products, versions, and banners to determine which nuclei
    template tags would be most relevant for scanning this target.

    Args:
        services: List of service dicts from nmap parser with 'product', 'raw_version', etc.
        os_info: OS detection string from nmap (optional)

    Returns:
        Comma-separated string of nuclei tags, or empty string if no tech detected
        Example: "apache,php,cve" or "wordpress,wp-plugin,cve"
    """
    detected_tags = set()

    # Always include these base tags for web scanning
    base_tags = {'exposure', 'misconfiguration'}

    for service in services:
        product = (service.get('product') or '').lower()
        raw_version = (service.get('raw_version') or '').lower()
        service_name = (service.get('service_name') or '').lower()

        # Combine all text for matching
        combined = f"{product} {raw_version} {service_name}"

        # Check each tech pattern
        for tech, tags in TECH_TO_NUCLEI_TAGS.items():
            if tech in combined:
                detected_tags.update(tags)

        # Special detection patterns
        if 'wordpress' in combined or 'wp-' in combined:
            detected_tags.update(['wordpress', 'wp-plugin', 'cve'])
        if 'drupal' in combined:
            detected_tags.update(['drupal', 'drupalgeddon', 'cve'])
        if 'joomla' in combined:
            detected_tags.update(['joomla', 'cve'])

    # Check OS info for additional context
    os_lower = os_info.lower()
    if 'windows' in os_lower:
        detected_tags.update(['windows', 'microsoft'])
    if 'linux' in os_lower:
        detected_tags.add('linux')

    # If we detected specific tech, return those tags
    if detected_tags - base_tags:  # Has tags beyond base
        # Prioritize: CVE always, then limit to most relevant
        final_tags = detected_tags | {'cve'}
        return ','.join(sorted(final_tags))

    # No specific tech detected - return empty to trigger default behavior
    return ''


@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 = '/'

        # Detect nuclei tags based on discovered technologies
        nuclei_tags = ''
        if self.target_tool == 'nuclei' and '{nuclei_tags}' in str(args):
            services = context.get('services', [])
            os_info = context.get('os_info', '')
            nuclei_tags = detect_nuclei_tags(services, os_info)

        # 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)
                   .replace('{nuclei_tags}', nuclei_tags)
                for arg in args]

        # For nuclei: if no tech-specific tags detected, remove empty -tags arg
        if self.target_tool == 'nuclei' and not nuclei_tags:
            # Remove -tags and its empty value
            new_args = []
            skip_next = False
            for arg in args:
                if skip_next:
                    skip_next = False
                    continue
                if arg == '-tags':
                    skip_next = True
                    continue
                new_args.append(arg)
            args = new_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
            # Uses {nuclei_tags} placeholder to auto-detect tech and run relevant templates
            ChainRule(
                trigger_tool='nmap',
                trigger_condition='service:http',
                target_tool='nuclei',
                priority=9,
                args_template=['-tags', '{nuclei_tags}', '-severity', 'critical,high', '-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'],
                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=['-tags', '{nuclei_tags}', '-severity', 'critical,high', '-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'],
                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'],
                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
