//go:build linux

package device

import (
	"context"
	"errors"
	"fmt"
	"hash/fnv"
	"net"
	"os"
	"os/exec"
	"strconv"
	"strings"
	"time"

	"vocat/internal/modem"
)

func setQMINetwork(
	ctx context.Context,
	candidate modem.Candidate,
	enabled bool,
	apn string,
	ipVersion string,
	username string,
	password string,
	authentication string,
) (NetworkResult, error) {
	qmiNetwork, err := exec.LookPath("qmi-network")
	if err != nil {
		return NetworkResult{}, fmt.Errorf("%w: install libqmi-utils to control %s", ErrDataBackendUnavailable, candidate.QMIControl)
	}
	profile, err := os.CreateTemp("", "vocat-qmi-*.conf")
	if err != nil {
		return NetworkResult{}, fmt.Errorf("create temporary QMI profile: %w", err)
	}
	profilePath := profile.Name()
	defer os.Remove(profilePath)
	ipType := map[string]string{"IP": "4", "IPV6": "6", "IPV4V6": "4"}[ipVersion]
	profileText := fmt.Sprintf("IP_TYPE=%s\nPROXY=yes\n", ipType)
	if apn != "" {
		profileText = "APN=" + apn + "\n" + profileText
	}
	if username != "" {
		profileText += "APN_USER=" + shellProfileValue(username) + "\n"
	}
	if password != "" {
		profileText += "APN_PASS=" + shellProfileValue(password) + "\n"
	}
	if authentication != "" && authentication != "NONE" {
		profileText += "APN_AUTH=" + shellProfileValue(strings.ToLower(authentication)) + "\n"
	}
	if _, err := fmt.Fprint(profile, profileText); err != nil {
		_ = profile.Close()
		return NetworkResult{}, fmt.Errorf("write temporary QMI profile: %w", err)
	}
	if err := profile.Chmod(0o600); err != nil {
		_ = profile.Close()
		return NetworkResult{}, fmt.Errorf("protect temporary QMI profile: %w", err)
	}
	if err := profile.Close(); err != nil {
		return NetworkResult{}, fmt.Errorf("close temporary QMI profile: %w", err)
	}

	action := "stop"
	if enabled {
		action = "start"
	}
	command := exec.CommandContext(ctx, qmiNetwork, "--profile="+profilePath, candidate.QMIControl, action)
	output, err := command.CombinedOutput()
	detail := strings.TrimSpace(string(output))
	if err != nil {
		lowerDetail := strings.ToLower(detail)
		idempotentStop := !enabled && (strings.Contains(lowerDetail, "already stopped") ||
			strings.Contains(lowerDetail, "not started") || strings.Contains(lowerDetail, "no network"))
		idempotentStart := enabled && (strings.Contains(lowerDetail, "already started") ||
			strings.Contains(lowerDetail, "already connected"))
		if !idempotentStop && !idempotentStart {
			return NetworkResult{}, fmt.Errorf("qmi-network %s failed: %w: %s", action, err, detail)
		}
	}
	ipCommand, lookErr := exec.LookPath("ip")
	if lookErr != nil {
		return NetworkResult{}, fmt.Errorf("%w: install iproute2 to control %s", ErrDataBackendUnavailable, candidate.NetworkInterface)
	}
	linkAction := "down"
	if enabled {
		linkAction = "up"
	}
	linkOutput, linkErr := exec.CommandContext(ctx, ipCommand, "link", "set", "dev", candidate.NetworkInterface, linkAction).CombinedOutput()
	if linkErr != nil {
		return NetworkResult{}, fmt.Errorf("set %s %s: %w: %s", candidate.NetworkInterface, linkAction, linkErr, strings.TrimSpace(string(linkOutput)))
	}
	if enabled {
		busybox, busyboxErr := exec.LookPath("busybox")
		if busyboxErr != nil {
			return NetworkResult{}, fmt.Errorf("%w: busybox udhcpc is required for %s", ErrDataBackendUnavailable, candidate.NetworkInterface)
		}
		dhcpDetail, dhcpErr := configureExportProxyDHCP(ctx, busybox, ipCommand, candidate.NetworkInterface)
		if dhcpErr != nil {
			rollbackCtx, cancelRollback := context.WithTimeout(context.Background(), managerCommandCleanupTimeout)
			defer cancelRollback()
			clearExportProxyRoute(rollbackCtx, candidate.NetworkInterface)
			_, _ = exec.CommandContext(rollbackCtx, qmiNetwork, "--profile="+profilePath, candidate.QMIControl, "stop").CombinedOutput()
			_, _ = exec.CommandContext(rollbackCtx, ipCommand, "link", "set", "dev", candidate.NetworkInterface, "down").CombinedOutput()
			return NetworkResult{}, fmt.Errorf("QMI session started but protected DHCP failed: %w", dhcpErr)
		}
		detail = strings.TrimSpace(detail + "\n" + dhcpDetail)
	} else {
		clearExportProxyRoute(ctx, candidate.NetworkInterface)
		_, _ = exec.CommandContext(ctx, ipCommand, "-4", "addr", "flush", "dev", candidate.NetworkInterface, "scope", "global").CombinedOutput()
	}
	return NetworkResult{
		Enabled:       enabled,
		Backend:       "qmi",
		Interface:     candidate.NetworkInterface,
		ControlDevice: candidate.QMIControl,
		APN:           apn,
		IPVersion:     ipVersion,
		Detail:        detail,
	}, nil
}

