#!/usr/bin/env python3
"""
Mole Windows Install Agent
Scans network for ASIC miners and saves discovered data to database
"""

import os
import sys
import argparse
import asyncio
import logging
import sqlite3
import yaml
import json
import ipaddress
import csv
from datetime import datetime
from typing import List, Dict, Any, Optional
import aiohttp
from pyasic import get_miner

# Configure logging
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
    handlers=[
        logging.FileHandler('mole_agent.log'),
        logging.StreamHandler(sys.stdout)
    ],
    force=True
)
logger = logging.getLogger("MoleAgent")

AGENT_VERSION = "v3.276.0"


def detect_cpu_count() -> int:
    """Detect the number of logical CPUs on the host. Falls back to 1."""
    try:
        count = os.cpu_count()
        if count and count > 0:
            return int(count)
    except Exception:
        pass
    # psutil fallback (optional dependency)
    try:
        import psutil  # type: ignore
        count = psutil.cpu_count(logical=True)
        if count and count > 0:
            return int(count)
    except Exception:
        pass
    return 1


def detect_ram_mb() -> Optional[int]:
    """Detect total physical RAM in MB.

    Prefers Linux ``/proc/meminfo`` (``MemTotal``), then falls back to psutil
    when available. Returns ``None`` when it cannot be determined (e.g. Windows
    without psutil), so callers can fall back to a conservative assumption.
    """
    # Linux: /proc/meminfo reports MemTotal in kB
    try:
        with open("/proc/meminfo", "r") as f:
            for line in f:
                if line.startswith("MemTotal:"):
                    parts = line.split()
                    if len(parts) >= 2:
                        return int(int(parts[1]) / 1024)
    except Exception:
        pass
    # psutil fallback (works cross-platform, optional dependency)
    try:
        import psutil  # type: ignore
        total = psutil.virtual_memory().total
        if total and total > 0:
            return int(total / (1024 * 1024))
    except Exception:
        pass
    return None


def compute_resource_defaults(cpu_count: Optional[int] = None,
                              ram_mb: Optional[int] = None) -> Dict[str, int]:
    """Compute thread/batch/scan tuning defaults scaled to host resources.

    Thread pools scale with CPU cores; batch sizes scale with RAM. Every value
    is clamped to a safe [min, max] so a tiny box (e.g. a Raspberry Pi) and a
    large server each get sane settings. This mirrors ``compute_resource_defaults``
    in the backend (``app/api/v1/endpoints/moles.py``) so the agent and the
    dashboard agree on what a given host should run.
    """
    try:
        cores = int(cpu_count) if cpu_count else 0
    except (TypeError, ValueError):
        cores = 0
    if cores < 1:
        cores = 2  # conservative assumption for an unknown host

    try:
        ram = float(ram_mb) if ram_mb else 0.0
    except (TypeError, ValueError):
        ram = 0.0
    if ram < 256:
        ram = 2048.0  # assume ~2 GB when unknown
    ram_gb = max(0.5, ram / 1024.0)

    def clamp(value: float, lo: int, hi: int) -> int:
        return int(max(lo, min(hi, round(value))))

    big_box = cores >= 8 and ram_gb >= 8
    small_box = cores <= 1 or ram_gb < 1.5

    return {
        # Thread pools scale with CPU cores
        "scan_threads": clamp(cores * 25, 20, 400),
        "stats_threads": clamp(cores * 50, 50, 800),
        "stats_push_threads": clamp(cores, 1, 8),
        "command_threads": clamp(cores * 100, 100, 1500),
        "resync_threads": clamp(cores * 25, 20, 400),
        "infrastructure_threads": clamp(cores // 2, 1, 8),
        "infrastructure_push_threads": clamp(cores // 4, 1, 4),
        "max_concurrent_scans": clamp(cores * 12, 10, 300),
        # Batch sizes scale with RAM
        "command_batch_size": clamp(ram_gb * 120, 50, 1200),
        "miner_batch_size": clamp(ram_gb * 100, 50, 1000),
        "infrastructure_batch_size": clamp(ram_gb * 50, 25, 500),
        # Cadence: small boxes scan less often, big servers more often
        "scan_interval": 600 if small_box else (180 if big_box else 300),
        "cron_interval": 10 if small_box else (3 if big_box else 5),
    }


class NetworkScanner:
    """Network scanner for discovering ASIC miners"""
    
    def __init__(self, common_ports: List[int] = [4028, 4029, 8080, 80, 443], timeout: int = 5):
        self.common_ports = common_ports
        self.timeout = timeout

    async def scan_network(self, network_range: str, timeout: int = 5) -> List[Dict[str, Any]]:
        """Scan network range for ASIC miners"""
        self.timeout = timeout
        discovered_miners = []
        
        try:
            # Parse network range
            network = ipaddress.ip_network(network_range, strict=False)
            logger.info(f"Scanning network {network_range} for ASIC miners...")
            
            # Create tasks for concurrent scanning
            tasks = []
            for ip in network.hosts():
                tasks.append(self._scan_ip(str(ip)))
            
            # Execute all scans concurrently
            results = await asyncio.gather(*tasks, return_exceptions=True)
            
            # Process results
            for result in results:
                if isinstance(result, dict) and result.get('found'):
                    discovered_miners.append(result)
                elif isinstance(result, Exception):
                    logger.debug(f"Scan error: {result}")
            
            logger.info(f"Found {len(discovered_miners)} miners on network")
            return discovered_miners
            
        except Exception as e:
            logger.error(f"Network scan failed: {e}")
            return []

    async def _scan_ip(self, ip: str) -> Dict[str, Any]:
        """Scan a single IP address for ASIC miners"""
        try:
            # First try to identify using pyasic
            miner = await asyncio.wait_for(
                get_miner(ip), 
                timeout=self.timeout
            )
            
            if miner:
                # Fetch all miner data in a single socket connection to avoid network/VNish overhead
                data = await asyncio.wait_for(
                    miner.get_data(),
                    timeout=self.timeout
                )
                
                # Get errors list as strings
                errors = []
                if data.errors:
                    for err in data.errors:
                        errors.append(str(err.get('message', err) if isinstance(err, dict) else err))
                
                # Get primary pool details
                pool_url = None
                pool_username = None
                worker_name = None
                if data.pools:
                    first_pool = data.pools[0]
                    if hasattr(first_pool, 'url'):
                        pool_url = first_pool.url
                    elif isinstance(first_pool, dict):
                        pool_url = first_pool.get('url')
                    
                    if hasattr(first_pool, 'username'):
                        pool_username = first_pool.username
                    elif isinstance(first_pool, dict):
                        pool_username = first_pool.get('username')
                        
                    if pool_username and "." in pool_username:
                        worker_name = pool_username.split(".")[-1]
                
                return {
                    'ip': ip,
                    'model': data.model or "Unknown",
                    'status': 'active' if data.is_mining else 'idle',
                    'found': True,
                    'hashrate': data.hashrate or 0.0,
                    'temperature': data.temperature_avg if data.temperature_avg is not None else (data.env_temp or 0.0),
                    'power_consumption': data.wattage or 0.0,
                    'fan_speed': data.fan_speed or 0,
                    'uptime': data.uptime or 0,
                    'firmware_version': data.fw_ver or "",
                    'serial_number': data.serial or "",
                    'mac_address': data.mac or "",
                    'pool_url': pool_url or "",
                    'pool_username': pool_username or "",
                    'worker_name': worker_name or "",
                    'voltage': data.voltage or 0.0,
                    'fan_count': len(data.fans) if data.fans else 0,
                    'hashboard_count': len(data.hashboards) if data.hashboards else 0,
                    'algorithm': data.algo or "SHA256",
                    'coin': data.coin or "BTC",
                    'errors': errors,
                    'fans': [{"index": idx, "speed_rpm": getattr(f, 'speed', f) if not isinstance(f, dict) else f.get('speed', 0)} for idx, f in enumerate(data.fans or [])] if data.fans else [],
                    'hashboards': [{"index": idx, "hashrate": getattr(hb, 'hashrate', 0.0) or 0.0, "chips": getattr(hb, 'chips', 0) or 0, "temperature": getattr(hb, 'temp', 0.0) or 0.0} for idx, hb in enumerate(data.hashboards or [])] if data.hashboards else [],
                    'pools': [{"url": getattr(p, 'url', '') or '', "worker": getattr(p, 'username', '') or '', "status": getattr(p, 'status', 'unknown') or 'unknown'} for p in data.pools or []] if data.pools else [],
                    'pcb_sensors': [],
                    'expected_hashrate': data.expected_hashrate or 0.0,
                    'expected_chips': data.expected_chips or 0,
                    'expected_fans': data.expected_fans or 0,
                    'env_temp': data.env_temp or 0.0,
                    'wattage_limit': data.wattage_limit or 0,
                    'fan_psu': data.fan_psu or 0,
                    'percent_expected_chips': data.percent_expected_chips or 0.0
                }
            
        except asyncio.TimeoutError:
            logger.debug(f"Timeout scanning {ip}")
        except Exception as e:
            logger.debug(f"Error scanning {ip}: {e}")
        
        # Fallback: try common ports
        return await self._scan_ports(ip)

    async def _scan_ports(self, ip: str) -> Dict[str, Any]:
        """Fallback method to scan common ports"""
        for port in self.common_ports:
            try:
                async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=self.timeout)) as session:
                    async with session.get(f"http://{ip}:{port}", timeout=self.timeout) as response:
                        if response.status == 200:
                            text = await response.text()
                            miner_type = "Unknown ASIC"
                            if "antminer" in text.lower():
                                miner_type = "Antminer"
                            elif "whatsminer" in text.lower():
                                miner_type = "Whatsminer"
                            elif "avalon" in text.lower():
                                miner_type = "Avalon"
                            elif "innosilicon" in text.lower():
                                miner_type = "Innosilicon"
                                
                            return {
                                'ip': ip,
                                'model': miner_type,
                                'status': 'unknown',
                                'found': True,
                                'port': port,
                                'hashrate': 0.0,
                                'temperature': 0.0,
                                'power_consumption': 0.0,
                                'fan_speed': 0,
                                'uptime': 0,
                                'firmware_version': "",
                                'serial_number': "",
                                'mac_address': "",
                                'pool_url': "",
                                'pool_username': "",
                                'worker_name': "",
                                'voltage': 0.0,
                                'fan_count': 0,
                                'hashboard_count': 0,
                                'algorithm': "SHA256",
                                'coin': "BTC",
                                'errors': []
                            }
            except Exception:
                continue
        
        return {'found': False}


