#!/usr/bin/env python3
"""
TMP117 Device Scanner — Fast network scanner for TMP117 devices on port 5025.

Scans local networks for devices with port 5025 open, then verifies they are
TMP117 instruments by querying *IDN? via SCPI. Supports concurrent scanning
for maximum speed (~3-5 seconds for a /24 network).

Usage (installed via pip):
    # Scan default network (auto-detected)
    tmp117-scan

    # Scan specific network
    tmp117-scan -n 192.168.1.0/24

    # JSON output
    tmp117-scan -n 192.168.1.0/24 --json

    # Save results to file
    tmp117-scan -n 192.168.1.0/24 -o devices.txt

    # Verbose mode (show all port checks)
    tmp117-scan -v

(c) 2026 Prof. Flavio ABREU ARAUJO. All rights reserved.
"""

from __future__ import annotations

import argparse
import concurrent.futures
import json
import socket
import sys
import time
from ipaddress import IPv4Network, ip_network

# ---------------------------------------------------------------------------
# ANSI Color Codes
# ---------------------------------------------------------------------------
CYAN = "\033[96m"
YELLOW = "\033[93m"
GREEN = "\033[92m"
MAGENTA = "\033[95m"
RED = "\033[91m"
BLUE = "\033[94m"
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"

DEFAULT_SCPI_PORT = 5025
DEFAULT_PORT_TIMEOUT = 0.5  # seconds
DEFAULT_SCPI_TIMEOUT = 1.5  # seconds
DEFAULT_MAX_WORKERS = 100


# ---------------------------------------------------------------------------
# Network Utilities
# ---------------------------------------------------------------------------
def get_local_network() -> str | None:
    """Auto-detect local network CIDR (e.g., 192.168.1.0/24)."""
    try:
        # Create a socket to find the local IP
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.settimeout(0.1)
        # Connect to a public DNS server (doesn't actually send data)
        s.connect(("8.8.8.8", 80))
        local_ip = s.getsockname()[0]
        s.close()

        # Convert to network address with /24 subnet
        network = ip_network(f"{local_ip}/24", strict=False)
        return str(network)
    except Exception:
        return None


def get_all_local_networks() -> list[str]:
    """
    Get all local network CIDRs for all network interfaces.

    Returns:
        List of network CIDR strings (e.g., ['192.168.1.0/24', '10.0.0.0/24'])
    """
    networks = []

    try:
        import subprocess

        # Use ifconfig to get all network interfaces
        result = subprocess.run(["ifconfig"], capture_output=True, text=True, timeout=2)

        if result.returncode != 0:
            return []

        # Parse ifconfig output to find inet addresses
        current_iface = None
        for line in result.stdout.split("\n"):
            line = line.strip()

            # New interface
            if line and not line.startswith(" ") and ":" in line:
                current_iface = line.split(":")[0]

            # IPv4 address line
            if line.startswith("inet ") and current_iface:
                parts = line.split()
                if len(parts) >= 4:
                    ip_addr = parts[1]
                    netmask = parts[3]

                    # Skip loopback
                    if ip_addr.startswith("127."):
                        continue

                    # Skip link-local
                    if ip_addr.startswith("169.254."):
                        continue

                    try:
                        # Convert netmask to CIDR prefix length
                        if netmask.startswith("0x"):
                            # Hex format (macOS)
                            netmask_int = int(netmask, 16)
                            prefix_len = bin(netmask_int).count("1")
                        else:
                            # Dotted decimal format
                            netmask_parts = [int(x) for x in netmask.split(".")]
                            netmask_int = (
                                (netmask_parts[0] << 24)
                                + (netmask_parts[1] << 16)
                                + (netmask_parts[2] << 8)
                                + netmask_parts[3]
                            )
                            prefix_len = bin(netmask_int).count("1")

                        # Create network CIDR
                        network = ip_network(f"{ip_addr}/{prefix_len}", strict=False)
                        network_str = str(network)

                        if network_str not in networks:
                            networks.append(network_str)

                    except (ValueError, IndexError):
                        continue

    except (subprocess.TimeoutExpired, FileNotFoundError):
        pass

    return networks


