# lan_inventory.py - liệt kê thiết bị trong mạng nhà của BẠN:
#                    IP, MAC, hãng sản xuất, tên, port mở.
# Chỉ chạy trên mạng của chính bạn hoặc nơi được cho phép.
# Chỉ dùng thư viện có sẵn của Python 3.
import argparse
import ipaddress
import json
import platform
import re
import socket
import subprocess
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path

# Bảng port quen thuộc: số port -> tên dịch vụ thường chạy ở đó.
# Công cụ chỉ thử các port có trong bảng này.
PORTS = {21: "ftp", 22: "ssh", 23: "telnet", 53: "dns", 80: "http", 139: "netbios", 443: "https",
         445: "smb", 554: "rtsp (camera)", 631: "ipp (may in)", 3389: "rdp", 5000: "http-alt/airplay",
         7000: "airplay", 8000: "http-alt", 8008: "chromecast", 8080: "http-proxy", 8443: "https-alt",
         9100: "may in", 62078: "iphone-sync"}
MAX_HOSTS = 1024   # giới hạn số địa chỉ mỗi lần quét, để gõ nhầm dải quá lớn không làm nghẽn mạng


def local_ip():
    # Mục đích: biết IP của máy mình trong mạng LAN (một máy có thể có nhiều card mạng).
    # connect() với UDP KHÔNG gửi gói nào ra mạng; nó chỉ bắt hệ điều hành chọn
    # card mạng sẽ dùng để tới địa chỉ 192.0.2.1 (địa chỉ dành riêng cho tài liệu,
    # không ai dùng). Sau đó ta đọc IP của card mạng đó.
    with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
        s.connect(("192.0.2.1", 9))
        return s.getsockname()[0]


def detect_network(ip):
    # Đọc mặt nạ mạng thật của card mạng (cho biết dải IP gồm bao nhiêu địa chỉ).
    # Không đọc được thì coi là /24 (256 địa chỉ, mạng nhà phổ biến nhất).
    system = platform.system()
    try:
        if system == "Linux":
            out = subprocess.run(["ip", "-o", "-4", "addr"], capture_output=True, text=True).stdout
            m = re.search(re.escape(ip) + r"/(\d+)", out)
            if m:
                return ipaddress.ip_network(f"{ip}/{m.group(1)}", strict=False)
        elif system == "Windows":
            lines = subprocess.run(["ipconfig"], capture_output=True, text=True).stdout.splitlines()
            for i, line in enumerate(lines):
                if ip in line:
                    for nxt in lines[i + 1:i + 3]:
                        m = re.search(r"(255\.\d+\.\d+\.\d+)", nxt)
                        if m:
                            return ipaddress.ip_network(f"{ip}/{m.group(1)}", strict=False)
        else:
            out = subprocess.run(["ifconfig"], capture_output=True, text=True).stdout
            m = re.search(r"inet " + re.escape(ip) + r" netmask (0x[0-9a-f]+)", out)
            if m:
                prefix = bin(int(m.group(1), 16)).count("1")
                return ipaddress.ip_network(f"{ip}/{prefix}", strict=False)
    except (OSError, ValueError):
        pass
    return ipaddress.ip_network(f"{ip}/24", strict=False)


def normalize_mac(raw):
    # macOS in MAC không đệm số 0 ("a:b:c:1:2:3"), Windows dùng dấu gạch nối: đưa về
    # dạng chuẩn aa:bb:cc:dd:ee:ff
    parts = re.split(r"[:-]", raw.lower())
    return ":".join(p.zfill(2) for p in parts) if len(parts) == 6 else None


def read_neighbors():
    system = platform.system()
    if system == "Linux":
        out = subprocess.run(["ip", "neigh"], capture_output=True, text=True).stdout
        pattern = r"^(\d+\.\d+\.\d+\.\d+) .*lladdr ([0-9a-f:]{11,17})"
    else:
        out = subprocess.run(["arp", "-a"], capture_output=True, text=True).stdout
        pattern = r"(\d+\.\d+\.\d+\.\d+)\)? (?:at )?\s*([0-9a-fA-F]{1,2}(?:[:-][0-9a-fA-F]{1,2}){5})"
    found = {}
    for line in out.splitlines():
        m = re.search(pattern, line)
        if m and normalize_mac(m.group(2)):
            found[m.group(1)] = normalize_mac(m.group(2))
    return found