class _IPReportProtocol(asyncio.DatagramProtocol):
    """UDP protocol wrapper that hands raw IP Report packets to the agent."""

    def __init__(self, on_packet, port: int):
        self._on_packet = on_packet
        self._port = port

    def datagram_received(self, data: bytes, addr):
        try:
            self._on_packet(data, addr, self._port)
        except Exception as e:
            logger.debug(f"Error handling IP report packet on UDP {self._port}: {e}")


class MoleAgent:
    """Mole Agent daemon"""

    def __init__(self, config_path: str):
        self.config_path = config_path
        self.config = self._load_config()
        self.historical_synced = False
        self.scanner = NetworkScanner(
            common_ports=self.config.get('scan_ports', [4028, 4029, 8080, 80, 443]),
            timeout=self.config.get('timeout', 5)
        )
        self._init_offline_cache()
        # IP Report listener state (started once per process in daemon mode)
        self._ip_report_transports: List[Any] = []
        self._ip_report_recent: Dict[str, float] = {}

    # ─── IP Report Listener ──────────────────────────────────────────────────
    # Miners broadcast a UDP packet when their physical "IP Report" button is
    # pressed: Antminer firmware targets port 14235 ("ip,mac" text payload),
    # Whatsminer targets port 8888 (JSON payload). The agent forwards each
    # press to the backend so the dashboard's sitemap mapping mode can drop
    # the machine into the armed rack slot in real time.

    async def start_ip_report_listener(self):
        """Bind UDP listeners for miner IP Report broadcasts (idempotent)."""
        if self._ip_report_transports:
            return
        if not self.config.get('ip_report_enabled', True):
            logger.info("IP Report listener disabled by configuration.")
            return

        loop = asyncio.get_running_loop()
        ports = self.config.get('ip_report_ports', [14235, 8888])
        for port in ports:
            try:
                transport, _ = await loop.create_datagram_endpoint(
                    lambda p=port: _IPReportProtocol(self._handle_ip_report_packet, p),
                    local_addr=("0.0.0.0", int(port)),
                    allow_broadcast=True,
                )
                self._ip_report_transports.append(transport)
                logger.info(f"IP Report listener active on UDP port {port}")
            except OSError as e:
                logger.warning(f"Could not bind IP Report listener on UDP {port}: {e}")

    def stop_ip_report_listener(self):
        for transport in self._ip_report_transports:
            try:
                transport.close()
            except Exception:
                pass
        self._ip_report_transports = []

    @staticmethod
    def _parse_ip_report(data: bytes, addr, port: int):
        """Extract (ip, mac, source, raw) from an IP Report broadcast packet."""
        import re

        text = data.decode("utf-8", errors="ignore").strip()
        ip = None
        mac = None
        source = "antminer" if port == 14235 else "whatsminer" if port == 8888 else "unknown"

        if text.startswith("{"):
            try:
                obj = json.loads(text)
                if isinstance(obj, dict):
                    ip = obj.get("ip") or obj.get("IP") or obj.get("ip_address")
                    mac = obj.get("mac") or obj.get("MAC") or obj.get("mac_address")
            except Exception:
                pass

        if not ip:
            m = re.search(r"(\d{1,3}(?:\.\d{1,3}){3})", text)
            if m:
                ip = m.group(1)
        if not mac:
            # Consistent separator + boundary guards keep e.g. the "AC" of
            # "MAC:c4-..." out of the match
            m = re.search(
                r"(?<![0-9A-Fa-f])((?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}|(?:[0-9A-Fa-f]{2}-){5}[0-9A-Fa-f]{2})(?![0-9A-Fa-f])",
                text
            )
            if m:
                mac = m.group(1).replace("-", ":").upper()

        # The broadcast's source address is the miner itself — a solid fallback
        # when the payload format is unrecognized.
        if not ip and addr:
            ip = addr[0]

        return ip, mac, source, text[:500]

    def _handle_ip_report_packet(self, data: bytes, addr, port: int):
        ip, mac, source, raw = self._parse_ip_report(data, addr, port)
        if not ip:
            return
        logger.info(f"IP Report received: ip={ip} mac={mac or '?'} via UDP {port}")
        asyncio.get_running_loop().create_task(
            self._forward_ip_report(ip, mac, source, raw)
        )

    async def _forward_ip_report(self, ip: str, mac: Optional[str], source: str, raw: str):
        """Push one IP Report press to the backend (deduped per miner for 5s)."""
        import time

        now = time.monotonic()
        last = self._ip_report_recent.get(ip)
        if last is not None and (now - last) < 5.0:
            return
        self._ip_report_recent[ip] = now
        # Prune stale dedupe entries
        if len(self._ip_report_recent) > 500:
            cutoff = now - 60.0
            self._ip_report_recent = {
                k: v for k, v in self._ip_report_recent.items() if v >= cutoff
            }

        main_app_url = self.config.get('main_app_url', 'http://localhost:8001')
        pickaxe_id = self.config.get('pickaxe_id')
        client_id = self.config.get('client_id')
        api_key = self.config.get('api_key')

        if not pickaxe_id or not client_id or not api_key:
            logger.warning("IP Report captured but pickaxe_id/client_id/api_key are not configured; not forwarded.")
            return

        url = f"{main_app_url}/api/v1/cron-data/ip-report/pickaxe/{pickaxe_id}"
        headers = {
            "Authorization": f"Bearer {api_key}",
            "X-Client-Id": client_id
        }
        payload = {"ip": ip, "mac": mac, "source": source, "raw": raw}

        try:
            connector = aiohttp.TCPConnector(ssl=False)
            async with aiohttp.ClientSession(connector=connector) as session:
                async with session.post(url, json=payload, headers=headers, timeout=10) as response:
                    if response.status == 200:
                        logger.info(f"Forwarded IP Report for {ip} to backend")
                    else:
                        logger.warning(f"Backend rejected IP Report for {ip}: HTTP {response.status}")
        except Exception as e:
            logger.warning(f"Could not forward IP Report for {ip}: {e}")
        
    async def _fetch_backend_settings(self) -> Optional[Dict[str, Any]]:
        """Fetch settings for this Mole Hole daemon from the backend"""
        main_app_url = self.config.get('main_app_url', 'http://localhost:8001')
        pickaxe_id = self.config.get('pickaxe_id')
        client_id = self.config.get('client_id')
        api_key = self.config.get('api_key')
        
        if not pickaxe_id or not client_id or not api_key:
            return None
            
        url = f"{main_app_url}/api/v1/moles/{pickaxe_id}"
        headers = {
            "Authorization": f"Bearer {api_key}",
            "X-Client-Id": client_id
        }
        
        try:
            connector = aiohttp.TCPConnector(ssl=False)
            async with aiohttp.ClientSession(connector=connector) as session:
                async with session.get(url, headers=headers, timeout=10) as response:
                    if response.status == 200:
                        settings_data = await response.json()
                        logger.info(f"Successfully fetched settings from backend for Mole Hole '{pickaxe_id}'")
                        return settings_data
                    else:
                        logger.warning(f"Failed to fetch backend settings. Status code: {response.status}")
        except Exception as e:
            logger.warning(f"Could not connect to backend to fetch settings: {e}")
            
        return None

    async def _check_for_updates(self, settings: Optional[Dict[str, Any]]):
        """Check if a newer version of the agent code is available and update if necessary"""
        if not settings:
            return
            
        latest_version = settings.get("latest_version")
        update_url = settings.get("update_url")
        
        if not latest_version or not update_url:
            return
            
        # Parse versions. We assume standard semantic versioning or simple comparison.
        def clean_ver(v):
            v = str(v).strip().lower().lstrip('v')
            return [int(x) for x in v.split('.') if x.isdigit()]
            
        try:
            curr_v = clean_ver(AGENT_VERSION)
            late_v = clean_ver(latest_version)
        except Exception:
            logger.warning(f"Failed to parse version strings: current={AGENT_VERSION}, latest={latest_version}")
            return
            
        if late_v > curr_v:
            logger.info(f"🚀 New agent version available: {latest_version} (Current: {AGENT_VERSION})")
            logger.info(f"Downloading update from {update_url}...")
            
            try:
                connector = aiohttp.TCPConnector(ssl=False)
                async with aiohttp.ClientSession(connector=connector) as session:
                    async with session.get(update_url, timeout=30) as response:
                        if response.status == 200:
                            new_code = await response.text()
                            
                            # Simple validation: ensure it contains NetworkScanner and MoleAgent
                            if "NetworkScanner" not in new_code:
                                logger.error("Downloaded update failed basic validation. Aborting update.")
                                return
                                
                            # Write to current file path
                            current_file = os.path.abspath(sys.argv[0])
                            backup_file = current_file + ".bak"
                            
                            # Backup current file
                            with open(backup_file, "w", encoding="utf-8") as f:
                                with open(current_file, "r", encoding="utf-8") as current_f:
                                    f.write(current_f.read())
                                    
                            # Write new file
                            with open(current_file, "w", encoding="utf-8") as f:
                                f.write(new_code)
                                
                            logger.info(f"✅ Agent code updated successfully to {latest_version}!")
                            logger.info("Restarting process to apply update...")
                            
                            # Exit. The systemd service or Docker container restart policy will restart the process.
                            sys.exit(0)
                        else:
                            logger.error(f"Failed to download update: HTTP status {response.status}")
            except Exception as e:
                logger.error(f"Error during agent self-update: {e}")

    async def _send_heartbeat(self, miners: List[Dict[str, Any]]):
        """Send periodic heartbeat to Mole Hole endpoint"""
        main_app_url = self.config.get('main_app_url', 'http://localhost:8001')
        pickaxe_id = self.config.get('pickaxe_id')
        client_id = self.config.get('client_id')
        api_key = self.config.get('api_key')
        
        if not pickaxe_id or not client_id or not api_key:
            return
            
        url = f"{main_app_url}/api/v1/cron-data/heartbeat/pickaxe/{pickaxe_id}"
        headers = {
            "Authorization": f"Bearer {api_key}",
            "X-Client-Id": client_id
        }
        
        active_miners = len([m for m in miners if m.get('status') == 'active'])
        total_miners = len(miners)
        total_hashrate = sum(m.get('hashrate', 0.0) for m in miners)
        total_power = sum(m.get('power_consumption', 0.0) for m in miners)
        
        payload = {
            "active_miners": active_miners,
            "total_miners": total_miners,
            "total_hashrate": total_hashrate,
            "total_power": total_power,
            "version": AGENT_VERSION,
            # Report detected host resources so the dashboard can offer
            # resource-aware recommended tuning defaults.
            "host_cpu_count": self.config.get("host_cpu_count"),
            "host_ram_mb": self.config.get("host_ram_mb"),
        }

        try:
            connector = aiohttp.TCPConnector(ssl=False)
            async with aiohttp.ClientSession(connector=connector) as session:
                async with session.post(url, json=payload, headers=headers, timeout=10) as response:
                    if response.status == 200:
                        logger.info("Sent heartbeat successfully to backend")
        except Exception as e:
            logger.warning(f"Could not send heartbeat to backend: {e}")
        
    def _load_config(self) -> dict:
        """Load YAML configuration with environment variable overrides"""
        config = {
            'site_id': 'site_1',
            'scan_range': '192.168.1.0/24',
            'timeout': 5,
            'scan_ports': [4028, 4029, 8080, 80, 443],
            'db_path': 'data/mining_manager.db',
            'main_app_url': 'http://localhost:8001'
        }
        try:
            if os.path.exists(self.config_path):
                with open(self.config_path, 'r') as f:
                    yaml_config = yaml.safe_load(f)
                if yaml_config:
                    config.update(yaml_config)
                logger.info(f"Loaded config from {self.config_path}")
            else:
                logger.warning(f"Config file {self.config_path} not found. Using defaults.")
        except Exception as e:
            logger.error(f"Error loading configuration: {e}")
            
        # Environment overrides
        for env_key, config_key in [
            ("SITE_ID", "site_id"),
            ("SCAN_RANGE", "scan_range"),
            ("TIMEOUT", "timeout"),
            ("MAIN_APP_URL", "main_app_url"),
            ("API_KEY", "api_key"),
            ("CLIENT_ID", "client_id"),
            ("PICKAXE_ID", "pickaxe_id")
        ]:
            if os.environ.get(env_key):
                config[config_key] = os.environ.get(env_key)

        # Resource-aware defaults: detect host CPU/RAM and, for any tuning key
        # not explicitly set via config.yaml or env, fill in a value scaled to
        # this box. Explicit config keys and env overrides always win.
        detected_cpu = detect_cpu_count()
        detected_ram = detect_ram_mb()
        config.setdefault("host_cpu_count", detected_cpu)
        if detected_ram is not None:
            config.setdefault("host_ram_mb", detected_ram)

        resource_defaults = compute_resource_defaults(
            config.get("host_cpu_count"), config.get("host_ram_mb")
        )
        applied = []
        for key, value in resource_defaults.items():
            if config.get(key) is None:
                config[key] = value
                applied.append(key)
        logger.info(
            "Host resources detected: cpu=%s cores, ram=%s MB. "
            "Applied resource-scaled defaults for: %s",
            config.get("host_cpu_count"),
            config.get("host_ram_mb"),
            ", ".join(applied) if applied else "(none — all explicitly configured)",
        )

        return config
        
    def _init_offline_cache(self):
        """Initialize the local offline cache SQLite database"""
        cache_path = 'mole_offline_cache.db'
        try:
            conn = sqlite3.connect(cache_path)
            cursor = conn.cursor()
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS offline_scans (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    timestamp TEXT,
                    miners_payload TEXT,
                    site_id TEXT,
                    api_key TEXT
                )
            """)
            # Upgrade existing table if it was created without site_id/api_key
            cursor.execute("PRAGMA table_info(offline_scans)")
            columns = [col[1] for col in cursor.fetchall()]
            if 'site_id' not in columns:
                cursor.execute("ALTER TABLE offline_scans ADD COLUMN site_id TEXT")
            if 'api_key' not in columns:
                cursor.execute("ALTER TABLE offline_scans ADD COLUMN api_key TEXT")
            conn.commit()
            conn.close()
            logger.info("Initialized offline cache local database successfully.")
        except Exception as e:
            logger.error(f"Failed to initialize offline cache SQLite database: {e}")

    def _save_to_offline_cache(self, miners: List[Dict[str, Any]], site_id: str, api_key: str):
        """Save discovered miners payload to local offline cache"""
        cache_path = 'mole_offline_cache.db'
        try:
            conn = sqlite3.connect(cache_path)
            cursor = conn.cursor()
            now_str = datetime.utcnow().isoformat()
            cursor.execute(
                "INSERT INTO offline_scans (timestamp, miners_payload, site_id, api_key) VALUES (?, ?, ?, ?)",
                (now_str, json.dumps(miners), site_id, api_key)
            )
            conn.commit()
            
            # Prune old records to keep the cache size bounded (keep last 50 scans)
            cursor.execute("SELECT COUNT(*) FROM offline_scans")
            total_count = cursor.fetchone()[0]
            if total_count > 50:
                cursor.execute("""
                    DELETE FROM offline_scans 
                    WHERE id NOT IN (
                        SELECT id FROM offline_scans 
                        ORDER BY id DESC 
                        LIMIT 50
                    )
                """)
                conn.commit()
                cursor.execute("VACUUM")
                conn.commit()
                logger.info("🧹 Pruned old offline cache records and vacuumed database.")
                
            conn.close()
            logger.info(f"💾 Telemetry snapshot saved to offline cache (count: {len(miners)}) for site {site_id}")
        except Exception as e:
            logger.error(f"Failed to save telemetry to local offline cache: {e}")

    async def _sync_offline_cache(self):
        """Synchronize cached offline telemetry to the central API when connection is available"""
        cache_path = 'mole_offline_cache.db'
        if not os.path.exists(cache_path):
            return
            
        try:
            conn = sqlite3.connect(cache_path)
            cursor = conn.cursor()
            cursor.execute("SELECT id, timestamp, miners_payload, site_id, api_key FROM offline_scans ORDER BY id ASC")
            rows = cursor.fetchall()
            conn.close()
            
            if not rows:
                return
                
            logger.info(f"🔄 Found {len(rows)} cached offline scans. Attempting to sync...")
            
            success_ids = []
            for row in rows:
                if len(row) == 5:
                    row_id, timestamp, payload_json, site_id, api_key = row
                else:
                    row_id, timestamp, payload_json = row
                    site_id = self.config.get('site_id', 'site_1')
                    api_key = self.config.get('api_key', '')
                
                miners = json.loads(payload_json)
                success = await self._notify_api(miners, site_id, api_key)
                if success:
                    success_ids.append(row_id)
                    logger.info(f"✅ Synced offline scan from {timestamp} for site {site_id} successfully.")
                else:
                    logger.warning("⚠️ Sync failed. Central API might have gone offline again. Stopping sync.")
                    break
                    
            if success_ids:
                conn = sqlite3.connect(cache_path)
                cursor = conn.cursor()
                cursor.execute(f"DELETE FROM offline_scans WHERE id IN ({','.join(map(str, success_ids))})")
                conn.commit()
                # Run VACUUM to reclaim disk space immediately
                cursor.execute("VACUUM")
                conn.commit()
                conn.close()
                logger.info(f"🧹 Removed {len(success_ids)} synced entries from offline cache and vacuumed database.")
                
        except Exception as e:
            logger.error(f"Error during offline cache sync: {e}")
        
    def _save_directly_to_sqlite(self, miners: List[Dict[str, Any]], site_id: Optional[str] = None) -> bool:
        """Save discovered miners directly to the local SQLite database file"""
        db_path = self.config.get('db_path')
        if not db_path:
            return False
            
        if not os.path.exists(db_path):
            logger.warning(f"SQLite database file not found at: {db_path}. Skipping direct save.")
            return False
            
        logger.info(f"Connecting to local SQLite database: {db_path}")
        if not site_id:
            site_id = self.config.get('site_id', 'site_1')
        
        conn = None
        try:
            conn = sqlite3.connect(db_path)
            cursor = conn.cursor()
            
            # Verify site exists
            cursor.execute("SELECT id FROM sites WHERE id = ?", (site_id,))
            site_row = cursor.fetchone()
            if not site_row:
                # Auto-create the default site if missing to prevent foreign key errors
                cursor.execute(
                    "INSERT INTO sites (id, name, status, created_at) VALUES (?, ?, ?, ?)",
                    (site_id, f"Site {site_id}", "online", datetime.utcnow().isoformat())
                )
                logger.info(f"Auto-created missing site record: {site_id}")
                
            for miner in miners:
                ip = miner['ip']
                mac = miner.get('mac_address') or miner.get('mac')
                
                # Check if miner exists
                row = None
                if mac:
                    cursor.execute("SELECT id, error_history, ip_address FROM miners WHERE mac_address = ? AND site_id = ?", (mac, site_id))
                    row = cursor.fetchone()
                if not row:
                    cursor.execute("SELECT id, error_history, ip_address FROM miners WHERE ip_address = ? AND site_id = ?", (ip, site_id))
                    row = cursor.fetchone()
                
                now_str = datetime.utcnow().isoformat()
                
                # Handle error history json
                error_history_json = "[]"
                if miner.get('errors'):
                    first_error = miner['errors'][0]
                    if row and row[1]:
                        try:
                            history = json.loads(row[1])
                        except:
                            history = []
                        history.append({"timestamp": now_str, "message": first_error})
                        error_history_json = json.dumps(history[-100:])
                    else:
                        error_history_json = json.dumps([{"timestamp": now_str, "message": first_error}])
                
                if row:
                    # Update miner
                    miner_id = row[0]
                    old_ip = row[2]
                    cursor.execute(
                        """
                        UPDATE miners SET
                            model = ?, status = ?, hashrate = ?, temperature = ?, power_consumption = ?,
                            fan_speed = ?, uptime = ?, firmware_version = ?, serial_number = ?,
                            mac_address = ?, pool_url = ?, pool_username = ?, worker_name = ?,
                            voltage = ?, fan_count = ?, hashboard_count = ?, algorithm = ?, coin = ?,
                            last_error = ?, error_history = ?, last_seen = ?, updated_at = ?, ip_address = ?
                        WHERE id = ?
                        """,
                        (
                            miner['model'], miner['status'], miner['hashrate'], miner['temperature'], miner['power_consumption'],
                            miner['fan_speed'], miner['uptime'], miner['firmware_version'], miner['serial_number'],
                            miner['mac_address'], miner['pool_url'], miner['pool_username'], miner['worker_name'],
                            miner['voltage'], miner['fan_count'], miner['hashboard_count'], miner['algorithm'], miner['coin'],
                            miner['errors'][0] if miner.get('errors') else None, error_history_json, now_str, now_str,
                            ip, miner_id
                        )
                    )
                    if old_ip != ip:
                        logger.info(f"Updated database record for miner: {miner_id} (IP changed from {old_ip} to {ip})")
                    else:
                        logger.info(f"Updated database record for miner: {ip}")
                else:
                    # Insert miner
                    if mac:
                        miner_id = f"miner_{mac.replace(':', '').lower()}"
                    else:
                        miner_id = f"miner_{int(datetime.utcnow().timestamp() * 1000)}_{ip.replace('.', '_')}"
                    cursor.execute(
                        """
                        INSERT INTO miners (
                            id, site_id, name, ip_address, port, model, status, hashrate,
                            temperature, power_consumption, fan_speed, uptime, firmware_version,
                            serial_number, mac_address, pool_url, pool_username, worker_name,
                            voltage, fan_count, hashboard_count, algorithm, coin,
                            last_error, error_history, last_seen, created_at
                        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                        """,
                        (
                            miner_id, site_id, f"Miner {ip}", ip, miner.get('port', 4028), miner['model'], miner['status'], miner['hashrate'],
                            miner['temperature'], miner['power_consumption'], miner['fan_speed'], miner['uptime'], miner['firmware_version'],
                            miner['serial_number'], miner['mac_address'], miner['pool_url'], miner['pool_username'], miner['worker_name'],
                            miner['voltage'], miner['fan_count'], miner['hashboard_count'], miner['algorithm'], miner['coin'],
                            miner['errors'][0] if miner.get('errors') else None, error_history_json, now_str, now_str
                        )
                    )
                    logger.info(f"Inserted new database record for miner: {ip}")
                    
            conn.commit()
            logger.info("Local SQLite database update complete.")
            return True
        except Exception as e:
            logger.error(f"Failed to write directly to SQLite database: {e}")
            if conn:
                conn.rollback()
            return False
        finally:
            if conn:
                conn.close()

    async def _notify_api(self, miners: List[Dict[str, Any]], site_id: Optional[str] = None, api_key: Optional[str] = None) -> bool:
        """Send discovered miners payload to FastAPI endpoint"""
        main_app_url = self.config.get('main_app_url', 'http://localhost:8001')
        pickaxe_id = self.config.get('pickaxe_id')
        client_id = self.config.get('client_id')
        
        # Prepare list for API matching the FastAPI expected model list
        miner_payloads = []
        for m in miners:
            miner_payloads.append({
                "ip": m["ip"],
                "port": m.get("port", 4028),
                "model": m["model"],
                "status": m["status"],
                "hashrate": m["hashrate"],
                "temperature": m["temperature"],
                "power_consumption": m["power_consumption"],
                "fan_speed": m["fan_speed"],
                "uptime": m["uptime"],
                "firmware_version": m["firmware_version"],
                "serial_number": m["serial_number"],
                "mac_address": m["mac_address"],
                "pool_url": m["pool_url"],
                "pool_username": m["pool_username"],
                "worker_name": m["worker_name"],
                "voltage": m["voltage"],
                "fan_count": m["fan_count"],
                "hashboard_count": m["hashboard_count"],
                "algorithm": m["algorithm"],
                "coin": m["coin"],
                "errors": m["errors"]
            })
            
        headers = {}
        # Try Mole Hole auth first
        if pickaxe_id and client_id and (api_key or self.config.get('api_key')):
            url = f"{main_app_url}/api/v1/cron-data/miner-data/pickaxe/{pickaxe_id}"
            headers["Authorization"] = f"Bearer {api_key or self.config.get('api_key')}"
            headers["X-Client-Id"] = client_id
            logger.info(f"Sending miner data to Mole Hole API: {url}")
        else:
            # Fallback to legacy site endpoint
            if not site_id:
                site_id = self.config.get('site_id', 'site_1')
            url = f"{main_app_url}/api/v1/cron-data/miner-data/{site_id}"
            if api_key:
                headers["Authorization"] = f"Bearer {api_key}"
            logger.info(f"Sending miner data to legacy Site API: {url}")
            
        try:
            connector = aiohttp.TCPConnector(ssl=False)
            async with aiohttp.ClientSession(connector=connector) as session:
                async with session.post(
                    url,
                    json=miner_payloads,
                    headers=headers,
                    timeout=aiohttp.ClientTimeout(total=15)
                ) as response:
                    if response.status == 200:
                        res_json = await response.json()
                        logger.info(f"API notification success: {res_json.get('message', 'Processed')}")
                        return True
                    else:
                        logger.error(f"API notification returned status code: {response.status}")
                        return False
        except Exception as e:
            logger.error(f"Could not connect to API to notify: {e}")
            return False

    async def run_mock_foreman(self, dry_run: bool = False):
        """Mock network scan by reading telemetry rows from Foreman CSV and shifting dates to today"""
        csv_paths = [
            os.environ.get("FOREMAN_CSV_PATH"),
            "/foreman/01_Power_Report/reports/power_summary.csv",
            "backend/foreman/01_Power_Report/reports/power_summary.csv",
            "foreman/01_Power_Report/reports/power_summary.csv",
            "/app/foreman/01_Power_Report/reports/power_summary.csv"
        ]
        
        power_summary_path = None
        for path in csv_paths:
            if path and os.path.exists(path):
                power_summary_path = path
                break
                
        if not power_summary_path:
            logger.warning(f"Could not find power_summary.csv in paths: {csv_paths}. Falling back to simulation fallback mode...")
            await self.run_simulation_fallback(dry_run)
            return
            
        logger.info(f"Reading mock Foreman telemetry from {power_summary_path}...")
        
        # 1. Parse headers to detect dates
        headers = []
        with open(power_summary_path, mode="r", encoding="utf-8") as f:
            reader = csv.reader(f)
            headers = next(reader)
            
        dates = sorted(list(set(
            h.split("_")[0] for h in headers if h.startswith("202") and "_" in h
        )))
        if not dates:
            logger.error("No telemetry dates detected in CSV headers.")
            return
            
        latest_csv_date_str = dates[-1]
        from datetime import timedelta
        latest_csv_date = datetime.strptime(latest_csv_date_str, "%Y-%m-%d").date()
        today = datetime.utcnow().date()
        days_diff = (today - latest_csv_date).days
        
        # Load parameters
        pickaxe_id = self.config.get('pickaxe_id')
        client_id = self.config.get('client_id')
        api_key = self.config.get('api_key')
        site_id = self.config.get('site_id', 'site_1')
        
        def clean_val(val, default=0.0):
            try:
                if not val or val.strip() == "":
                    return default
                return float(val.strip())
            except ValueError:
                return default
                
        # Helper to construct miner dicts for a specific date
        def get_miners_for_date(target_date_str: str, shifted_timestamp: str) -> List[Dict[str, Any]]:
            miners = []
            with open(power_summary_path, mode="r", encoding="utf-8") as f:
                reader = csv.DictReader(f)
                for idx, row in enumerate(reader):
                    mac = row.get("miner_mac")
                    if not mac:
                        continue
                        
                    mac_clean = mac.strip().lower()
                    
                    # IP generation logic matches seed script
                    x = idx // 250
                    y = idx % 250
                    subnet_multiplier = y // 254
                    host_part = y % 254 + 1
                    ip_address = f"10.30.{x + subnet_multiplier}.{host_part}"
                    
                    hr_raw = clean_val(row.get(f"{target_date_str}_hash_rate"), None)
                    p_raw = clean_val(row.get(f"{target_date_str}_power_consumption"), None)
                    
                    # hashrate in TH/s (convert from H/s)
                    hashrate = hr_raw / 1e12 if hr_raw > 0 else 0.0
                    
                    # power consumption in Watts: Wh / 24 hours
                    power = p_raw / 24.0 if p_raw > 0 else 0.0
                    
                    status = "active" if hashrate > 0 else "offline"
                    
                    rack_name = row.get("rack_name") or ""
                    row_val = int(row.get("row") or 0) + 1
                    col_val = int(row.get("index") or 0) + 1
                    
                    custom_tags = ["foreman", f"rack:{rack_name}", f"coords:({row_val},{col_val})"]
                    miner_type = row.get("miner_type") or "Antminer S19 Pro"
                    
                    miners.append({
                        "ip": ip_address,
                        "port": int(row.get("apiIp") or 4028) if row.get("apiIp") else 4028,
                        "model": miner_type,
                        "status": status,
                        "hashrate": hashrate,
                        "temperature": 75.0 if status == "active" else 0.0,
                        "power_consumption": power,
                        "fan_speed": 4500 if status == "active" else 0,
                        "uptime": 86400 if status == "active" else 0,
                        "firmware_version": "Vnish 1.2.3" if "whatsminer" not in miner_type.lower() else "Whatsminer Official",
                        "serial_number": row.get("miner_name", "").split("-")[-1] or f"SN-{mac_clean.replace(':', '')[:10]}",
                        "mac_address": mac_clean,
                        "pool_url": "stratum+tcp://btc.slushpool.com:3333",
                        "pool_username": f"greenhash.{idx}",
                        "worker_name": f"worker_{idx}",
                        "voltage": 12.0 if status == "active" else 0.0,
                        "fan_count": 4 if status == "active" else 0,
                        "hashboard_count": 3 if status == "active" else 0,
                        "algorithm": "SHA256",
                        "coin": "BTC",
                        "errors": [],
                        "custom_tags": custom_tags,
                        "timestamp": shifted_timestamp
                    })
            return miners

        # 2. Run one-time historical sync
        if not self.historical_synced:
            logger.info("Initializing historical telemetry rollup sync...")
            # Sync only the last 5 days to avoid overloading/rate limiting
            for date_str in dates[-5:]:
                csv_date = datetime.strptime(date_str, "%Y-%m-%d").date()
                shifted_date = csv_date + timedelta(days=days_diff)
                timestamp = datetime.combine(shifted_date, datetime.utcnow().time()).isoformat() + "Z"
                
                logger.info(f"Syncing mock telemetry for shifted date: {shifted_date} (CSV source: {date_str})")
                miners = get_miners_for_date(date_str, timestamp)
                
                # Send to API
                await self._notify_api(miners, site_id, api_key)
                await asyncio.sleep(0.5)
                
            self.historical_synced = True
            logger.info("Historical rollup sync completed successfully.")
            
        # 3. Regular scan cycle: send today's telemetry
        timestamp = datetime.utcnow().isoformat() + "Z"
        logger.info(f"Sending current telemetry for date: {today} (CSV source: {latest_csv_date_str})")
        miners = get_miners_for_date(latest_csv_date_str, timestamp)
        
        if dry_run:
            logger.info(f"[DRY-RUN] Sending mock miner telemetry detail (count: {len(miners)})")
            return
            
        await self._notify_api(miners, site_id, api_key)
        # Send heartbeat
        if pickaxe_id and client_id and api_key:
            await self._send_heartbeat(miners)

    async def run(self, dry_run: bool = False):
        """Run the scan and reporting cycle"""
        force_simulation = os.environ.get("FORCE_SIMULATION", "false").lower() == "true" or self.config.get("force_simulation", False)
        if force_simulation:
            await self.run_simulation_fallback(dry_run)
            return

        mock_foreman = os.environ.get("MOCK_FOREMAN", "false").lower() == "true" or self.config.get("mock_foreman", False)
        if mock_foreman:
            await self.run_mock_foreman(dry_run)
            return

        # Try fetching backend settings first
        backend_settings = await self._fetch_backend_settings()
        
        # Check for self-updates
        await self._check_for_updates(backend_settings)
        
        # Load parameters
        pickaxe_id = self.config.get('pickaxe_id')
        client_id = self.config.get('client_id')
        api_key = self.config.get('api_key')
        
        if backend_settings:
            # Update scanner parameters
            self.scanner.common_ports = backend_settings.get('scan_ports', [4028, 4029, 8080, 80, 443])
            self.scanner.timeout = backend_settings.get('scan_timeout', 5)
            
            network_ranges = backend_settings.get('network_ranges') or [self.config.get('scan_range', '192.168.1.0/24')]
            logger.info(f"Using backend network ranges for Mole Hole scan: {network_ranges}")
        else:
            network_ranges = [self.config.get('scan_range', '192.168.1.0/24')]
            logger.info(f"Using local network range: {network_ranges}")
            
        timeout = self.scanner.timeout
        
        logger.info("=" * 60)
        logger.info(f"Mole Agent started scan cycle at {datetime.now()}")
        logger.info("=" * 60)
        
        # Scan either specific active miner IPs or the whole ranges
        all_miners = []
        miner_ips = backend_settings.get('miner_ips') if backend_settings else None
        
        if miner_ips:
            logger.info(f"Using backend active miner list (polling {len(miner_ips)} specific IPs): {miner_ips}")
            scan_tasks = [self._scan_ip(ip) for ip in miner_ips]
            scan_results = await asyncio.gather(*scan_tasks, return_exceptions=True)
            for res in scan_results:
                if isinstance(res, dict) and res.get('found'):
                    all_miners.append(res)
                elif isinstance(res, Exception):
                    logger.error(f"Error during specific miner scan: {res}")
        else:
            logger.info("No specific miner IPs returned by backend. Falling back to subnet range scans.")
            scan_tasks = []
            for range_str in network_ranges:
                scan_tasks.append(self.scanner.scan_network(range_str, timeout))
                
            scan_results = await asyncio.gather(*scan_tasks, return_exceptions=True)
            for res in scan_results:
                if isinstance(res, list):
                    all_miners.extend(res)
                elif isinstance(res, Exception):
                    logger.error(f"Error during subnet scan task: {res}")
                
        if dry_run:
            logger.info(f"[DRY-RUN] Discovered miners detail:")
            logger.info(json.dumps(all_miners, indent=2))
            return
            
        if not all_miners:
            logger.info("No online miners discovered in this cycle.")
            # Send an empty heartbeat to report Mole Hole status to API
            if pickaxe_id and client_id and api_key:
                await self._send_heartbeat(all_miners)
            return
            
        # Try local SQLite storage if configured
        sqlite_success = self._save_directly_to_sqlite(all_miners)
        
        # If local SQLite fails or is not configured, send to remote API or cache offline
        if not sqlite_success:
            logger.info("Attempting to notify remote API...")
            api_success = await self._notify_api(all_miners, api_key=api_key)
            
            if api_success:
                logger.info("Mole Agent successfully sent current telemetry to API.")
                # Since API is online, trigger sync of any historical offline cached data
                await self._sync_offline_cache()
            else:
                logger.warning("Could not connect to API. Cacheing data locally to offline cache.")
                # Fallback to site_id for offline cache storage
                fallback_site_id = backend_settings.get('site_id') if backend_settings else self.config.get('site_id', 'site_1')
                self._save_to_offline_cache(all_miners, fallback_site_id, api_key)
        else:
            # Also send heartbeat/update count when sqlite_success
            if pickaxe_id and client_id and api_key:
                await self._send_heartbeat(all_miners)
                
        logger.info("Mole Agent cycle completed successfully.")

    async def run_simulation_fallback(self, dry_run: bool = False):
        """Pure simulation mode when CSV is missing. Generates randomized telemetry for configured sites."""
        import random
        
        # Determine sites to simulate
        sites_to_sim = self.config.get('sites')
        if not sites_to_sim:
            # Fallback to single site_id from config
            # No credential-shaped fallbacks: an unconfigured agent must fail
            # loudly against the server rather than half-working with a guessed
            # key (these defaults also used to ship in the public download).
            sites_to_sim = [{
                'site_id': self.config.get('site_id', 'REPLACE_WITH_YOUR_SITE_ID'),
                'api_key': self.config.get('api_key', 'REPLACE_WITH_YOUR_SITE_API_KEY')
            }]
            
        logger.info(f"Simulating telemetry for {len(sites_to_sim)} sites...")
        
        for site_info in sites_to_sim:
            site_id = site_info.get('site_id')
            api_key = site_info.get('api_key')
            
            # Generate 2 simulated miners per site
            miners = []
            if site_id == "site_a":
                miners.append({
                    "ip": "192.168.1.10",
                    "port": 4028,
                    "model": "Antminer S19 Pro",
                    "status": "active",
                    "hashrate": 110.0 + random.uniform(-4, 4),
                    "temperature": 72.0 + random.uniform(-1.5, 1.5),
                    "power_consumption": 3250.0 + random.uniform(-30, 30),
                    "fan_speed": 6500 + random.randint(-150, 150),
                    "uptime": 86400,
                    "firmware_version": "Stock-2023",
                    "serial_number": "SN_A1_MOCK",
                    "mac_address": "00:15:5d:02:01:0a",
                    "pool_url": "stratum+tcp://pool.molehash.com:3333",
                    "pool_username": "Molehash.001",
                    "worker_name": "Molehash.001",
                    "voltage": 12.0,
                    "fan_count": 4,
                    "hashboard_count": 3,
                    "algorithm": "SHA256",
                    "coin": "BTC",
                    "errors": []
                })
                miner2_active = random.random() > 0.15
                miners.append({
                    "ip": "192.168.1.11",
                    "port": 4028,
                    "model": "Antminer S19 Pro",
                    "status": "active" if miner2_active else "offline",
                    "hashrate": 110.0 + random.uniform(-4, 4) if miner2_active else 0.0,
                    "temperature": 72.0 + random.uniform(-1.5, 1.5) if miner2_active else 20.0,
                    "power_consumption": 3250.0 + random.uniform(-30, 30) if miner2_active else 0.0,
                    "fan_speed": 6500 + random.randint(-150, 150) if miner2_active else 0,
                    "uptime": 86400 if miner2_active else 0,
                    "firmware_version": "Stock-2023",
                    "serial_number": "SN_A2_MOCK",
                    "mac_address": "00:15:5d:02:01:0b",
                    "pool_url": "stratum+tcp://pool.molehash.com:3333",
                    "pool_username": "Molehash.002",
                    "worker_name": "Molehash.002",
                    "voltage": 12.0 if miner2_active else 0.0,
                    "fan_count": 4 if miner2_active else 0,
                    "hashboard_count": 3 if miner2_active else 0,
                    "algorithm": "SHA256",
                    "coin": "BTC",
                    "errors": [] if miner2_active else ["Low hash rate"]
                })
            elif site_id == "site_b":
                miners.append({
                    "ip": "192.168.2.10",
                    "port": 4028,
                    "model": "Antminer S19j Pro",
                    "status": "active",
                    "hashrate": 104.0 + random.uniform(-3, 3),
                    "temperature": 68.0 + random.uniform(-1, 1),
                    "power_consumption": 3100.0 + random.uniform(-25, 25),
                    "fan_speed": 5800 + random.randint(-100, 100),
                    "uptime": 86400,
                    "firmware_version": "VNish-1.2.5",
                    "serial_number": "SN_B1_MOCK",
                    "mac_address": "00:15:5d:02:02:0a",
                    "pool_url": "stratum+tcp://btc.cruxpool.com:31111",
                    "pool_username": "Molehash.010",
                    "worker_name": "Molehash.010",
                    "voltage": 11.8,
                    "fan_count": 4,
                    "hashboard_count": 3,
                    "algorithm": "SHA256",
                    "coin": "BTC",
                    "errors": []
                })
                miners.append({
                    "ip": "192.168.2.11",
                    "port": 4028,
                    "model": "Antminer S19j Pro",
                    "status": "active",
                    "hashrate": 104.0 + random.uniform(-3, 3),
                    "temperature": 69.0 + random.uniform(-1, 1),
                    "power_consumption": 3100.0 + random.uniform(-25, 25),
                    "fan_speed": 5900 + random.randint(-100, 100),
                    "uptime": 86400,
                    "firmware_version": "VNish-1.2.5",
                    "serial_number": "SN_B2_MOCK",
                    "mac_address": "00:15:5d:02:02:0b",
                    "pool_url": "stratum+tcp://btc.cruxpool.com:31111",
                    "pool_username": "Molehash.011",
                    "worker_name": "Molehash.011",
                    "voltage": 11.8,
                    "fan_count": 4,
                    "hashboard_count": 3,
                    "algorithm": "SHA256",
                    "coin": "BTC",
                    "errors": []
                })
            elif site_id == "site_c":
                miners.append({
                    "ip": "192.168.3.10",
                    "port": 4028,
                    "model": "Whatsminer M30S",
                    "status": "active",
                    "hashrate": 88.0 + random.uniform(-3, 3),
                    "temperature": 75.0 + random.uniform(-2, 2),
                    "power_consumption": 3344.0 + random.uniform(-40, 40),
                    "fan_speed": 6200 + random.randint(-150, 150),
                    "uptime": 86400,
                    "firmware_version": "Stock-2022",
                    "serial_number": "SN_C1_MOCK",
                    "mac_address": "00:15:5d:02:03:0a",
                    "pool_url": "stratum+tcp://solo.ckpool.org:3333",
                    "pool_username": "Molehash.020",
                    "worker_name": "Molehash.020",
                    "voltage": 12.2,
                    "fan_count": 4,
                    "hashboard_count": 3,
                    "algorithm": "SHA256",
                    "coin": "BTC",
                    "errors": []
                })
                miner_c2_ok = random.random() > 0.3
                miners.append({
                    "ip": "192.168.3.11",
                    "port": 4028,
                    "model": "Whatsminer M30S",
                    "status": "active" if miner_c2_ok else "error",
                    "hashrate": 82.0 + random.uniform(-3, 3) if miner_c2_ok else 5.0,
                    "temperature": 80.0 + random.uniform(-2, 2) if miner_c2_ok else 86.0,
                    "power_consumption": 3344.0 + random.uniform(-40, 40) if miner_c2_ok else 3344.0,
                    "fan_speed": 6500 if miner_c2_ok else 7500,
                    "uptime": 86400 if miner_c2_ok else 600,
                    "firmware_version": "Stock-2022",
                    "serial_number": "SN_C2_MOCK",
                    "mac_address": "00:15:5d:02:03:0b",
                    "pool_url": "stratum+tcp://solo.ckpool.org:3333",
                    "pool_username": "Molehash.021",
                    "worker_name": "Molehash.021",
                    "voltage": 12.2,
                    "fan_count": 4,
                    "hashboard_count": 3,
                    "algorithm": "SHA256",
                    "coin": "BTC",
                    "errors": [] if miner_c2_ok else ["High Temperature Alert"]
                })
            else:
                miners.append({
                    "ip": "10.0.0.10",
                    "port": 4028,
                    "model": "Antminer S19 Pro",
                    "status": "active",
                    "hashrate": 110.0 + random.uniform(-5, 5),
                    "temperature": 70.0,
                    "power_consumption": 3250.0,
                    "fan_speed": 6000,
                    "uptime": 86400,
                    "firmware_version": "Stock",
                    "serial_number": f"SN_{site_id}_1",
                    "mac_address": f"00:15:5d:02:ef:01",
                    "pool_url": "stratum+tcp://pool.molehash.com:3333",
                    "pool_username": "Molehash.generic",
                    "worker_name": "Molehash.generic",
                    "voltage": 12.0,
                    "fan_count": 4,
                    "hashboard_count": 3,
                    "algorithm": "SHA256",
                    "coin": "BTC",
                    "errors": []
                })
                
            # Enrich with detailed metrics (fans, hashboards, pools, expected metrics)
            for m in miners:
                m_status = m.get("status", "offline")
                is_active = m_status == "active"
                
                # fans
                if "fans" not in m:
                    m["fans"] = [
                        {"index": i, "speed_rpm": random.randint(4500, 6500) if is_active else 0}
                        for i in range(m.get("fan_count", 4))
                    ]
                
                # hashboards
                if "hashboards" not in m:
                    hr_share = m.get("hashrate", 0.0) / 3
                    m["hashboards"] = [
                        {"index": i, "hashrate": hr_share, "chips": 120 if is_active else 0, "temperature": m.get("temperature", 70.0) + random.uniform(-2, 2) if is_active else 20.0}
                        for i in range(m.get("hashboard_count", 3))
                    ]
                
                # pools
                if "pools" not in m:
                    m["pools"] = [
                        {"url": m.get("pool_url", ""), "worker": m.get("pool_username", ""), "status": "active" if is_active else "inactive"}
                    ]
                
                # expected metrics
                m["expected_hashrate"] = m.get("hashrate", 110.0)
                m["expected_chips"] = 360 if is_active else 0
                m["expected_fans"] = m.get("fan_count", 4)
                m["env_temp"] = 22.0 + random.uniform(-1, 1) if is_active else 20.0
                m["wattage_limit"] = int(m.get("power_consumption", 3250))
                m["fan_psu"] = random.randint(2500, 3500) if is_active else 0
                m["percent_expected_chips"] = 100.0 if is_active else 0.0
                
            if dry_run:
                logger.info(f"[DRY-RUN] Sending simulated telemetry for site {site_id} (count: {len(miners)})")
            else:
                logger.info(f"Sending simulated telemetry for site {site_id} (count: {len(miners)})")
                await self._notify_api(miners, site_id, api_key)
                await self._send_site_heartbeat(site_id, api_key, miners)

    async def _send_site_heartbeat(self, site_id: str, api_key: str, miners: List[Dict[str, Any]]):
        """Send periodic site heartbeat (legacy path)"""
        main_app_url = self.config.get('main_app_url', 'http://localhost:8001')
        url = f"{main_app_url}/api/v1/cron-data/heartbeat/{site_id}"
        headers = {
            "Authorization": f"Bearer {api_key}"
        }
        active_miners = len([m for m in miners if m.get('status') == 'active'])
        total_miners = len(miners)
        total_hashrate = sum(m.get('hashrate', 0.0) for m in miners)
        total_power = sum(m.get('power_consumption', 0.0) for m in miners)
        payload = {
            "active_miners": active_miners,
            "total_miners": total_miners,
            "total_hashrate": total_hashrate,
            "total_power": total_power,
            "version": AGENT_VERSION
        }
        try:
            connector = aiohttp.TCPConnector(ssl=False)
            async with aiohttp.ClientSession(connector=connector) as session:
                async with session.post(url, json=payload, headers=headers, timeout=10) as response:
                    if response.status == 200:
                        logger.info(f"Sent site heartbeat successfully for {site_id}")
        except Exception as e:
            logger.warning(f"Could not send site heartbeat for {site_id}: {e}")


async def main():
    parser = argparse.ArgumentParser(description="Mole Miner Scanner Linux/Windows Agent")
    parser.add_argument("--config", default="config.yaml", help="Path to config.yaml")
    parser.add_argument("--dry-run", action="store_true", help="Scan and output without saving")
    parser.add_argument("--loop", type=int, default=0, help="Loop interval in seconds (0 to disable)")
    
    args = parser.parse_args()
    
    # Get loop interval from CLI arg or environment variable
    loop_interval = args.loop or int(os.environ.get("LOOP_INTERVAL", "0"))
    
    agent = MoleAgent(args.config)
    
    if loop_interval > 0:
        logger.info(f"Starting Mole Agent daemon loop (interval: {loop_interval}s)")
        # Listen for miner "IP Report" button broadcasts between scan cycles
        try:
            await agent.start_ip_report_listener()
        except Exception as e:
            logger.warning(f"Could not start IP Report listener: {e}")
        while True:
            try:
                await agent.run(dry_run=args.dry_run)
            except Exception as e:
                logger.error(f"Error during scan cycle: {e}")
            logger.info(f"Sleeping for {loop_interval} seconds...")
            await asyncio.sleep(loop_interval)
    else:
        await agent.run(dry_run=args.dry_run)

if __name__ == "__main__":
    asyncio.run(main())