# ---------------------------------------------------------------------------
# Port Scanning
# ---------------------------------------------------------------------------
def check_port(
    ip: str, port: int = DEFAULT_SCPI_PORT, timeout: float = DEFAULT_PORT_TIMEOUT
) -> bool:
    """
    Check if TCP port is open on given IP address.

    Args:
        ip: IP address to check
        port: TCP port number (default: 5025)
        timeout: Connection timeout in seconds (default: 0.5)

    Returns:
        True if port is open, False otherwise
    """
    try:
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
            sock.settimeout(timeout)
            result = sock.connect_ex((ip, port))
            return result == 0
    except (socket.timeout, OSError):
        return False


def verify_tmp117(
    ip: str, port: int = DEFAULT_SCPI_PORT, timeout: float = DEFAULT_SCPI_TIMEOUT
) -> str | None:
    """
    Verify if device is a TMP117 by sending *IDN? SCPI query.

    Args:
        ip: IP address of device
        port: SCPI port (default: 5025)
        timeout: SCPI query timeout in seconds (default: 1.5)

    Returns:
        Device identification string if TMP117, None otherwise
    """
    try:
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
            sock.settimeout(timeout)
            sock.connect((ip, port))

            # Send *IDN? query
            sock.sendall(b"*IDN?\n")

            # Read response
            response = sock.recv(1024).decode().strip()

            # Check if response contains TMP117 identifier
            if "TMP117" in response.upper():
                return response

    except (socket.timeout, OSError, UnicodeDecodeError):
        pass

    return None


# ---------------------------------------------------------------------------
# Network Scanner
# ---------------------------------------------------------------------------
def scan_network(
    network: str,
    port: int = DEFAULT_SCPI_PORT,
    max_workers: int = DEFAULT_MAX_WORKERS,
    verbose: bool = False,
    progress_callback=None,
) -> list[tuple[str, str]]:
    """
    Scan network for TMP117 devices on specified port.

    Args:
        network: Network in CIDR notation (e.g., "192.168.1.0/24")
        port: TCP port to scan (default: 5025)
        max_workers: Number of concurrent scanning threads (default: 100)
        verbose: Print detailed progress (default: False)
        progress_callback: Optional callback function(current, total)

    Returns:
        List of tuples (ip_address, device_id_string)
    """
    try:
        net = IPv4Network(network, strict=False)
    except ValueError as e:
        print(f"{RED}✗ Invalid network: {network}{RESET}")
        print(f"  Error: {e}")
        return []

    hosts = [str(ip) for ip in net.hosts()]
    total_hosts = len(hosts)

    if verbose:
        print(f"{CYAN}Scanning {network} ({total_hosts} hosts) for port {port}...{RESET}")

    devices_found: list[tuple[str, str]] = []
    ports_open = []
    checked_count = 0

    # Stage 1: Fast port scan
    with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
        future_to_ip = {executor.submit(check_port, ip, port): ip for ip in hosts}

        for future in concurrent.futures.as_completed(future_to_ip):
            ip = future_to_ip[future]
            checked_count += 1

            if progress_callback:
                progress_callback(checked_count, total_hosts)

            try:
                is_open = future.result()
                if is_open:
                    ports_open.append(ip)
                    if verbose:
                        print(f"{YELLOW}  Port {port} open: {ip}{RESET}")
            except Exception as e:
                if verbose:
                    print(f"{RED}  Error checking {ip}: {e}{RESET}")

    # Stage 2: Verify TMP117 devices (only for IPs with open ports)
    if verbose and ports_open:
        print(f"{CYAN}Verifying {len(ports_open)} device(s)...{RESET}")

    for ip in ports_open:
        idn = verify_tmp117(ip, port)
        if idn:
            devices_found.append((ip, idn))
            if verbose:
                print(f"{GREEN}  ✓ TMP117 found: {ip} → {idn}{RESET}")
        else:
            if verbose:
                print(f"{DIM}  ✗ Not a TMP117: {ip}{RESET}")

    return devices_found


# ---------------------------------------------------------------------------
# Output Formatters
# ---------------------------------------------------------------------------
def format_table(devices: list[tuple[str, str]]) -> str:
    """Format device list as a table."""
    if not devices:
        return f"{YELLOW}No TMP117 devices found.{RESET}"

    lines = [
        "",
        f"{BOLD}{'=' * 70}{RESET}",
        f"{BOLD}Found {len(devices)} TMP117 device(s):{RESET}",
        f"{BOLD}{'=' * 70}{RESET}",
    ]

    for ip, idn in devices:
        lines.append(f"{GREEN}  • {ip:15s}{RESET} - {idn}")

    lines.append(f"{BOLD}{'=' * 70}{RESET}")
    return "\n".join(lines)