func shellProfileValue(value string) string {
	return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'"
}

// exportProxyRouteIdentity must stay in sync with the Export Proxy plugin's
// Linux socket mark. Unmarked host traffic never sees the cellular default
// route; only plugin sockets carrying this mark are policy-routed to it.
func exportProxyRouteIdentity(networkInterface string) (mark uint32, table, priority int) {
	hash := fnv.New32a()
	_, _ = hash.Write([]byte(networkInterface))
	value := hash.Sum32()
	mark = 0x56000000 | (value & 0x00ffffff)
	table = 20000 + int(value%10000)
	priority = 20000 + int(value%10000)
	return
}

func configureExportProxyDHCP(ctx context.Context, busybox, ipCommand, networkInterface string) (string, error) {
	lease, err := os.CreateTemp("", "vocat-dhcp-lease-*.env")
	if err != nil {
		return "", err
	}
	leasePath := lease.Name()
	_ = lease.Close()
	_ = os.Remove(leasePath)
	defer os.Remove(leasePath)
	script, err := os.CreateTemp("", "vocat-udhcpc-*.sh")
	if err != nil {
		return "", err
	}
	scriptPath := script.Name()
	defer os.Remove(scriptPath)
	scriptText := fmt.Sprintf(`#!/bin/sh
case "$1" in
  bound|renew)
    (umask 077; printf 'ip=%%s\nsubnet=%%s\nrouter=%%s\ndns=%%s\n' "$ip" "$subnet" "$router" "$dns" > %q)
    ;;
esac
exit 0
`, leasePath)
	if _, err := script.WriteString(scriptText); err != nil {
		_ = script.Close()
		return "", err
	}
	if err := script.Chmod(0o700); err != nil {
		_ = script.Close()
		return "", err
	}
	if err := script.Close(); err != nil {
		return "", err
	}
	output, err := exec.CommandContext(ctx, busybox, "udhcpc", "-q", "-n", "-t", "5", "-T", "3", "-i", networkInterface, "-s", scriptPath).CombinedOutput()
	if err != nil {
		if strings.Contains(strings.ToLower(string(output)), "address family not supported") {
			return "", fmt.Errorf("udhcpc cannot open its link-layer socket: allow AF_PACKET in the vocat systemd service RestrictAddressFamilies setting: %w", err)
		}
		return "", fmt.Errorf("udhcpc: %w: %s", err, strings.TrimSpace(string(output)))
	}
	raw, err := os.ReadFile(leasePath)
	if err != nil {
		return "", fmt.Errorf("read DHCP lease: %w", err)
	}
	values := make(map[string]string)
	for _, line := range strings.Split(string(raw), "\n") {
		key, value, found := strings.Cut(line, "=")
		if found {
			values[strings.TrimSpace(key)] = strings.TrimSpace(value)
		}
	}
	address := net.ParseIP(values["ip"]).To4()
	maskIP := net.ParseIP(values["subnet"]).To4()
	if address == nil || maskIP == nil {
		return "", errors.New("DHCP returned no valid IPv4 address/subnet")
	}
	mask := net.IPMask(maskIP)
	ones, bits := mask.Size()
	if bits != 32 || ones < 0 {
		return "", errors.New("DHCP returned an invalid IPv4 subnet")
	}
	network := address.Mask(mask)
	routers := strings.Fields(values["router"])
	if len(routers) > 0 && net.ParseIP(routers[0]).To4() == nil {
		return "", errors.New("DHCP returned an invalid IPv4 gateway")
	}
	if result, addrErr := exec.CommandContext(ctx, ipCommand, "-4", "addr", "replace", fmt.Sprintf("%s/%d", address.String(), ones), "dev", networkInterface).CombinedOutput(); addrErr != nil {
		return "", fmt.Errorf("configure cellular address: %w: %s", addrErr, strings.TrimSpace(string(result)))
	}
	mark, table, priority := exportProxyRouteIdentity(networkInterface)
	clearExportProxyRoute(ctx, networkInterface)
	connectedCIDR := fmt.Sprintf("%s/%d", network.String(), ones)
	if result, routeErr := exec.CommandContext(ctx, ipCommand, "-4", "route", "replace", "table", strconv.Itoa(table), connectedCIDR, "dev", networkInterface, "scope", "link", "src", address.String()).CombinedOutput(); routeErr != nil {
		clearExportProxyRoute(ctx, networkInterface)
		return "", fmt.Errorf("install protected connected route: %w: %s", routeErr, strings.TrimSpace(string(result)))
	}
	defaultArgs := []string{"-4", "route", "replace", "table", strconv.Itoa(table), "default"}
	if len(routers) > 0 {
		defaultArgs = append(defaultArgs, "via", routers[0])
	}
	defaultArgs = append(defaultArgs, "dev", networkInterface, "onlink")
	if result, routeErr := exec.CommandContext(ctx, ipCommand, defaultArgs...).CombinedOutput(); routeErr != nil {
		clearExportProxyRoute(ctx, networkInterface)
		return "", fmt.Errorf("install protected default route: %w: %s", routeErr, strings.TrimSpace(string(result)))
	}
	markText := fmt.Sprintf("0x%x", mark)
	result, err := exec.CommandContext(ctx, ipCommand, "rule", "add", "priority", strconv.Itoa(priority), "fwmark", markText, "lookup", strconv.Itoa(table)).CombinedOutput()
	if err != nil {
		clearExportProxyRoute(ctx, networkInterface)
		return "", fmt.Errorf("install protected routing rule: %w: %s", err, strings.TrimSpace(string(result)))
	}
	if err := writeExportProxyDNS(networkInterface, strings.Fields(values["dns"])); err != nil {
		clearExportProxyRoute(ctx, networkInterface)
		return "", fmt.Errorf("publish protected DNS configuration: %w", err)
	}
	return fmt.Sprintf("protected DHCP lease %s/%d", address.String(), ones), nil
}