def is_unicast(ip, mac):
    a = ipaddress.ip_address(ip)
    return not (a.is_multicast or ip.endswith(".255") or mac == "ff:ff:ff:ff:ff:ff" or mac.startswith("01:00:5e"))


def poke(ip):
    # MỤC ĐÍCH: "gõ cửa" một IP để máy phải hỏi ARP "ai giữ IP này?".
    # Thiết bị nào có thật cũng trả lời ARP (kể cả khi nó chặn ping), nên MAC của
    # nó sẽ nằm trong bảng ARP và ta đọc được.
    try:
        # [1] Tạo socket UDP (SOCK_DGRAM): chỉ "ném" gói đi, không bắt tay, không
        #     chờ trả lời
        with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
            # [2] Gửi 1 byte rỗng tới port 9. Nội dung không quan trọng; điều quan
            #     trọng là hệ điều hành phải hỏi ARP để biết MAC của IP này TRƯỚC
            #     khi đưa gói ra card mạng
            s.sendto(b"\0", (ip, 9))
    except OSError:
        # [3] Thiết bị trả lời "port này không mở" cũng có thể gây lỗi; ta không cần
        #     kết quả đó -> bỏ qua
        pass


def load_oui(path):
    table = {}
    if path and Path(path).exists():
        for line in Path(path).read_text(encoding="utf-8", errors="ignore").splitlines():
            if "(hex)" in line:
                prefix, _, name = line.partition("(hex)")
                table[prefix.strip().replace("-", ":").lower()] = name.strip()
    return table


def describe_mac(mac, oui):
    # [1] Đọc byte đầu của MAC. Bit 0x02 bật = MAC do thiết bị tự đặt/ngẫu nhiên
    #     (điện thoại hay làm vậy để khó bị theo dõi) -> không có hãng thật nào để
    #     tra, trả lời luôn
    if int(mac[:2], 16) & 0x02:
        return "MAC rieng/ngau nhien (dien thoai thuong bat tinh nang nay)"
    # [2] MAC thật: 8 ký tự đầu ("aa:bb:cc" = 3 byte OUI) là mã hãng; tra trong bảng
    #     oui.txt (không có thì nói rõ lý do)
    return oui.get(mac[:8], "khong co trong oui.txt" if oui else "chua co oui.txt")


def ttl_of(ip):
    # Gửi đúng 1 gói ping tới thiết bị ĐÃ TÌM THẤY để đọc TTL.
    # (Việc tìm thiết bị không dựa vào ping.)
    # [1] Mỗi hệ điều hành viết lệnh ping khác nhau: -n (Windows) / -c (macOS,
    #     Linux) là số gói; -w, -t, -W là thời gian chờ
    system = platform.system()
    if system == "Windows":
        cmd = ["ping", "-n", "1", "-w", "1000", ip]
    elif system == "Darwin":
        cmd = ["ping", "-c", "1", "-t", "2", ip]
    else:
        cmd = ["ping", "-c", "1", "-W", "1", ip]
    # [2] Chạy lệnh ping và đọc chữ nó in ra; chờ tối đa 4 giây. Lỗi hoặc quá hạn
    #     thì trả None (không rõ)
    try:
        out = subprocess.run(cmd, capture_output=True, text=True, timeout=4).stdout
    except (OSError, subprocess.SubprocessError):
        return None
    # [3] Tìm trong chữ đó đoạn "ttl=64" (hoặc "TTL=128"...) bằng regex; re.I =
    #     không phân biệt hoa thường
    m = re.search(r"ttl[=:]\s*(\d+)", out, re.I)
    # [4] Có thì trả về con số TTL, không thì trả None
    return int(m.group(1)) if m else None


def guess_os(ttl):
    # TTL khởi đầu của gói trả lời: ~64 Linux/macOS/iOS/Android, ~128 Windows,
    # ~255 thiết bị mạng. Chỉ là gợi ý. (Gói đi qua mỗi router bị trừ 1 nên số
    # nhận được có thể nhỏ hơn số khởi đầu một chút, vì vậy ta chia theo KHOẢNG.)
    # [1] Không đọc được TTL (thiết bị chặn ping): không đoán bừa
    if ttl is None:
        return "khong ro (thiet bi chan ping)"
    # [2] TTL từ 64 trở xuống: nhóm Linux/macOS/iOS/Android
    if ttl <= 64:
        return f"nhom Linux/macOS/iOS/Android (TTL {ttl})"
    # [3] TTL trên 64 đến 128: nhóm Windows
    if ttl <= 128:
        return f"nhom Windows (TTL {ttl})"
    # [4] Còn lại (tới 255): thiết bị mạng hoặc máy in
    return f"thiet bi mang/may in (TTL {ttl})"


