#!/usr/bin/env python3
"""
netcheck — A fast, human-friendly network scanner CLI tool.
Usage:
    python3 netcheck.py 192.168.1.0/24
    python3 netcheck.py 192.168.1.0/24 --ports 22 80 443 8080
    python3 netcheck.py 192.168.1.0/24 --limit 20 --timeout 2
    python3 netcheck.py 192.168.1.0/24 --no-ports
    python3 netcheck.py 192.168.1.0/24 --output results.txt
"""

import subprocess
import socket
import sys
import ipaddress
import argparse
import datetime
from concurrent.futures import ThreadPoolExecutor, as_completed

try:
    from rich.console import Console
    from rich.table import Table
    from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn, TimeElapsedColumn
    from rich import box
    from rich.panel import Panel
    from rich.text import Text
    RICH_AVAILABLE = True
except ImportError:
    RICH_AVAILABLE = False

console = Console() if RICH_AVAILABLE else None

# ── Port name map ─────────────────────────────────────────────────────────────
PORT_NAMES = {
    21: "FTP", 22: "SSH", 23: "Telnet", 25: "SMTP",
    53: "DNS", 80: "HTTP", 110: "POP3", 143: "IMAP",
    443: "HTTPS", 445: "SMB", 3306: "MySQL", 3389: "RDP",
    5432: "PostgreSQL", 6379: "Redis", 8080: "HTTP-Alt", 8443: "HTTPS-Alt",
}

DEFAULT_PORTS = [22, 80, 443]


# ── Core functions ─────────────────────────────────────────────────────────────

def ping_host(ip: str, timeout: int = 1) -> bool:
    """Ping a host once. Returns True if reachable."""
    try:
        result = subprocess.run(
            ['ping', '-c', '1', '-W', str(timeout), str(ip)],
            capture_output=True, text=True
        )
        return result.returncode == 0
    except Exception:
        return False


def check_ports(ip: str, ports: list[int], timeout: float = 1.0) -> dict[int, bool]:
    """Check which ports are open on a host. Returns {port: is_open}."""
    results = {}
    for port in ports:
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.settimeout(timeout)
        open_ = sock.connect_ex((str(ip), port)) == 0
        sock.close()
        results[port] = open_
    return results


def scan_host(ip: str, ports: list[int], timeout: int, skip_ports: bool) -> dict:
    """Scan a single host: ping + optional port check."""
    is_up = ping_host(ip, timeout)
    port_results = {}
    if is_up and not skip_ports:
        port_results = check_ports(ip, ports, timeout)
    return {
        "ip": str(ip),
        "up": is_up,
        "ports": port_results,
    }


# ── Output helpers ─────────────────────────────────────────────────────────────

def format_ports(port_results: dict[int, bool]) -> str:
    """Summarise port results as a short string."""
    open_ports = [p for p, up in port_results.items() if up]
    if not open_ports:
        return "—"
    return ", ".join(
        f"{p}/{PORT_NAMES.get(p, '?')}" for p in sorted(open_ports)
    )


def plain_print(results: list[dict], network: str, elapsed: float):
    """Fallback plain-text output when rich is not installed."""
    up = [r for r in results if r["up"]]
    print(f"\nnetcheck — {network}  |  {len(results)} hosts scanned in {elapsed:.1f}s")
    print(f"Hosts up: {len(up)}  |  Hosts down: {len(results) - len(up)}\n")
    print(f"{'IP':<18} {'Status':<8} {'Open Ports'}")
    print("-" * 55)
    for r in results:
        status = "UP" if r["up"] else "DOWN"
        ports = format_ports(r["ports"]) if r["up"] else "—"
        print(f"{r['ip']:<18} {status:<8} {ports}")


def rich_print(results: list[dict], network: str, elapsed: float):
    """Rich table output."""
    up = [r for r in results if r["up"]]

    # Summary panel
    summary = (
        f"[bold cyan]Network:[/bold cyan] {network}   "
        f"[bold green]Up:[/bold green] {len(up)}   "
        f"[bold red]Down:[/bold red] {len(results) - len(up)}   "
        f"[dim]Scanned in {elapsed:.1f}s[/dim]"
    )
    console.print(Panel(summary, title="[bold white]netcheck[/bold white]", border_style="bright_black"))

    # Results table
    table = Table(
        box=box.ROUNDED,
        show_header=True,
        header_style="bold bright_white",
        border_style="bright_black",
        row_styles=["", "dim"],
        expand=False,
    )
    table.add_column("IP Address", style="cyan", min_width=16)
    table.add_column("Status", min_width=8, justify="center")
    table.add_column("Open Ports", style="yellow")

    for r in results:
        status_text = (
            Text("● UP", style="bold green") if r["up"]
            else Text("● DOWN", style="bold red")
        )
        ports_text = format_ports(r["ports"]) if r["up"] else "[dim]—[/dim]"
        table.add_row(r["ip"], status_text, ports_text)

    console.print(table)


