// Package netscan discovers hosts on the LAN Maven is configured to look at
// (Vikunja #257, docs/plans/12-bluetooth-network-scan.md).
//
// A scan is a read, but an unbounded scanner on a home network is noisy and is
// trivially pointed somewhere it should not go, so the whole package is built
// around four rules:
//
//   - The target range NEVER comes from an utterance, a router, an LLM or a
//     device. Scan takes no target argument at all: it reads only the CIDRs in
//     the config block. There is deliberately no exported way to scan an
//     arbitrary range, so no amount of prompt injection or a rogue reply from a
//     scanned host can retarget it.
//   - Every configured CIDR must be private (RFC1918 / CGNAT / link-local) and
//     no larger than MaxPrefixHosts addresses. Scanning the public internet
//     from his flat is not a thing Maven does, and /8 is not a home LAN.
//   - Rate-limited. Connections leave at a fixed rate, so a scan looks like
//     background traffic rather than a portscan to anything watching.
//   - Bounded in total. MaxHosts, a per-connection timeout and the caller's
//     context all cap the work; a scan that runs long returns what it has.
//
// It is a TCP-connect scan (net.DialTimeout) and an ARP-table read. No raw
// sockets, no SYN scan, no privileges: mavend does not run as root and this
// does not ask it to.
package netscan

import (
	"bufio"
	"context"
	"errors"
	"fmt"
	"io"
	"net"
	"net/netip"
	"os"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"
)

// DefaultPorts — what a scan looks at when the config names nothing. Chosen to
// answer "what is this box" on a home network, not to find a way in.
var DefaultPorts = []int{22, 80, 443, 8080}

const (
	// DefaultTimeout — per-connection budget. Short: on a LAN a live host
	// answers in single-digit milliseconds, and a filtered port never answers.
	DefaultTimeout = 400 * time.Millisecond
	// DefaultRate — connections per second across the whole scan. A default
	// /24 at four ports is 1016 probes, so this is also what decides whether
	// the shipped configuration fits inside the caller's budget: at 100/s it
	// takes about ten seconds. Lowering it means a truncated scan, which is
	// reported rather than hidden, but it is still a worse answer.
	DefaultRate = 100
	// MaxRate — the highest configurable rate. The dial loop floors the ticker
	// interval at a millisecond, so anything above this was already a lie; say
	// so at config load instead of silently clamping.
	MaxRate = 1000
	// DefaultMaxHosts — cap on addresses probed in one scan.
	DefaultMaxHosts = 256
	// MaxPrefixHosts — the largest CIDR that may be configured, in addresses.
	// 1024 is a /22: generous for a flat, and far short of anything that would
	// take minutes or wake up a neighbour's IDS.
	MaxPrefixHosts = 1024
	// maxParallel — in-flight dials. The rate limiter is the real throttle;
	// this only stops a slow subnet from piling up file descriptors.
	maxParallel = 16
	// arpFile — the kernel's ARP cache. Reading it is free and needs no packet.
	arpFile = "/proc/net/arp"
)

var (
	// ErrNotConfigured — no netscan block, or it is disabled.
	ErrNotConfigured = errors.New("netscan: not configured")
	// ErrNoSubnets — enabled with nothing to scan.
	ErrNoSubnets = errors.New("netscan: no subnets configured")
)

// Config — the bounds of every scan. There is nothing here that can be
// overridden at call time.
type Config struct {
	// Subnets — the ONLY ranges that are ever probed, as CIDRs. Each must be
	// private and no bigger than MaxPrefixHosts.
	Subnets []string
	// Ports — TCP ports to try on each host. Empty ⇒ DefaultPorts.
	Ports []int
	// Timeout — per-connection budget. 0 ⇒ DefaultTimeout.
	Timeout time.Duration
	// Rate — connections per second. 0 ⇒ DefaultRate.
	Rate int
	// MaxHosts — cap on addresses probed per scan. 0 ⇒ DefaultMaxHosts.
	MaxHosts int
}

// Host is one machine the scan saw.
type Host struct {
	// Addr — the IP.
	Addr string
	// MAC — from the ARP cache, empty when the kernel has no entry.
	MAC string
	// Ports — open TCP ports, ascending.
	Ports []int
}

// Up reports whether anything at all answered for this host.
func (h Host) Up() bool { return len(h.Ports) > 0 || h.MAC != "" }