# Bảng 1 - đoán theo CỔNG: thiết bị mở MỘT TRONG các cổng bên trái
#          thì gợi ý nhãn bên phải.
HINTS = [({631, 9100}, "may in"), ({554}, "camera"), ({62078}, "iPhone/iPad"), ({8008, 8009}, "Chromecast/TV Google"),
         ({3389}, "may Windows (Remote Desktop)"), ({445, 139}, "chia se file Windows/NAS"), ({22}, "may Linux/NAS/router (SSH)"),
         ({5000, 7000}, "Mac/Apple TV (AirPlay)"), ({23}, "thiet bi cu (Telnet): coi chung")]
# Bảng 2 - đoán theo TÊN HOST: tên (viết thường) có chứa từ khóa bên trái
#          thì gợi ý nhãn bên phải.
NAME_HINTS = [("iphone", "iPhone"), ("ipad", "iPad"), ("galaxy", "Samsung Galaxy"), ("android", "Android"),
              ("macbook", "Mac"), ("printer", "may in"), ("tv", "TV"), ("cam", "camera"), ("router", "router")]


def guess_device(device):
    # Ghép các manh mối thành nhãn gợi ý: cổng mở (HINTS) + tên host (NAME_HINTS).
    # [1] Lấy tập các số port đang mở của thiết bị
    open_ports = {p["port"] for p in device["ports"]}
    # [2] Với mỗi dòng của HINTS: nếu tập cổng của dòng đó GIAO với cổng đang mở
    #     (phép &) thì lấy nhãn của dòng đó
    clues = [label for ports, label in HINTS if open_ports & ports]
    # [3] Đổi tên host sang chữ thường để so khớp không phân biệt hoa/thường
    name = device["hostname"].lower()
    # [4] Với mỗi từ khóa của NAME_HINTS có trong tên host: thêm nhãn tương ứng
    clues += [label for key, label in NAME_HINTS if key in name]
    # [5] set() bỏ nhãn trùng, sorted() sắp xếp để kết quả ổn định giữa các lần chạy
    return sorted(set(clues))


def hostname(ip):
    try:
        return socket.gethostbyaddr(ip)[0]
    except OSError:
        return ""


def grab_banner(ip, port):
    try:
        with socket.create_connection((ip, port), timeout=0.6) as s:
            s.settimeout(0.6)
            if port in (80, 8000, 8008, 8080):
                s.sendall(b"HEAD / HTTP/1.0\r\n\r\n")
            data = s.recv(200).decode("utf-8", errors="replace")
    except OSError:
        return ""
    for line in data.splitlines():
        if line.lower().startswith("server:"):
            return line[7:].strip()
    return data.splitlines()[0][:60] if data else ""


def scan_ports(ip):
    # Tìm các port đang mở của MỘT thiết bị: thử kết nối TCP tới từng port trong PORTS.
    def probe(port):
        # [1] Thử kết nối TCP tới 1 port, chờ tối đa 0,5 giây. connect_ex() trả 0
        #     nếu kết nối thành công (= port mở), trả mã lỗi khác 0 nếu không (không
        #     ném exception như connect()). Mở -> trả số port, không mở -> None
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            s.settimeout(0.5)
            return port if s.connect_ex((ip, port)) == 0 else None
    # [2] Thử mọi port CÙNG LÚC bằng nhiều luồng (thread), không thử lần lượt: nhanh
    #     hơn rất nhiều vì phần lớn thời gian là ĐỢI
    with ThreadPoolExecutor(max_workers=len(PORTS)) as pool:
        # [3] Giữ lại port mở (bỏ None), sắp xếp từ nhỏ đến lớn
        open_ports = sorted(p for p in pool.map(probe, PORTS) if p)
    # [4] Với mỗi port mở: ghi tên dịch vụ quen thuộc và đọc "banner" (câu tự giới
    #     thiệu của dịch vụ, nếu có)
    return [{"port": p, "service": PORTS[p], "banner": grab_banner(ip, p)} for p in open_ports]


