"""Step 095 reference: bounded TCP observations for your own authorized lab.

Python 3.10+; standard library only. No packets are sent on import.
"""
import argparse
from contextlib import closing
from datetime import datetime, timezone
import ipaddress
import json
import math
from pathlib import Path
import socket
import sqlite3
import sys

DEFAULT_PORTS = '22,80,443,445,3389,8000,8001,9000'
SCHEMA = {
    'scans': ['id', 'scan_time', 'scope', 'connection_errors'],
    'findings': ['id', 'scan_id', 'ip', 'port', 'banner'],
}


def make_ip_range(start, end):
    """Validate an ascending IPv4 range inside one /24, then enumerate it."""
    first, last = ipaddress.IPv4Address(start), ipaddress.IPv4Address(end)
    if int(first) > int(last):
        raise ValueError('start IP must not exceed end IP')
    if int(first) >> 8 != int(last) >> 8:
        raise ValueError('both IPs must belong to the same /24')
    return [str(ipaddress.IPv4Address(n)) for n in range(int(first), int(last) + 1)]


def parse_ports(text):
    """Return sorted unique TCP port numbers, with at most 64 entries."""
    parts = text.split(',')
    if not parts or any(not p.isascii() or not p.isdecimal() for p in parts):
        raise ValueError('ports must be comma-separated decimal integers')
    ports = sorted(set(map(int, parts)))
    if len(ports) > 64 or not all(1 <= p <= 65535 for p in ports):
        raise ValueError('use 1 to 64 ports, each between 1 and 65535')
    return ports


def scope_key(ips, ports, timeout):
    """Keep different target ranges, port lists and timeouts out of one baseline."""
    return json.dumps({'start': ips[0], 'end': ips[-1], 'ports': ports,
                       'timeout': timeout, 'probe': 'tcp-connect-v1'}, sort_keys=True)


def scan(ips, ports, timeout):
    """Observe successful TCP connects; a banner failure does not undo a connect."""
    found, errors = [], {}
    for ip in ips:
        for port in ports:
            with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
                sock.settimeout(timeout)
                try:
                    sock.connect((ip, port))
                except OSError as error:
                    key = type(error).__name__
                    errors[key] = errors.get(key, 0) + 1
                    continue
                try:
                    banner = sock.recv(64).decode('utf-8', 'replace').strip()
                except OSError:
                    banner = ''
                found.append((ip, port, banner))
    return found, errors


def initialize(conn):
    """Create a new lesson DB, or refuse an unrelated/older schema unchanged."""
    tables = {r[0] for r in conn.execute(
        "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")}
    if tables:
        if tables != set(SCHEMA):
            raise ValueError('existing DB is not this lesson schema; choose a new --db file')
        for table, columns in SCHEMA.items():
            actual = [r[1] for r in conn.execute('PRAGMA table_info(' + table + ')')]
            if actual != columns:
                raise ValueError('existing DB schema differs; choose a new --db file')
        return
    conn.executescript('''
        CREATE TABLE scans(
            id INTEGER PRIMARY KEY, scan_time TEXT NOT NULL,
            scope TEXT NOT NULL, connection_errors TEXT NOT NULL);
        CREATE TABLE findings(
            id INTEGER PRIMARY KEY, scan_id INTEGER NOT NULL REFERENCES scans(id),
            ip TEXT NOT NULL, port INTEGER NOT NULL, banner TEXT NOT NULL,
            UNIQUE(scan_id, ip, port));
    ''')


def save_scan(conn, scope, found, errors):
    """Commit one complete scan and all findings together, or roll back both."""
    with conn:
        cur = conn.execute(
            'INSERT INTO scans(scan_time,scope,connection_errors) VALUES(?,?,?)',
            (datetime.now(timezone.utc).isoformat(), scope, json.dumps(errors, sort_keys=True)))
        scan_id = cur.lastrowid
        conn.executemany('INSERT INTO findings(scan_id,ip,port,banner) VALUES(?,?,?,?)',
                         [(scan_id, ip, port, banner) for ip, port, banner in found])
    return scan_id


def comparison(conn, scan_id):
    """Compare this specific scan with the previous completed scan of its scope."""
    row = conn.execute('SELECT scope FROM scans WHERE id=?', (scan_id,)).fetchone()
    if row is None:
        raise ValueError('scan ID does not exist')
    previous = conn.execute(
        'SELECT id FROM scans WHERE scope=? AND id<? ORDER BY id DESC LIMIT 1',
        (row[0], scan_id)).fetchone()
    def endpoints(sid):
        return set(conn.execute('SELECT ip,port FROM findings WHERE scan_id=?', (sid,)))
    now = endpoints(scan_id)
    if previous is None:
        return {'previousScanId': None, 'baseline': True, 'new': [], 'lost': []}
    before = endpoints(previous[0])
    return {'previousScanId': previous[0], 'baseline': False,
            'new': sorted(now - before), 'lost': sorted(before - now)}


def report(conn, scan_id, errors):
    """Read this scan's observations from the DB and attach a bounded comparison."""
    devices = {}
    for ip, port in conn.execute(
            'SELECT ip,port FROM findings WHERE scan_id=? ORDER BY ip,port', (scan_id,)):
        devices.setdefault(ip, []).append(port)
    return {'scanId': scan_id, 'devices': devices,
            'observedOpenCount': sum(map(len, devices.values())),
            'connectionErrors': errors, **comparison(conn, scan_id)}


def show(result):
    print(f"Responding IP addresses: {len(result['devices'])}; observed open TCP ports: {result['observedOpenCount']}")
    for ip, ports in result['devices'].items():
        print('  ' + ip + ': ' + ', '.join(map(str, ports)))
    if result['baseline']:
        print('BASELINE: first completed scan for this target/port/timeout scope')
    elif not result['new'] and not result['lost']:
        print('UNCHANGED: same observed endpoints as the previous comparable scan')
    else:
        for ip, port in result['new']:
            print(f'[!] NEW: {ip}:{port}')
        for ip, port in result['lost']:
            print(f'[-] NOT OBSERVED: {ip}:{port}')
    if result['connectionErrors']:
        print('Connect failures: ' + json.dumps(result['connectionErrors'], sort_keys=True))


def main(argv=None):
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('start'); parser.add_argument('end')
    parser.add_argument('--ports', default=DEFAULT_PORTS)
    parser.add_argument('--timeout', type=float, default=0.3)
    parser.add_argument('--db', type=Path, default=Path('netwatch-lab.sqlite3'))
    parser.add_argument('--json', action='store_true', help='print a machine-readable report')
    args = parser.parse_args(argv)
    try:
        ips = make_ip_range(args.start, args.end)
        ports = parse_ports(args.ports)
        if not math.isfinite(args.timeout) or not 0.05 <= args.timeout <= 2:
            raise ValueError('timeout must be a finite number between 0.05 and 2 seconds')
    except ValueError as error:
        parser.error(str(error))
    try:
        with closing(sqlite3.connect(args.db)) as conn:
            conn.execute('PRAGMA foreign_keys=ON')
            initialize(conn)
            found, errors = scan(ips, ports, args.timeout)
            scan_id = save_scan(conn, scope_key(ips, ports, args.timeout), found, errors)
            result = report(conn, scan_id, errors)
        print(json.dumps(result, ensure_ascii=True)) if args.json else show(result)
        return 0
    except (OSError, sqlite3.Error, ValueError) as error:
        print('ERROR: ' + str(error), file=sys.stderr)
        return 1
    except KeyboardInterrupt:
        print('Interrupted; check the DB for the last committed scan.', file=sys.stderr)
        return 130


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