"""
AUTARCH iPhone Exploitation Manager
Local USB device access via libimobiledevice tools.
Device info, screenshots, syslog, app management, backup extraction,
filesystem mounting, provisioning profiles, port forwarding.
"""

import os
import re
import json
import time
import shutil
import sqlite3
import subprocess
import plistlib
from pathlib import Path
from typing import Optional, Dict, Any

from core.paths import get_data_dir, find_tool


class IPhoneExploitManager:
    """All iPhone USB exploitation logic using libimobiledevice."""

    # Tools we look for
    TOOLS = [
        'idevice_id', 'ideviceinfo', 'idevicepair', 'idevicename',
        'idevicedate', 'idevicescreenshot', 'idevicesyslog',
        'idevicecrashreport', 'idevicediagnostics', 'ideviceinstaller',
        'idevicebackup2', 'ideviceprovision', 'idevicedebug',
        'ideviceactivation', 'ifuse', 'iproxy',
    ]

    def __init__(self):
        self._base = get_data_dir() / 'iphone_exploit'
        for sub in ('backups', 'screenshots', 'recon', 'apps', 'crash_reports'):
            (self._base / sub).mkdir(parents=True, exist_ok=True)
        # Find available tools
        self._tools = {}
        for name in self.TOOLS:
            path = find_tool(name)
            if not path:
                path = shutil.which(name)
            if path:
                self._tools[name] = path

    def _udid_dir(self, category, udid):
        d = self._base / category / udid
        d.mkdir(parents=True, exist_ok=True)
        return d

    def _run(self, tool_name, args, timeout=30):
        """Run a libimobiledevice tool."""
        path = self._tools.get(tool_name)
        if not path:
            return '', f'{tool_name} not found', 1
        cmd = [path] + args
        try:
            result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
            return result.stdout, result.stderr, result.returncode
        except subprocess.TimeoutExpired:
            return '', 'Command timed out', 1
        except Exception as e:
            return '', str(e), 1

    def _run_udid(self, tool_name, udid, args, timeout=30):
        """Run tool with -u UDID flag."""
        return self._run(tool_name, ['-u', udid] + args, timeout=timeout)

    def get_status(self):
        """Get availability of libimobiledevice tools."""
        available = {name: bool(path) for name, path in self._tools.items()}
        total = len(self.TOOLS)
        found = sum(1 for v in available.values() if v)
        return {
            'tools': available,
            'total': total,
            'found': found,
            'ready': found >= 3,  # At minimum need idevice_id, ideviceinfo, idevicepair
        }

    # ── Device Management ────────────────────────────────────────────

    def list_devices(self):
        """List connected iOS devices."""
        stdout, stderr, rc = self._run('idevice_id', ['-l'])
        if rc != 0:
            return []
        devices = []
        for line in stdout.strip().split('\n'):
            udid = line.strip()
            if udid:
                info = self.device_info_brief(udid)
                devices.append({
                    'udid': udid,
                    'name': info.get('DeviceName', ''),
                    'model': info.get('ProductType', ''),
                    'ios_version': info.get('ProductVersion', ''),
                })
        return devices

    def device_info(self, udid):
        """Get full device information."""
        stdout, stderr, rc = self._run_udid('ideviceinfo', udid, [])
        if rc != 0:
            return {'error': stderr or 'Cannot get device info'}
        info = {}
        for line in stdout.split('\n'):
            if ':' in line:
                key, _, val = line.partition(':')
                info[key.strip()] = val.strip()
        return info

    def device_info_brief(self, udid):
        """Get key device info (name, model, iOS version)."""
        keys = ['DeviceName', 'ProductType', 'ProductVersion', 'BuildVersion',
                'SerialNumber', 'UniqueChipID', 'WiFiAddress', 'BluetoothAddress']
        info = {}
        for key in keys:
            stdout, _, rc = self._run_udid('ideviceinfo', udid, ['-k', key])
            if rc == 0:
                info[key] = stdout.strip()
        return info

    def device_info_domain(self, udid, domain):
        """Get device info for a specific domain."""
        stdout, stderr, rc = self._run_udid('ideviceinfo', udid, ['-q', domain])
        if rc != 0:
            return {'error': stderr}
        info = {}
        for line in stdout.split('\n'):
            if ':' in line:
                key, _, val = line.partition(':')
                info[key.strip()] = val.strip()
        return info

    def pair_device(self, udid):
        """Pair with device (requires user trust on device)."""
        stdout, stderr, rc = self._run_udid('idevicepair', udid, ['pair'])
        return {'success': rc == 0, 'output': (stdout or stderr).strip()}

    def unpair_device(self, udid):
        """Unpair from device."""
        stdout, stderr, rc = self._run_udid('idevicepair', udid, ['unpair'])
        return {'success': rc == 0, 'output': (stdout or stderr).strip()}

    def validate_pair(self, udid):
        """Check if device is properly paired."""
        stdout, stderr, rc = self._run_udid('idevicepair', udid, ['validate'])
        return {'success': rc == 0, 'paired': rc == 0, 'output': (stdout or stderr).strip()}

    def get_name(self, udid):
        """Get device name."""
        stdout, stderr, rc = self._run_udid('idevicename', udid, [])
        return {'success': rc == 0, 'name': stdout.strip()}

    def set_name(self, udid, name):
        """Set device name."""
        stdout, stderr, rc = self._run_udid('idevicename', udid, [name])
        return {'success': rc == 0, 'output': (stdout or stderr).strip()}

    def get_date(self, udid):
        """Get device date/time."""
        stdout, stderr, rc = self._run_udid('idevicedate', udid, [])
        return {'success': rc == 0, 'date': stdout.strip()}

    def set_date(self, udid, timestamp):
        """Set device date (epoch timestamp)."""
        stdout, stderr, rc = self._run_udid('idevicedate', udid, ['-s', str(timestamp)])
        return {'success': rc == 0, 'output': (stdout or stderr).strip()}

    def restart_device(self, udid):
        """Restart device."""
        stdout, stderr, rc = self._run_udid('idevicediagnostics', udid, ['restart'])
        return {'success': rc == 0, 'output': (stdout or stderr).strip()}

    def shutdown_device(self, udid):
        """Shutdown device."""
        stdout, stderr, rc = self._run_udid('idevicediagnostics', udid, ['shutdown'])
        return {'success': rc == 0, 'output': (stdout or stderr).strip()}

    def sleep_device(self, udid):
        """Put device to sleep."""
        stdout, stderr, rc = self._run_udid('idevicediagnostics', udid, ['sleep'])
        return {'success': rc == 0, 'output': (stdout or stderr).strip()}

    # ── Screenshot & Syslog ──────────────────────────────────────────

    def screenshot(self, udid):
        """Take a screenshot."""
        out_dir = self._udid_dir('screenshots', udid)
        filename = f'screen_{int(time.time())}.png'
        filepath = str(out_dir / filename)
        stdout, stderr, rc = self._run_udid('idevicescreenshot', udid, [filepath])
        if rc == 0 and os.path.exists(filepath):
            return {'success': True, 'path': filepath, 'size': os.path.getsize(filepath)}
        return {'success': False, 'error': (stderr or stdout).strip()}

    def syslog_dump(self, udid, duration=5):
        """Capture syslog for a duration."""
        out_dir = self._udid_dir('recon', udid)
        logfile = str(out_dir / f'syslog_{int(time.time())}.txt')
        try:
            proc = subprocess.Popen(
                [self._tools.get('idevicesyslog', 'idevicesyslog'), '-u', udid],
                stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
            )
            time.sleep(duration)
            proc.terminate()
            stdout, _ = proc.communicate(timeout=3)
            with open(logfile, 'w') as f:
                f.write(stdout)
            return {'success': True, 'path': logfile, 'lines': len(stdout.split('\n'))}
        except Exception as e:
            return {'success': False, 'error': str(e)}

    def syslog_grep(self, udid, pattern, duration=5):
        """Capture syslog and grep for pattern (passwords, tokens, etc)."""
        result = self.syslog_dump(udid, duration=duration)
        if not result['success']:
            return result
        matches = []
        try:
            with open(result['path']) as f:
                for line in f:
                    if re.search(pattern, line, re.IGNORECASE):
                        matches.append(line.strip())
        except Exception:
            pass
        return {'success': True, 'matches': matches, 'count': len(matches), 'pattern': pattern}

    def crash_reports(self, udid):
        """Pull crash reports from device."""
        out_dir = self._udid_dir('crash_reports', udid)
        stdout, stderr, rc = self._run_udid('idevicecrashreport', udid,
                                            ['-e', str(out_dir)], timeout=60)
        if rc == 0:
            files = list(out_dir.iterdir()) if out_dir.exists() else []
            return {'success': True, 'output_dir': str(out_dir),
                    'count': len(files), 'output': stdout.strip()}
        return {'success': False, 'error': (stderr or stdout).strip()}

    # ── App Management ───────────────────────────────────────────────

    def list_apps(self, udid, app_type='user'):
        """List installed apps. type: user, system, all."""
        flags = {
            'user': ['-l', '-o', 'list_user'],
            'system': ['-l', '-o', 'list_system'],
            'all': ['-l', '-o', 'list_all'],
        }
        args = flags.get(app_type, ['-l'])
        stdout, stderr, rc = self._run_udid('ideviceinstaller', udid, args, timeout=30)
        if rc != 0:
            return {'success': False, 'error': (stderr or stdout).strip(), 'apps': []}
        apps = []
        for line in stdout.strip().split('\n'):
            line = line.strip()
            if not line or line.startswith('CFBundle') or line.startswith('Total'):
                continue
            # Format: com.example.app, "App Name", "1.0"
            parts = line.split(',', 2)
            if parts:
                app = {'bundle_id': parts[0].strip().strip('"')}
                if len(parts) >= 2:
                    app['name'] = parts[1].strip().strip('"')
                if len(parts) >= 3:
                    app['version'] = parts[2].strip().strip('"')
                apps.append(app)
        return {'success': True, 'apps': apps, 'count': len(apps)}

    def install_app(self, udid, ipa_path):
        """Install an IPA on device."""
        if not os.path.isfile(ipa_path):
            return {'success': False, 'error': f'File not found: {ipa_path}'}
        stdout, stderr, rc = self._run_udid('ideviceinstaller', udid,
                                            ['-i', ipa_path], timeout=120)
        return {'success': rc == 0, 'output': (stdout or stderr).strip()}

    def uninstall_app(self, udid, bundle_id):
        """Uninstall an app by bundle ID."""
        stdout, stderr, rc = self._run_udid('ideviceinstaller', udid,
                                            ['-U', bundle_id], timeout=30)
        return {'success': rc == 0, 'bundle_id': bundle_id, 'output': (stdout or stderr).strip()}

    # ── Backup & Data Extraction ─────────────────────────────────────

    def create_backup(self, udid, encrypted=False, password=''):
        """Create a full device backup."""
        backup_dir = str(self._base / 'backups')
        args = ['backup', '--full', backup_dir]
        if encrypted and password:
            args = ['backup', '--full', backup_dir, '-p', password]
        stdout, stderr, rc = self._run_udid('idevicebackup2', udid, args, timeout=600)
        backup_path = os.path.join(backup_dir, udid)
        success = os.path.isdir(backup_path)
        return {
            'success': success,
            'backup_path': backup_path if success else None,
            'encrypted': encrypted,
            'output': (stdout or stderr).strip()[:500],
        }

    def list_backups(self):
        """List available local backups."""
        backup_dir = self._base / 'backups'
        backups = []
        if backup_dir.exists():
            for d in backup_dir.iterdir():
                if d.is_dir():
                    manifest = d / 'Manifest.db'
                    info_plist = d / 'Info.plist'
                    backup_info = {'udid': d.name, 'path': str(d)}
                    if manifest.exists():
                        backup_info['has_manifest'] = True
                        backup_info['size_mb'] = sum(
                            f.stat().st_size for f in d.rglob('*') if f.is_file()
                        ) / (1024 * 1024)
                    if info_plist.exists():
                        try:
                            with open(info_plist, 'rb') as f:
                                plist = plistlib.load(f)
                            backup_info['device_name'] = plist.get('Device Name', '')
                            backup_info['product_type'] = plist.get('Product Type', '')
                            backup_info['ios_version'] = plist.get('Product Version', '')
                            backup_info['date'] = str(plist.get('Last Backup Date', ''))
                        except Exception:
                            pass
                    backups.append(backup_info)
        return {'backups': backups, 'count': len(backups)}

    def extract_backup_sms(self, backup_path):
        """Extract SMS/iMessage from a backup."""
        manifest = os.path.join(backup_path, 'Manifest.db')
        if not os.path.exists(manifest):
            return {'success': False, 'error': 'Manifest.db not found'}
        try:
            conn = sqlite3.connect(manifest)
            cur = conn.cursor()
            # Find SMS database file hash
            cur.execute("SELECT fileID FROM Files WHERE relativePath = 'Library/SMS/sms.db' AND domain = 'HomeDomain'")
            row = cur.fetchone()
            conn.close()
            if not row:
                return {'success': False, 'error': 'SMS database not found in backup'}
            file_hash = row[0]
            sms_db = os.path.join(backup_path, file_hash[:2], file_hash)
            if not os.path.exists(sms_db):
                return {'success': False, 'error': f'SMS db file not found: {file_hash}'}
            # Query messages
            conn = sqlite3.connect(sms_db)
            cur = conn.cursor()
            cur.execute('''
                SELECT m.rowid, m.text, m.date, m.is_from_me,
                       h.id AS handle_id, h.uncanonicalized_id
                FROM message m
                LEFT JOIN handle h ON m.handle_id = h.rowid
                ORDER BY m.date DESC LIMIT 500
            ''')
            messages = []
            for row in cur.fetchall():
                # Apple timestamps: seconds since 2001-01-01
                apple_epoch = 978307200
                ts = row[2]
                if ts and ts > 1e17:
                    ts = ts / 1e9  # nanoseconds
                date_readable = ''
                if ts:
                    try:
                        date_readable = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(ts + apple_epoch))
                    except (ValueError, OSError):
                        pass
                messages.append({
                    'id': row[0], 'text': row[1] or '', 'date': date_readable,
                    'is_from_me': bool(row[3]),
                    'handle': row[4] or row[5] or '',
                })
            conn.close()
            return {'success': True, 'messages': messages, 'count': len(messages)}
        except Exception as e:
            return {'success': False, 'error': str(e)}

    def extract_backup_contacts(self, backup_path):
        """Extract contacts from backup."""
        manifest = os.path.join(backup_path, 'Manifest.db')
        if not os.path.exists(manifest):
            return {'success': False, 'error': 'Manifest.db not found'}
        try:
            conn = sqlite3.connect(manifest)
            cur = conn.cursor()
            cur.execute("SELECT fileID FROM Files WHERE relativePath = 'Library/AddressBook/AddressBook.sqlitedb' AND domain = 'HomeDomain'")
            row = cur.fetchone()
            conn.close()
            if not row:
                return {'success': False, 'error': 'AddressBook not found in backup'}
            file_hash = row[0]
            ab_db = os.path.join(backup_path, file_hash[:2], file_hash)
            if not os.path.exists(ab_db):
                return {'success': False, 'error': 'AddressBook file not found'}
            conn = sqlite3.connect(ab_db)
            cur = conn.cursor()
            cur.execute('''
                SELECT p.rowid, p.First, p.Last, p.Organization,
                       mv.value AS phone_or_email
                FROM ABPerson p
                LEFT JOIN ABMultiValue mv ON p.rowid = mv.record_id
                ORDER BY p.Last, p.First
            ''')
            contacts = {}
            for row in cur.fetchall():
                rid = row[0]
                if rid not in contacts:
                    contacts[rid] = {
                        'first': row[1] or '', 'last': row[2] or '',
                        'organization': row[3] or '', 'values': []
                    }
                if row[4]:
                    contacts[rid]['values'].append(row[4])
            conn.close()
            contact_list = list(contacts.values())
            return {'success': True, 'contacts': contact_list, 'count': len(contact_list)}
        except Exception as e:
            return {'success': False, 'error': str(e)}

    def extract_backup_call_log(self, backup_path):
        """Extract call history from backup."""
        manifest = os.path.join(backup_path, 'Manifest.db')
        if not os.path.exists(manifest):
            return {'success': False, 'error': 'Manifest.db not found'}
        try:
            conn = sqlite3.connect(manifest)
            cur = conn.cursor()
            cur.execute("SELECT fileID FROM Files WHERE relativePath LIKE '%CallHistory%' AND domain = 'HomeDomain'")
            row = cur.fetchone()
            conn.close()
            if not row:
                return {'success': False, 'error': 'Call history not found in backup'}
            file_hash = row[0]
            ch_db = os.path.join(backup_path, file_hash[:2], file_hash)
            if not os.path.exists(ch_db):
                return {'success': False, 'error': 'Call history file not found'}
            conn = sqlite3.connect(ch_db)
            cur = conn.cursor()
            cur.execute('''
                SELECT ROWID, address, date, duration, flags, country_code
                FROM ZCALLRECORD ORDER BY ZDATE DESC LIMIT 200
            ''')
            flag_map = {4: 'incoming', 5: 'outgoing', 8: 'missed'}
            calls = []
            apple_epoch = 978307200
            for row in cur.fetchall():
                ts = row[2]
                date_readable = ''
                if ts:
                    try:
                        date_readable = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(ts + apple_epoch))
                    except (ValueError, OSError):
                        pass
                calls.append({
                    'id': row[0], 'address': row[1] or '', 'date': date_readable,
                    'duration': row[3] or 0, 'type': flag_map.get(row[4], str(row[4])),
                    'country': row[5] or '',
                })
            conn.close()
            return {'success': True, 'calls': calls, 'count': len(calls)}
        except Exception as e:
            return {'success': False, 'error': str(e)}

    def extract_backup_notes(self, backup_path):
        """Extract notes from backup."""
        manifest = os.path.join(backup_path, 'Manifest.db')
        if not os.path.exists(manifest):
            return {'success': False, 'error': 'Manifest.db not found'}
        try:
            conn = sqlite3.connect(manifest)
            cur = conn.cursor()
            cur.execute("SELECT fileID FROM Files WHERE relativePath LIKE '%NoteStore.sqlite%' AND domain = 'AppDomainGroup-group.com.apple.notes'")
            row = cur.fetchone()
            conn.close()
            if not row:
                return {'success': False, 'error': 'Notes database not found in backup'}
            file_hash = row[0]
            notes_db = os.path.join(backup_path, file_hash[:2], file_hash)
            if not os.path.exists(notes_db):
                return {'success': False, 'error': 'Notes file not found'}
            conn = sqlite3.connect(notes_db)
            cur = conn.cursor()
            cur.execute('''
                SELECT n.Z_PK, n.ZTITLE, nb.ZDATA, n.ZMODIFICATIONDATE
                FROM ZICCLOUDSYNCINGOBJECT n
                LEFT JOIN ZICNOTEDATA nb ON n.Z_PK = nb.ZNOTE
                WHERE n.ZTITLE IS NOT NULL
                ORDER BY n.ZMODIFICATIONDATE DESC LIMIT 100
            ''')
            apple_epoch = 978307200
            notes = []
            for row in cur.fetchall():
                body = ''
                if row[2]:
                    try:
                        body = row[2].decode('utf-8', errors='replace')[:500]
                    except Exception:
                        body = '[binary data]'
                date_readable = ''
                if row[3]:
                    try:
                        date_readable = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(row[3] + apple_epoch))
                    except (ValueError, OSError):
                        pass
                notes.append({'id': row[0], 'title': row[1] or '', 'body': body, 'date': date_readable})
            conn.close()
            return {'success': True, 'notes': notes, 'count': len(notes)}
        except Exception as e:
            return {'success': False, 'error': str(e)}

    def list_backup_files(self, backup_path, domain='', path_filter=''):
        """List all files in a backup's Manifest.db."""
        manifest = os.path.join(backup_path, 'Manifest.db')
        if not os.path.exists(manifest):
            return {'success': False, 'error': 'Manifest.db not found'}
        try:
            conn = sqlite3.connect(manifest)
            cur = conn.cursor()
            query = 'SELECT fileID, domain, relativePath, flags FROM Files'
            conditions = []
            params = []
            if domain:
                conditions.append('domain LIKE ?')
                params.append(f'%{domain}%')
            if path_filter:
                conditions.append('relativePath LIKE ?')
                params.append(f'%{path_filter}%')
            if conditions:
                query += ' WHERE ' + ' AND '.join(conditions)
            query += ' LIMIT 500'
            cur.execute(query, params)
            files = []
            for row in cur.fetchall():
                files.append({
                    'hash': row[0], 'domain': row[1],
                    'path': row[2], 'flags': row[3],
                })
            conn.close()
            return {'success': True, 'files': files, 'count': len(files)}
        except Exception as e:
            return {'success': False, 'error': str(e)}

    def extract_backup_file(self, backup_path, file_hash, output_name=None):
        """Extract a specific file from backup by its hash."""
        src = os.path.join(backup_path, file_hash[:2], file_hash)
        if not os.path.exists(src):
            return {'success': False, 'error': f'File not found: {file_hash}'}
        out_dir = self._base / 'recon' / 'extracted'
        out_dir.mkdir(parents=True, exist_ok=True)
        dest = str(out_dir / (output_name or file_hash))
        shutil.copy2(src, dest)
        return {'success': True, 'path': dest, 'size': os.path.getsize(dest)}

    # ── Filesystem ───────────────────────────────────────────────────

    def mount_filesystem(self, udid, mountpoint=None):
        """Mount device filesystem via ifuse."""
        if 'ifuse' not in self._tools:
            return {'success': False, 'error': 'ifuse not installed'}
        if not mountpoint:
            mountpoint = str(self._base / 'mnt' / udid)
        os.makedirs(mountpoint, exist_ok=True)
        stdout, stderr, rc = self._run('ifuse', ['-u', udid, mountpoint])
        return {'success': rc == 0, 'mountpoint': mountpoint, 'output': (stderr or stdout).strip()}

    def mount_app_documents(self, udid, bundle_id, mountpoint=None):
        """Mount a specific app's Documents folder via ifuse."""
        if 'ifuse' not in self._tools:
            return {'success': False, 'error': 'ifuse not installed'}
        if not mountpoint:
            mountpoint = str(self._base / 'mnt' / udid / bundle_id)
        os.makedirs(mountpoint, exist_ok=True)
        stdout, stderr, rc = self._run('ifuse', ['-u', udid, '--documents', bundle_id, mountpoint])
        return {'success': rc == 0, 'mountpoint': mountpoint, 'output': (stderr or stdout).strip()}

    def unmount_filesystem(self, mountpoint):
        """Unmount a previously mounted filesystem."""
        try:
            subprocess.run(['fusermount', '-u', mountpoint], capture_output=True, timeout=10)
            return {'success': True, 'mountpoint': mountpoint}
        except Exception as e:
            return {'success': False, 'error': str(e)}

    # ── Provisioning Profiles ────────────────────────────────────────

    def list_profiles(self, udid):
        """List provisioning profiles on device."""
        stdout, stderr, rc = self._run_udid('ideviceprovision', udid, ['list'], timeout=15)
        if rc != 0:
            return {'success': False, 'error': (stderr or stdout).strip(), 'profiles': []}
        profiles = []
        current = {}
        for line in stdout.split('\n'):
            line = line.strip()
            if line.startswith('ProvisionedDevices'):
                continue
            if ' - ' in line and not current:
                current = {'id': line.split(' - ')[0].strip(), 'name': line.split(' - ', 1)[1].strip()}
            elif line == '' and current:
                profiles.append(current)
                current = {}
        if current:
            profiles.append(current)
        return {'success': True, 'profiles': profiles, 'count': len(profiles)}

    def install_profile(self, udid, profile_path):
        """Install a provisioning/configuration profile."""
        if not os.path.isfile(profile_path):
            return {'success': False, 'error': f'File not found: {profile_path}'}
        stdout, stderr, rc = self._run_udid('ideviceprovision', udid,
                                            ['install', profile_path], timeout=15)
        return {'success': rc == 0, 'output': (stdout or stderr).strip()}

    def remove_profile(self, udid, profile_id):
        """Remove a provisioning profile."""
        stdout, stderr, rc = self._run_udid('ideviceprovision', udid,
                                            ['remove', profile_id], timeout=15)
        return {'success': rc == 0, 'output': (stdout or stderr).strip()}

    # ── Port Forwarding ──────────────────────────────────────────────

    def port_forward(self, udid, local_port, device_port):
        """Set up port forwarding via iproxy (runs in background)."""
        if 'iproxy' not in self._tools:
            return {'success': False, 'error': 'iproxy not installed'}
        try:
            proc = subprocess.Popen(
                [self._tools['iproxy'], '-u', udid, str(local_port), str(device_port)],
                stdout=subprocess.PIPE, stderr=subprocess.PIPE
            )
            time.sleep(0.5)
            if proc.poll() is not None:
                _, err = proc.communicate()
                return {'success': False, 'error': err.decode().strip()}
            return {'success': True, 'pid': proc.pid,
                    'local': local_port, 'device': device_port}
        except Exception as e:
            return {'success': False, 'error': str(e)}

    # ── Device Fingerprint ───────────────────────────────────────────

    def full_fingerprint(self, udid):
        """Get comprehensive device fingerprint."""
        fp = self.device_info(udid)
        # Add specific domains
        for domain in ['com.apple.disk_usage', 'com.apple.mobile.battery',
                       'com.apple.mobile.internal', 'com.apple.international']:
            domain_info = self.device_info_domain(udid, domain)
            if 'error' not in domain_info:
                fp[f'domain_{domain.split(".")[-1]}'] = domain_info
        return fp

    def export_recon_report(self, udid):
        """Export full reconnaissance report."""
        out_dir = self._udid_dir('recon', udid)
        report = {
            'udid': udid,
            'timestamp': time.strftime('%Y-%m-%d %H:%M:%S'),
            'device_info': self.device_info(udid),
            'pair_status': self.validate_pair(udid),
            'apps': self.list_apps(udid),
            'profiles': self.list_profiles(udid),
        }
        report_path = str(out_dir / f'report_{int(time.time())}.json')
        with open(report_path, 'w') as f:
            json.dump(report, f, indent=2, default=str)
        return {'success': True, 'report_path': report_path}


# ── Singleton ──────────────────────────────────────────────────────

_manager = None

def get_iphone_manager():
    global _manager
    if _manager is None:
        _manager = IPhoneExploitManager()
    return _manager
