// Package portscan performs concurrent TCP port scanning on all online hosts.
package portscan

import (
	"context"
	"fmt"
	"log/slog"
	"net"
	"sort"
	"sync"
	"time"

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

const modName = "port_scan"

// Common ports to scan when none are configured.
var defaultPorts = []int{21, 22, 23, 25, 53, 80, 110, 135, 139, 143, 443, 445, 3389, 5985, 8080, 8443, 9100}

// Module scans TCP ports on all online hosts.
type Module struct {
	site  string
	cfg   config.PortScanConfig
	store *db.Store
}

// New creates a new port scan module.
func New(site string, cfg config.PortScanConfig, store *db.Store) *Module {
	return &Module{site: site, cfg: cfg, store: store}
}

func (m *Module) Name() string            { return modName }
func (m *Module) Interval() time.Duration { return m.cfg.Interval }

// Run scans all online hosts and stores their open ports.
func (m *Module) Run(ctx context.Context) error {
	hosts, err := m.store.ListHosts("")
	if err != nil {
		return fmt.Errorf("list hosts: %w", err)
	}

	ports := m.cfg.Ports
	if len(ports) == 0 {
		ports = defaultPorts
	}
	timeout := m.cfg.Timeout
	if timeout == 0 {
		timeout = 500 * time.Millisecond
	}

	scanID, err := m.store.BeginScan(modName)
	if err != nil {
		return fmt.Errorf("begin scan: %w", err)
	}

	found := 0
	for _, h := range hosts {
		if h.Status != "online" {
			continue
		}
		open := scanHost(ctx, h.IP, ports, timeout)
		sort.Ints(open)
		if err := m.store.UpsertHostPorts(h.IP, open); err != nil {
			slog.Error("save host ports", "ip", h.IP, "err", err)
		}
		if len(open) > 0 {
			slog.Debug("port scan", "ip", h.IP, "open", open)
			found++
		}
	}

	_ = m.store.EndScan(scanID, nil)
	slog.Info("port scan complete", "hosts_with_open_ports", found, "total_hosts", len(hosts))
	return nil
}

// scanHost tries all ports concurrently (max 30 goroutines) and returns open ones.
func scanHost(ctx context.Context, ip string, ports []int, timeout time.Duration) []int {
	var mu sync.Mutex
	var open []int
	var wg sync.WaitGroup
	sem := make(chan struct{}, 30)

	for _, port := range ports {
		wg.Add(1)
		go func(p int) {
			defer wg.Done()
			sem <- struct{}{}
			defer func() { <-sem }()

			addr := fmt.Sprintf("%s:%d", ip, p)
			d := net.Dialer{Timeout: timeout}
			conn, err := d.DialContext(ctx, "tcp", addr)
			if err == nil {
				conn.Close()
				mu.Lock()
				open = append(open, p)
				mu.Unlock()
			}
		}(port)
	}
	wg.Wait()
	return open
}