def save_output(results: list[dict], network: str, elapsed: float, filepath: str):
    """Save a plain-text report to a file."""
    up = [r for r in results if r["up"]]
    timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    lines = [
        f"netcheck report — {timestamp}",
        f"Network : {network}",
        f"Scanned : {len(results)} hosts in {elapsed:.1f}s",
        f"Up/Down : {len(up)} up, {len(results) - len(up)} down",
        "",
        f"{'IP':<18} {'Status':<8} {'Open Ports'}",
        "-" * 55,
    ]
    for r in results:
        status = "UP" if r["up"] else "DOWN"
        ports = format_ports(r["ports"]) if r["up"] else "—"
        lines.append(f"{r['ip']:<18} {status:<8} {ports}")

    with open(filepath, "w") as f:
        f.write("\n".join(lines) + "\n")

    msg = f"Results saved to [bold]{filepath}[/bold]"
    console.print(f"\n[green]✔[/green] {msg}") if RICH_AVAILABLE else print(f"\n✔ Results saved to {filepath}")


# ── Main ───────────────────────────────────────────────────────────────────────

def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="netcheck",
        description="🔍 netcheck — fast network host & port scanner",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
examples:
  python3 netcheck.py 192.168.1.0/24
  python3 netcheck.py 10.0.0.0/24 --ports 22 80 8080 --limit 50
  python3 netcheck.py 192.168.1.0/24 --no-ports --output report.txt
  python3 netcheck.py 192.168.1.1/32 --timeout 2
        """,
    )
    parser.add_argument(
        "network",
        help="Target network in CIDR notation (e.g. 192.168.1.0/24) or single IP (e.g. 8.8.8.8/32)",
    )
    parser.add_argument(
        "--ports", "-p",
        nargs="+",
        type=int,
        default=DEFAULT_PORTS,
        metavar="PORT",
        help=f"Ports to check (default: {DEFAULT_PORTS})",
    )
    parser.add_argument(
        "--no-ports",
        action="store_true",
        help="Skip port scanning, only ping hosts",
    )
    parser.add_argument(
        "--limit", "-l",
        type=int,
        default=254,
        metavar="N",
        help="Max number of hosts to scan (default: 254)",
    )
    parser.add_argument(
        "--timeout", "-t",
        type=int,
        default=1,
        metavar="SECS",
        help="Ping/connect timeout in seconds (default: 1)",
    )
    parser.add_argument(
        "--workers", "-w",
        type=int,
        default=50,
        metavar="N",
        help="Parallel workers (default: 50)",
    )
    parser.add_argument(
        "--output", "-o",
        metavar="FILE",
        help="Save results to a text file",
    )
    parser.add_argument(
        "--up-only",
        action="store_true",
        help="Only show hosts that are UP",
    )
    return parser


def scan_network(args):
    # Validate network
    try:
        network_obj = ipaddress.ip_network(args.network, strict=False)
    except ValueError as e:
        err = f"Invalid network: {e}"
        console.print(f"[bold red]Error:[/bold red] {err}") if RICH_AVAILABLE else print(f"Error: {err}")
        sys.exit(1)

    hosts = list(network_obj.hosts()) or [network_obj.network_address]
    hosts = hosts[:args.limit]
    total = len(hosts)

    start = datetime.datetime.now()

    results = [None] * total

    if RICH_AVAILABLE:
        with Progress(
            SpinnerColumn(),
            TextColumn("[progress.description]{task.description}"),
            BarColumn(),
            TextColumn("[cyan]{task.completed}/{task.total}"),
            TimeElapsedColumn(),
            console=console,
            transient=True,
        ) as progress:
            task = progress.add_task(f"Scanning {args.network}", total=total)

            with ThreadPoolExecutor(max_workers=args.workers) as executor:
                future_to_idx = {
                    executor.submit(scan_host, ip, args.ports, args.timeout, args.no_ports): i
                    for i, ip in enumerate(hosts)
                }
                for future in as_completed(future_to_idx):
                    idx = future_to_idx[future]
                    results[idx] = future.result()
                    progress.advance(task)
    else:
        print(f"Scanning {args.network} ({total} hosts)...")
        with ThreadPoolExecutor(max_workers=args.workers) as executor:
            future_to_idx = {
                executor.submit(scan_host, ip, args.ports, args.timeout, args.no_ports): i
                for i, ip in enumerate(hosts)
            }
            done = 0
            for future in as_completed(future_to_idx):
                idx = future_to_idx[future]
                results[idx] = future.result()
                done += 1
                print(f"\r  {done}/{total}", end="", flush=True)
        print()

    elapsed = (datetime.datetime.now() - start).total_seconds()

    # Filter if --up-only
    display = [r for r in results if r] 
    if args.up_only:
        display = [r for r in display if r["up"]]

    # Print results
    if RICH_AVAILABLE:
        rich_print(display, args.network, elapsed)
    else:
        plain_print(display, args.network, elapsed)

    # Save if requested
    if args.output:
        save_output(display, args.network, elapsed, args.output)


def main():
    parser = build_parser()
    if len(sys.argv) == 1:
        parser.print_help()
        sys.exit(0)

    args = parser.parse_args()
    scan_network(args)


if __name__ == "__main__":
    main()