def build_inventory(network, oui, with_ports):
    hosts = [str(h) for h in network.hosts()] or [str(network.network_address)]
    # [1] "Gõ cửa" mọi địa chỉ trong dải cùng lúc bằng 64 luồng (poke() ở trên)
    with ThreadPoolExecutor(max_workers=64) as pool:
        list(pool.map(poke, hosts))
    # [2] Đợi 2 giây để các thiết bị kịp trả lời ARP và hệ điều hành kịp ghi vào
    #     bảng ARP
    time.sleep(2)
    # [3] Đọc bảng ARP (IP -> MAC), chỉ giữ IP thuộc dải đang quét và bỏ địa chỉ
    #     broadcast/multicast (không phải thiết bị thật)
    neighbors = {ip: mac for ip, mac in read_neighbors().items() if ip in hosts and is_unicast(ip, mac)}
    me = local_ip()
    if me in hosts:
        neighbors.setdefault(me, "(may nay)")
    devices = []
    for ip in sorted(neighbors, key=lambda x: ipaddress.ip_address(x)):
        mac = neighbors[ip]
        is_self = mac == "(may nay)"
        devices.append({"ip": ip, "mac": mac,
                        "vendor": "may cua ban" if is_self else describe_mac(mac, oui),
                        "hostname": hostname(ip),
                        "ports": scan_ports(ip) if with_ports else []})
        devices[-1]["os_guess"] = guess_os(ttl_of(ip)) if with_ports else ""
        devices[-1]["guess"] = guess_device(devices[-1])
    return devices


def _dev(ip, mac, vendor, host, ports=(), ttl=None):
    d = {"ip": ip, "mac": mac, "vendor": vendor, "hostname": host,
         "ports": [{"port": p, "service": PORTS.get(p, "?"), "banner": b} for p, b in ports]}
    d["os_guess"] = guess_os(ttl)
    d["guess"] = guess_device(d)
    return d


def demo_inventory(day):
    # Dữ liệu MẪU để học đọc kết quả, không quét mạng thật. Hãng và tên là giả định.
    private = "MAC rieng/ngau nhien (dien thoai thuong bat tinh nang nay)"
    devices = [
        _dev("192.168.1.1", "f4:ec:38:11:22:33", "Hang router A", "router.lan", [(80, "Server: lighttpd")], 64),
        _dev("192.168.1.23", "6a:6c:4a:21:dc:21", private, "macbook-cua-ban.lan", ttl=64),
        _dev("192.168.1.40", "3c:22:fb:aa:bb:cc", "Hang may in X", "npi-3f2a",
             [(80, "Server: Embedded HTTP Server"), (631, ""), (9100, "")], 255),
        _dev("192.168.1.45", "9c:b2:00:44:55:66", "Hang camera Y", "cam-phong-1",
             [(23, ""), (80, "Server: CamHTTPD/1.0"), (554, "")], 64),
        _dev("192.168.1.60", "3a:5b:7c:12:34:56", private, "galaxy-s24.lan", ttl=64),
        _dev("192.168.1.70", "00:1b:21:aa:bb:cc", "Hang may tinh Z", "PC-THUQUY", [(445, ""), (3389, "")], 128),
    ]
    if day == 1:
        devices.append(_dev("192.168.1.52", "da:a1:19:00:00:01", private, "", [(62078, "")], 64))
    else:
        devices[-1] = _dev("192.168.1.70", "00:1b:21:aa:bb:cc", "Hang may tinh Z", "PC-THUQUY",
                           [(445, ""), (8000, "Server: SimpleHTTP/0.6 Python/3.9.6")], 128)
        devices.append(_dev("192.168.1.77", "c2:11:aa:bb:cc:dd", private, "", ttl=64))
    return sorted(devices, key=lambda d: ipaddress.ip_address(d["ip"]))


def show(devices):
    print(f"{'IP':<16}{'MAC':<19}{'Hang / ghi chu':<34}Ten")
    for d in devices:
        print(f"{d['ip']:<16}{d['mac']:<19}{d['vendor'][:32]:<34}{d['hostname'][:30]}")
        for p in d["ports"]:
            print(f"    mo port {p['port']:<6}{p['service']:<20}{p['banner']}")
        if d.get("guess") or d.get("os_guess"):
            print(f"    DOAN: {', '.join(d.get('guess', [])) or 'chua du dau hieu'} | HDH: {d.get('os_guess') or '-'}")
    print(f"\nTong cong: {len(devices)} thiet bi tra loi.")


