// Package sysarp implements a cross-platform ARP-table reader.
// It runs "arp -a" (available on Windows, Linux and macOS) and
// writes every discovered host into the shared SQLite store.
// Unlike the arp_discovery module it does NOT send packets —
// it reads what the OS already knows, so no raw-socket privilege is needed.
package sysarp

import (
	"bufio"
	"bytes"
	"context"
	"fmt"
	"log/slog"
	"os/exec"
	"regexp"
	"runtime"
	"strings"
	"time"

	"github.com/cereda-systems/nexus-scanner/internal/db"
)

// Module reads the OS ARP cache and persists discovered hosts.
type Module struct {
	site  string
	store *db.Store
	log   *slog.Logger
}

// New returns a new sys_arp module.
func New(site string, store *db.Store) *Module {
	return &Module{
		site:  site,
		store: store,
		log:   slog.With("module", "sys_arp"),
	}
}

func (m *Module) Name() string            { return "sys_arp" }
func (m *Module) Interval() time.Duration { return 2 * time.Minute }

// Run executes one read of the ARP cache.
func (m *Module) Run(ctx context.Context) error {
	scanID, err := m.store.BeginScan(m.Name())
	if err != nil {
		return fmt.Errorf("begin scan: %w", err)
	}

	scanErr := m.scan(ctx)
	if endErr := m.store.EndScan(scanID, scanErr); endErr != nil {
		m.log.Error("record scan end", "err", endErr)
	}
	return scanErr
}

func (m *Module) scan(ctx context.Context) error {
	out, err := exec.CommandContext(ctx, "arp", "-a").Output()
	if err != nil {
		return fmt.Errorf("arp -a failed: %w", err)
	}

	entries := parseARPOutput(out)
	m.log.Info("ARP cache read", "entries", len(entries))

	now := time.Now().UTC()
	saved := 0
	for _, e := range entries {
		if err := m.store.UpsertHost(db.Host{
			IP:        e.ip,
			MAC:       e.mac,
			Site:      m.site,
			FirstSeen: now,
			LastSeen:  now,
		}); err != nil {
			m.log.Error("upsert host", "ip", e.ip, "err", err)
			continue
		}
		m.log.Info("host found", "ip", e.ip, "mac", e.mac)
		saved++
	}

	m.log.Info("scan complete", "found", len(entries), "saved", saved)
	return nil
}

type arpEntry struct{ ip, mac string }

// Windows: "  192.168.0.1     aa-bb-cc-dd-ee-ff     dynamic"
var reWindows = regexp.MustCompile(`^\s+([\d.]+)\s+([0-9a-fA-F]{2}(?:-[0-9a-fA-F]{2}){5})\s+\S+`)

// Linux/macOS: "? (192.168.0.1) at aa:bb:cc:dd:ee:ff [ether] on eth0"
var reUnix = regexp.MustCompile(`\(([\d.]+)\) at ([0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5})`)

// skipPrefixes are IPs that are multicast / broadcast — not real hosts.
var skipPrefixes = []string{"224.", "239.", "255.", "ff02"}

func parseARPOutput(data []byte) []arpEntry {
	var entries []arpEntry
	seen := make(map[string]struct{})

	scanner := bufio.NewScanner(bytes.NewReader(data))
	for scanner.Scan() {
		line := scanner.Text()
		var ip, mac string

		if runtime.GOOS == "windows" {
			m := reWindows.FindStringSubmatch(line)
			if m == nil {
				continue
			}
			ip = m[1]
			// Normalise Windows dashes to colons: aa-bb-cc → aa:bb:cc
			mac = strings.ReplaceAll(strings.ToLower(m[2]), "-", ":")
		} else {
			m := reUnix.FindStringSubmatch(line)
			if m == nil {
				continue
			}
			ip = m[1]
			mac = strings.ToLower(m[2])
		}

		// Skip multicast, broadcast and already-seen IPs.
		skip := false
		for _, pfx := range skipPrefixes {
			if strings.HasPrefix(ip, pfx) || strings.HasPrefix(mac, pfx) {
				skip = true
				break
			}
		}
		if skip {
			continue
		}
		if _, dup := seen[ip]; dup {
			continue
		}
		seen[ip] = struct{}{}
		entries = append(entries, arpEntry{ip: ip, mac: mac})
	}

	return entries
}