// Validate rejects a block that cannot safely run, at config-load time rather
// than at the first spoken scan. This is the guard the whole package rides on:
// if it passes, every later scan is inside these bounds by construction.
func Validate(c Config) error {
	if len(c.Subnets) == 0 {
		return ErrNoSubnets
	}
	for _, s := range c.Subnets {
		p, err := netip.ParsePrefix(strings.TrimSpace(s))
		if err != nil {
			return fmt.Errorf("netscan: subnet %q: %w", s, err)
		}
		if !p.Addr().Is4() {
			return fmt.Errorf("netscan: subnet %q: only IPv4 is scanned", s)
		}
		if !isPrivate(p.Addr()) {
			return fmt.Errorf("netscan: subnet %q is not a private range: Maven does not scan the public internet", s)
		}
		if n := prefixHosts(p); n > MaxPrefixHosts {
			return fmt.Errorf("netscan: subnet %q covers %d addresses, limit is %d: narrow the prefix", s, n, MaxPrefixHosts)
		}
	}
	for _, port := range c.Ports {
		if port < 1 || port > 65535 {
			return fmt.Errorf("netscan: port %d out of range", port)
		}
	}
	if c.Rate < 0 || c.MaxHosts < 0 || c.Timeout < 0 {
		return errors.New("netscan: rate, max_hosts and timeout must not be negative")
	}
	if c.Rate > MaxRate {
		return fmt.Errorf("netscan: rate %d is above the ceiling of %d connections per second", c.Rate, MaxRate)
	}
	return nil
}

// isPrivate — RFC1918, CGNAT and link-local. Loopback counts: scanning this box
// is harmless and is how the tests run.
func isPrivate(a netip.Addr) bool {
	if a.IsLoopback() || a.IsPrivate() || a.IsLinkLocalUnicast() {
		return true
	}
	// 100.64.0.0/10, the carrier-grade NAT range Tailscale hands out.
	cgnat := netip.MustParsePrefix("100.64.0.0/10")
	return cgnat.Contains(a)
}

// prefixHosts — addresses covered by a v4 prefix.
func prefixHosts(p netip.Prefix) int {
	bits := 32 - p.Bits()
	if bits >= 31 {
		return MaxPrefixHosts + 1
	}
	return 1 << bits
}

// Scanner probes the configured subnets. Build it with New; the config it holds
// is the config it was validated with, and nothing mutates it afterwards.
type Scanner struct {
	cfg Config
	// dial is the connect seam; tests swap it.
	dial func(ctx context.Context, addr string, timeout time.Duration) bool
	// arp is the ARP-cache seam; tests swap it.
	arp func() (map[string]string, error)
}

// New builds a scanner. Validate first — this does not.
func New(cfg Config) *Scanner {
	if len(cfg.Ports) == 0 {
		cfg.Ports = append([]int(nil), DefaultPorts...)
	}
	if cfg.Timeout <= 0 {
		cfg.Timeout = DefaultTimeout
	}
	if cfg.Rate <= 0 {
		cfg.Rate = DefaultRate
	}
	if cfg.MaxHosts <= 0 {
		cfg.MaxHosts = DefaultMaxHosts
	}
	return &Scanner{cfg: cfg, dial: dialTCP, arp: readARP}
}

// expand lists the scannable addresses of one CIDR, skipping the network and
// broadcast address.
func expand(cidr string) []netip.Addr {
	p, err := netip.ParsePrefix(strings.TrimSpace(cidr))
	if err != nil {
		return nil
	}
	p = p.Masked()
	first := p.Addr()
	var out []netip.Addr
	for a := first; p.Contains(a); a = a.Next() {
		// Skip the network address; the broadcast address is skipped by
		// looking one ahead.
		if a == first && p.Bits() < 31 {
			continue
		}
		if p.Bits() < 31 && !p.Contains(a.Next()) {
			continue
		}
		out = append(out, a)
	}
	return out
}

// targets expands the configured subnets into addresses, capped at MaxHosts,
// and reports whether the cap cut anything off. Deterministic order, so two
// scans of an unchanged network read the same.
//
// The subnets are taken round-robin rather than in order. Consuming MaxHosts
// from the first subnet used to leave a second configured LAN 99% unprobed,
// with nothing logged: he named two ranges and got an answer about one.
// Round-robin spends the budget evenly, so every named range is represented
// and the shortfall is reported instead.
func (s *Scanner) targets() ([]netip.Addr, bool) {
	lists := make([][]netip.Addr, 0, len(s.cfg.Subnets))
	total := 0
	for _, cidr := range s.cfg.Subnets {
		l := expand(cidr)
		if len(l) == 0 {
			continue
		}
		lists = append(lists, l)
		total += len(l)
	}
	out := make([]netip.Addr, 0, min(total, s.cfg.MaxHosts))
	for i := 0; len(out) < s.cfg.MaxHosts; i++ {
		took := false
		for _, l := range lists {
			if i >= len(l) {
				continue
			}
			if len(out) >= s.cfg.MaxHosts {
				break
			}
			out = append(out, l[i])
			took = true
		}
		if !took {
			break
		}
	}
	return out, len(out) < total
}

// Result is one scan's outcome.
type Result struct {
	// Hosts — what answered, ascending by address.
	Hosts []Host
	// Truncated — the scan did not cover every configured address, because
	// MaxHosts cut the target list or the caller's context expired mid-run.
	// Callers MUST NOT present a truncated result as the state of the network:
	// "нашла 6 устройств" is a claim about the LAN, and a scan that stopped at
	// .238 has not earned it.
	Truncated bool
}