func exportProxyDNSPath(networkInterface string) string {
	safeName := strings.Map(func(character rune) rune {
		if character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' ||
			character >= '0' && character <= '9' || character == '-' || character == '_' || character == '.' {
			return character
		}
		return '_'
	}, networkInterface)
	return "/run/vocat/cellular-" + safeName + ".dns"
}

func writeExportProxyDNS(networkInterface string, servers []string) error {
	valid := make([]string, 0, len(servers))
	for _, server := range servers {
		if address := net.ParseIP(server); address != nil {
			valid = append(valid, address.String())
		}
	}
	if len(valid) == 0 {
		// This is used only by marked Export Proxy sockets. It never changes the
		// host resolver and is merely a fallback for carriers omitting DHCP DNS.
		valid = []string{"1.1.1.1", "8.8.8.8"}
	}
	if err := os.MkdirAll("/run/vocat", 0o755); err != nil {
		return err
	}
	temporary, err := os.CreateTemp("/run/vocat", ".cellular-dns-*")
	if err != nil {
		return err
	}
	temporaryPath := temporary.Name()
	defer os.Remove(temporaryPath)
	if _, err := temporary.WriteString(strings.Join(valid, "\n") + "\n"); err != nil {
		_ = temporary.Close()
		return err
	}
	if err := temporary.Chmod(0o644); err != nil {
		_ = temporary.Close()
		return err
	}
	if err := temporary.Close(); err != nil {
		return err
	}
	return os.Rename(temporaryPath, exportProxyDNSPath(networkInterface))
}

func clearExportProxyRoute(ctx context.Context, networkInterface string) {
	_ = os.Remove(exportProxyDNSPath(networkInterface))
	ipCommand, err := exec.LookPath("ip")
	if err != nil {
		return
	}
	mark, table, priority := exportProxyRouteIdentity(networkInterface)
	_, _ = exec.CommandContext(ctx, ipCommand, "rule", "del", "priority", strconv.Itoa(priority), "fwmark", fmt.Sprintf("0x%x", mark), "lookup", strconv.Itoa(table)).CombinedOutput()
	_, _ = exec.CommandContext(ctx, ipCommand, "-4", "route", "flush", "table", strconv.Itoa(table)).CombinedOutput()
}

const managerCommandCleanupTimeout = 15 * time.Second
