package main

import (
	"context"
	"fmt"
	"log"
	"strings"
	"sync"
	"time"

	"github.com/kami/maven/internal/config"
	"github.com/kami/maven/internal/ipc"
	"github.com/kami/maven/internal/netscan"
	"github.com/kami/maven/internal/phraser"
)

// scanBudget — the whole spoken scan, end to end. A voice turn that takes
// longer than this has already failed as a turn, so the scan returns whatever
// it found rather than keeping him waiting.
//
// It has to be consistent with the shipped defaults or every scan is truncated:
// a /24 at four ports is 1016 probes, which at netscan.DefaultRate of 100 a
// second is a little over ten seconds plus the tail dials. 30s leaves room for
// that without pretending a slower rate would fit.
const scanBudget = 30 * time.Second

// scanCacheTTL — how long a scan answer is reused. Two questions in a row used
// to be two full sweeps of the LAN, up to a thousand connections each. The
// network does not change on the scale of a follow-up question, and the cheapest
// packet is the one not sent.
const scanCacheTTL = 2 * time.Minute

// scanReadOut — how many hosts go into the written record's first lines before
// it says "и ещё N". Nothing reads addresses out loud; see scanSummary.
const scanReadOut = 20

// netWiring — the LAN scanner, when the `netscan` block is enabled. nil ⇒ Maven
// never puts a discovery packet on the network.
//
// Unlike the house, a scan is a READ, so it is a query source rather than an
// act: there is no allowlist row and no confirm turn, because nothing changes.
// What makes that safe is that the range is not an argument — see
// internal/netscan's package comment.
type netWiring struct {
	scanner *netscan.Scanner
	subnets []string
	// api — where the address list is WRITTEN. The spoken answer is a count
	// and a shape, so the detail has to land somewhere readable; a note under
	// source "scan:lan" puts it on /history and, through the intake decorator,
	// on /events. It is also the only record that Maven put packets on the LAN
	// at all. nil ⇒ nothing is written, which is what the tests use.
	api ipc.CoreAPI
	now func() time.Time

	mu       sync.Mutex
	cached   netscan.Result
	cachedAt time.Time
}

// wireNetScan builds the scanner. nil unless the block is enabled and valid.
func wireNetScan(cfg *config.Config, api ipc.CoreAPI) *netWiring {
	nc, ok := cfg.NetScanner()
	if !ok {
		return nil
	}
	if err := netscan.Validate(nc); err != nil {
		// config.validate already ran this, so reaching here is a programming
		// error rather than a config one. Not fatal: the scanner off is a
		// working Maven.
		log.Printf("netscan: not wired: %v", err)
		return nil
	}
	return &netWiring{scanner: netscan.New(nc), subnets: nc.Subnets, api: api, now: time.Now}
}

// scan runs a scan, or reuses one younger than scanCacheTTL.
func (w *netWiring) scan(ctx context.Context) (netscan.Result, error) {
	w.mu.Lock()
	defer w.mu.Unlock()
	now := w.now()
	if !w.cachedAt.IsZero() && now.Sub(w.cachedAt) < scanCacheTTL {
		return w.cached, nil
	}
	scanCtx, cancel := context.WithTimeout(ctx, scanBudget)
	defer cancel()
	res, err := w.scanner.Scan(scanCtx)
	if err != nil {
		return res, err
	}
	w.cached, w.cachedAt = res, now
	// Written on a fresh scan only: the record is a trace of packets going out,
	// so a cached answer must not forge a second one.
	w.writeScanRecord(ctx, res)
	return res, nil
}

// scanSummary answers "какие устройства в сети?" in one spoken line.
//
// It does NOT read addresses out. This is the query path, so the reply goes to
// piper as well as to /chat, and "192.168.1.1 (80, 443); 192.168.1.14 (22)" is
// a digit stream nobody can follow through a speaker. She says how many and
// what shape they are; the addresses go into a note (see writeScanRecord).
func (w *netWiring) scanSummary(ctx context.Context) (string, bool) {
	if w == nil {
		return "", false
	}
	res, err := w.scan(ctx)
	if err != nil {
		log.Printf("netscan: scan: %v", err)
		return phraser.Q(phraser.QueryFailNetscan, nil), true
	}
	// A truncated run is not a statement about the LAN. Saying "нашла 6
	// устройств" after stopping two thirds of the way through the range is a
	// false claim, and the addresses at the end are the ones that go missing.
	tail := ""
	if res.Truncated {
		tail = ", но успела посмотреть не всю сеть"
	}
	if len(res.Hosts) == 0 {
		return phraser.Q(phraser.QueryNetEmpty, map[string]string{"tail": tail}), true
	}
	out := fmt.Sprintf("нашла %d %s", len(res.Hosts), phraser.Devices(len(res.Hosts)))
	if shape := scanShape(res.Hosts); shape != "" {
		out += ", " + shape
	}
	out += tail
	if w.api != nil {
		out += ". список записала"
	}
	return out + ".", true
}