// Scan probes every configured address and returns the hosts that answered.
//
// It takes no target: the range is the configured one, always. Callers pass a
// context and nothing else, which is the point — see the package comment.
func (s *Scanner) Scan(ctx context.Context) (Result, error) {
	if len(s.cfg.Subnets) == 0 {
		return Result{}, ErrNoSubnets
	}
	arp, err := s.arp()
	if err != nil {
		// A missing /proc/net/arp costs MAC addresses, not the scan.
		arp = map[string]string{}
	}

	// One token per connection, at Rate per second, shared by every worker.
	interval := time.Second / time.Duration(s.cfg.Rate)
	if interval <= 0 {
		interval = time.Millisecond
	}
	tick := time.NewTicker(interval)
	defer tick.Stop()

	type result struct {
		addr  string
		ports []int
	}
	targets, truncated := s.targets()
	// One slot per PROBE, not per host: a worker sends once per open port, so
	// a subnet with more open ports than addresses used to fill a host-sized
	// buffer and wedge. Nothing drains this channel until wg.Wait returns, and
	// the sends carry no select on ctx.Done, so that was a permanent hang of
	// the calling turn plus a leak of every worker.
	results := make(chan result, len(targets)*len(s.cfg.Ports))
	sem := make(chan struct{}, maxParallel)
	var wg sync.WaitGroup

scan:
	for _, a := range targets {
		addr := a.String()
		for _, port := range s.cfg.Ports {
			// Checked before the select as well as inside it: select picks
			// randomly among ready cases, so at a high rate the ticker would
			// sometimes win over an already-canceled context and let one more
			// probe out.
			if ctx.Err() != nil {
				truncated = true
				break scan
			}
			select {
			case <-ctx.Done():
				truncated = true
				break scan
			case <-tick.C:
			}
			sem <- struct{}{}
			wg.Add(1)
			go func(addr string, port int) {
				defer wg.Done()
				defer func() { <-sem }()
				if s.dial(ctx, net.JoinHostPort(addr, strconv.Itoa(port)), s.cfg.Timeout) {
					results <- result{addr: addr, ports: []int{port}}
				}
			}(addr, port)
		}
	}
	wg.Wait()
	close(results)

	byAddr := map[string]*Host{}
	for r := range results {
		h := byAddr[r.addr]
		if h == nil {
			h = &Host{Addr: r.addr}
			byAddr[r.addr] = h
		}
		h.Ports = append(h.Ports, r.ports...)
	}
	// A host in the ARP cache is up even with every port closed — it answered
	// an ARP request, which is the cheapest liveness signal there is.
	for _, a := range targets {
		addr := a.String()
		mac, ok := arp[addr]
		if !ok {
			continue
		}
		if byAddr[addr] == nil {
			byAddr[addr] = &Host{Addr: addr}
		}
		byAddr[addr].MAC = mac
	}

	out := make([]Host, 0, len(byAddr))
	for _, h := range byAddr {
		sort.Ints(h.Ports)
		if !h.Up() {
			continue
		}
		out = append(out, *h)
	}
	sort.Slice(out, func(i, j int) bool {
		ai, _ := netip.ParseAddr(out[i].Addr)
		aj, _ := netip.ParseAddr(out[j].Addr)
		return ai.Less(aj)
	})
	return Result{Hosts: out, Truncated: truncated}, nil
}

func dialTCP(ctx context.Context, addr string, timeout time.Duration) bool {
	d := net.Dialer{Timeout: timeout}
	ctx, cancel := context.WithTimeout(ctx, timeout)
	defer cancel()
	c, err := d.DialContext(ctx, "tcp", addr)
	if err != nil {
		return false
	}
	_ = c.Close()
	return true
}

func readARP() (map[string]string, error) {
	f, err := os.Open(arpFile)
	if err != nil {
		return nil, err
	}
	defer f.Close()
	return parseARP(f)
}

// parseARP reads the kernel's ARP table. Incomplete entries (all-zero MAC,
// flags 0x0) are dropped: they mean "we asked and nobody answered", which is
// the opposite of a discovered host.
func parseARP(r io.Reader) (map[string]string, error) {
	out := map[string]string{}
	sc := bufio.NewScanner(r)
	first := true
	for sc.Scan() {
		if first { // header row
			first = false
			continue
		}
		f := strings.Fields(sc.Text())
		if len(f) < 4 {
			continue
		}
		ip, flags, mac := f[0], f[2], f[3]
		if flags == "0x0" || mac == "00:00:00:00:00:00" {
			continue
		}
		if _, err := netip.ParseAddr(ip); err != nil {
			continue
		}
		out[ip] = mac
	}
	return out, sc.Err()
}