def compare(devices, baseline_path):
    old = {d["mac"]: d for d in json.loads(Path(baseline_path).read_text(encoding="utf-8"))}
    now = {d["mac"]: d for d in devices}
    new = [d for mac, d in now.items() if mac not in old]
    gone = [d for mac, d in old.items() if mac not in now]
    print("\nThiet bi MOI so voi lan truoc:", [f"{d['ip']} ({d['mac']})" for d in new] or "khong co")
    print("Thiet bi KHONG con tra loi:", [f"{d['ip']} ({d['mac']})" for d in gone] or "khong co")
    for mac in sorted(now.keys() & old.keys()):
        before = {p["port"] for p in old[mac]["ports"]}
        after = {p["port"] for p in now[mac]["ports"]}
        if before != after:
            print(f"Port THAY DOI o {now[mac]['ip']}: moi mo {sorted(after - before)}, da dong {sorted(before - after)}")


def main():
    ap = argparse.ArgumentParser(description="Liet ke thiet bi trong mang nha cua ban")
    ap.add_argument("--net", help="dai mang, vi du 192.168.1.0/24 (mac dinh: tu doc mat na mang cua may ban)")
    ap.add_argument("--oui", default="oui.txt", help="file oui.txt cua IEEE de tra hang san xuat")
    ap.add_argument("--no-ports", action="store_true", help="khong quet port")
    ap.add_argument("--save", default="inventory.json", help="file luu ket qua")
    ap.add_argument("--baseline", help="file ket qua lan truoc de so sanh thiet bi moi")
    ap.add_argument("--demo", type=int, choices=[1, 2], help="dung du lieu MAU (ngay 1 hoac ngay 2), khong quet mang that")
    ap.add_argument("--only", help="chi quet port cua mot IP rieng (vi du 127.0.0.1), bo qua buoc tim thiet bi")
    args = ap.parse_args()
    if args.only:
        ip = ipaddress.ip_address(args.only)
        if not ip.is_private:
            raise SystemExit("Chi quet IP rieng (192.168.x.x, 10.x.x.x, 127.0.0.1...).")
        found = scan_ports(str(ip))
        print(f"Port mo tren {ip}:", ", ".join(f"{p['port']}/{p['service']}" for p in found) or "khong co")
        for p in found:
            print(f"    {p['port']:<6}{p['service']:<20}{p['banner']}")
        guess = guess_device({"ports": found, "hostname": hostname(str(ip))})
        print("DOAN:", ", ".join(guess) or "chua du dau hieu")
        return
    if args.demo:
        print(f"[DU LIEU MAU ngay {args.demo}: khong quet mang that]")
        devices = demo_inventory(args.demo)
        show(devices)
        Path(args.save).write_text(json.dumps(devices, ensure_ascii=False, indent=2), encoding="utf-8")
        print(f"Da luu {args.save}")
        if args.baseline:
            compare(devices, args.baseline)
        return
    # [1] Dải mạng cần quét: người dùng gõ --net thì dùng, không thì tự đọc từ card
    #     mạng của máy
    network = ipaddress.ip_network(args.net, strict=False) if args.net else detect_network(local_ip())
    # [2] Chốt chặn an toàn: chỉ nhận dải IP RIÊNG và tối đa 1024 địa chỉ; ngoài ra
    #     thoát ngay, chưa gửi gói nào
    if not network.is_private or network.num_addresses > MAX_HOSTS:
        raise SystemExit("Chi quet dai IP rieng (192.168.x.x, 10.x.x.x...) va khong qua 1024 dia chi.")
    print(f"Dang kiem tra {network} ({network.num_addresses} dia chi), cho khoang 5-30 giay...")
    devices = build_inventory(network, load_oui(args.oui), not args.no_ports)
    show(devices)
    Path(args.save).write_text(json.dumps(devices, ensure_ascii=False, indent=2), encoding="utf-8")
    print(f"Da luu {args.save}")
    if args.baseline:
        compare(devices, args.baseline)


if __name__ == "__main__":
    main()