// scanShape describes the hosts by what they answer on, which is the part of
// the answer that carries meaning out loud: "два с вебом" says more about the
// flat than four octets do.
func scanShape(hosts []netscan.Host) string {
	var web, ssh, quiet int
	for _, h := range hosts {
		hasWeb, hasSSH := false, false
		for _, p := range h.Ports {
			switch p {
			case 80, 443, 8080:
				hasWeb = true
			case 22:
				hasSSH = true
			}
		}
		if hasWeb {
			web++
		}
		if hasSSH {
			ssh++
		}
		// No open port at all: seen only through the ARP cache.
		if len(h.Ports) == 0 {
			quiet++
		}
	}
	var parts []string
	if web > 0 {
		parts = append(parts, fmt.Sprintf("%d с вебом", web))
	}
	if ssh > 0 {
		parts = append(parts, fmt.Sprintf("%d с ssh", ssh))
	}
	if quiet > 0 {
		parts = append(parts, fmt.Sprintf("%d молча", quiet))
	}
	if len(parts) == 0 {
		return ""
	}
	return "из них " + strings.Join(parts, ", ")
}

// writeScanRecord stores the address list as a note. This is both where the
// detail becomes readable and the only trace that a scan happened at all: a
// scan is a read, but "when did she last put packets on the LAN" deserves an
// answer.
func (w *netWiring) writeScanRecord(ctx context.Context, res netscan.Result) {
	if w.api == nil {
		return
	}
	head := fmt.Sprintf("сканирование сети: %d %s", len(res.Hosts), phraser.Devices(len(res.Hosts)))
	if res.Truncated {
		head += " (не вся сеть)"
	}
	lines := []string{head, "подсети: " + strings.Join(w.subnets, ", ")}
	shown := res.Hosts
	if len(shown) > scanReadOut {
		shown = shown[:scanReadOut]
	}
	for _, h := range shown {
		s := h.Addr
		if len(h.Ports) > 0 {
			ps := make([]string, 0, len(h.Ports))
			for _, p := range h.Ports {
				ps = append(ps, fmt.Sprintf("%d", p))
			}
			s += " (" + strings.Join(ps, ", ") + ")"
		}
		if h.MAC != "" {
			s += " " + h.MAC
		}
		lines = append(lines, s)
	}
	if len(res.Hosts) > len(shown) {
		lines = append(lines, fmt.Sprintf("и ещё %d", len(res.Hosts)-len(shown)))
	}
	if _, err := w.api.WriteNote(ctx, w.now(), strings.Join(lines, "\n"), nil, "scan:lan"); err != nil {
		log.Printf("netscan: write scan note: %v", err)
	}
}

// isNetworkQuery recognises a question about the LAN, narrowly. It needs a
// network word AND an ask: "интернет не работает" is a complaint, not a request
// to scan, and a scan she runs unasked is exactly the noisy behaviour the
// bounds exist to prevent.
func isNetworkQuery(u string) bool {
	s := strings.ToLower(strings.TrimSpace(u))
	if s == "" {
		return false
	}
	// Whole tokens for the network nouns: the bare substring "сети" is inside
	// "посетил", so "сколько машин я посетил?" used to read as a request to
	// scan the LAN. The prefix forms below are stems that have no such
	// collisions.
	network := false
	for _, w := range []string{"сеть", "сети", "сетке", "сетку"} {
		if homeWord(s, w) {
			network = true
			break
		}
	}
	if !network {
		for _, w := range []string{"локальн", "wifi", "wi-fi", "вайфай"} {
			if strings.Contains(s, w) {
				network = true
				break
			}
		}
	}
	if !network {
		return false
	}
	// An explicit ask to scan, or a phrase that can only be about the LAN.
	// "кто в сети" carries no device noun but means nothing else.
	for _, w := range []string{"просканируй", "сканируй", "скан", "просканир", "кто в сети", "кто в сетке"} {
		if strings.Contains(s, w) {
			return true
		}
	}
	ask := strings.Contains(s, "?") || homeWord(s, "какие") || homeWord(s, "кто") ||
		homeWord(s, "что") || homeWord(s, "сколько") || strings.Contains(s, "покажи")
	if !ask {
		return false
	}
	for _, w := range []string{"устройств", "хост", "компьютер", "машин", "адрес"} {
		if strings.Contains(s, w) {
			return true
		}
	}
	return false
}