def format_json(devices: list[tuple[str, str]]) -> str:
    """Format device list as JSON."""
    device_list = [{"ip": ip, "id": idn} for ip, idn in devices]
    return json.dumps({"devices": device_list, "count": len(device_list)}, indent=2)


def format_simple(devices: list[tuple[str, str]]) -> str:
    """Format device list as simple IP list."""
    return "\n".join(ip for ip, _ in devices)


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> int:
    """Main entry point for tmp117-scan command."""
    parser = argparse.ArgumentParser(
        description="Scan network for TMP117 temperature instruments on port 5025",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  %(prog)s                          # Scan auto-detected local network
  %(prog)s -n 192.168.1.0/24        # Scan specific network
  %(prog)s -n 10.0.0.0/16 -w 200    # Scan large network with more workers
  %(prog)s --json                   # JSON output
  %(prog)s -o devices.txt           # Save to file
  %(prog)s -v                       # Verbose output

(c) 2026 Prof. Flavio ABREU ARAUJO. All rights reserved.
        """,
    )

    parser.add_argument(
        "-n",
        "--network",
        help="Network to scan in CIDR notation (e.g., 192.168.1.0/24). "
        "Auto-detects local network if not specified.",
    )

    parser.add_argument(
        "-p",
        "--port",
        type=int,
        default=DEFAULT_SCPI_PORT,
        help=f"SCPI port to scan (default: {DEFAULT_SCPI_PORT})",
    )

    parser.add_argument(
        "-w",
        "--workers",
        type=int,
        default=DEFAULT_MAX_WORKERS,
        help=f"Number of parallel scanning threads (default: {DEFAULT_MAX_WORKERS})",
    )

    parser.add_argument(
        "-o",
        "--output",
        help="Save results to file (default: print to stdout)",
    )

    parser.add_argument(
        "-v",
        "--verbose",
        action="store_true",
        help="Show detailed scanning progress",
    )

    format_group = parser.add_mutually_exclusive_group()
    format_group.add_argument(
        "--json",
        action="store_true",
        help="Output results as JSON",
    )
    format_group.add_argument(
        "--simple",
        action="store_true",
        help="Output only IP addresses (one per line)",
    )

    args = parser.parse_args()

    # Determine network(s) to scan
    if args.network:
        # Single network specified by user
        networks = [args.network]
        if args.verbose:
            print(f"{CYAN}Scanning specified network: {args.network}{RESET}")
        else:
            print(f"{CYAN}Scanning {args.network} for TMP117 devices...{RESET}")
    else:
        # Auto-detect all local networks
        networks = get_all_local_networks()
        if not networks:
            print(f"{RED}✗ Could not auto-detect local networks.{RESET}")
            print(f"{YELLOW}  Please specify network with -n/--network{RESET}")
            return 1

        if args.verbose:
            print(f"{CYAN}Auto-detected {len(networks)} network(s):{RESET}")
            for net in networks:
                print(f"{DIM}  • {net}{RESET}")
        else:
            print(f"{CYAN}Scanning {len(networks)} network(s) for TMP117 devices...{RESET}")

    # Start scan
    start_time = time.time()

    # Scan all networks concurrently
    devices = []
    for network in networks:
        network_devices = scan_network(
            network=network,
            port=args.port,
            max_workers=args.workers,
            verbose=args.verbose,
        )
        devices.extend(network_devices)

    elapsed = time.time() - start_time

    # Format output
    if args.json:
        output = format_json(devices)
    elif args.simple:
        output = format_simple(devices)
    else:
        output = format_table(devices)

    # Print or save
    if args.output:
        try:
            with open(args.output, "w") as f:
                # Write plain text (no ANSI codes) to file
                if args.json or args.simple:
                    f.write(output)
                else:
                    # Strip ANSI codes for file output
                    import re

                    clean_output = re.sub(r"\033\[[0-9;]+m", "", output)
                    f.write(clean_output)
            print(f"{GREEN}✓ Results saved to {args.output}{RESET}")
            print(f"{DIM}  Found {len(devices)} device(s) in {elapsed:.2f}s{RESET}")
        except OSError as e:
            print(f"{RED}✗ Error writing to {args.output}: {e}{RESET}")
            return 1
    else:
        print(output)
        if not args.simple and not args.json:
            print(f"{DIM}Scan completed in {elapsed:.2f}s{RESET}")

    return 0


if __name__ == "__main__":
    sys.exit(main())
