package main

import (
	"context"
	"crypto/sha256"
	"crypto/subtle"
	"encoding/hex"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"io"
	"log"
	"net"
	"net/http"
	"net/url"
	"os"
	"os/exec"
	"os/signal"
	"path/filepath"
	"strconv"
	"strings"
	"sync"
	"syscall"
	"time"

	"dev.pairling/connectd/internal/gateway"
	runtimecfg "dev.pairling/connectd/internal/runtime"
	"dev.pairling/connectd/internal/status"
	"tailscale.com/client/tailscale/apitype"
	"tailscale.com/ipn/ipnstate"
	"tailscale.com/tsnet"
)

var (
	buildVersion        = "development"
	buildSourceRevision = "unknown"
	buildSourceDirty    = "true"
)

type buildInfo struct {
	SchemaVersion  int    `json:"schema_version"`
	Version        string `json:"version"`
	SourceRevision string `json:"source_revision"`
	SourceDirty    bool   `json:"source_dirty"`
}

func main() {
	os.Exit(run(os.Args[1:]))
}

func run(args []string) int {
	if len(args) == 1 && args[0] == "--build-info-json" {
		if err := writeBuildInfo(os.Stdout); err != nil {
			fmt.Fprintf(os.Stderr, "cannot write build info: %v\n", err)
			return 1
		}
		return 0
	}

	fs := flag.NewFlagSet("pairling-connectd", flag.ContinueOnError)
	fs.SetOutput(os.Stderr)

	home, _ := os.UserHomeDir()
	appSupport := runtimecfg.DefaultAppSupportRoot(home)
	defaultStateDir := runtimecfg.DefaultStateDir(home)
	defaultHostname := runtimecfg.StableHostname(appSupport, defaultStateDir)
	defaultControlSocket := strings.TrimSpace(os.Getenv("PAIRLING_CONNECTD_CONTROL_SOCKET"))
	if defaultControlSocket == "" {
		defaultControlSocket = filepath.Join(home, ".claude", "companion", "connectd-control.sock")
	}
	defaultPairlingControlSocket := strings.TrimSpace(os.Getenv("PAIRLING_CONTROL_SOCKET"))
	if defaultPairlingControlSocket == "" {
		defaultPairlingControlSocket = filepath.Join(home, ".claude", "companion", "control.sock")
	}

	upstreamRaw := fs.String("upstream", "http://127.0.0.1:7773", "Pairling daemon upstream URL")
	listenAddr := fs.String("listen", ":7773", "tailnet-only service listen address")
	statusAddr := fs.String("status-addr", "127.0.0.1:7774", "loopback status server address")
	controlSocket := fs.String("control-socket", defaultControlSocket, "same-UID Unix status/control socket path")
	pairlingControlSocket := fs.String("pairling-control-socket", defaultPairlingControlSocket, "pairlingd same-UID Unix control socket path")
	stateDir := fs.String("state-dir", defaultStateDir, "tsnet state directory")
	hostname := fs.String("hostname", defaultHostname, "tailnet hostname for this Pairling Connect node")
	controlURL := fs.String("control-url", "", "advanced: custom Tailscale-compatible control server URL")
	maxBodyBytes := fs.Int64("max-body-bytes", 1_000_000, "maximum proxied request body size")
	verbose := fs.Bool("verbose", false, "enable verbose tsnet backend logs")
	// WS1: a tagged auth key (minted from an OAuth client scoped to
	// tag:pairling-connect) registers this node pre-authorized AND with key
	// expiry disabled — no 180-day re-auth cliff, no per-node REST call.
	// Empty keeps the legacy interactive browser-auth path (back-compat).
	authKeyTag := fs.String("auth-key-tag", defaultAuthKeyTag(), "tag applied to this node when registering with an auth key")
	funnelDefault := false
	switch strings.ToLower(strings.TrimSpace(os.Getenv("PAIRLING_CONNECT_FUNNEL"))) {
	case "1", "true", "on", "yes":
		funnelDefault = true
	}
	funnelEnabled := fs.Bool("funnel", funnelDefault, "expose a public Tailscale Funnel listener for the pre-pair bootstrap claim (off by default)")
	sshGatewayEnabled := fs.Bool("ssh-gateway", sshGatewayEnabledFromEnv(os.Getenv("PAIRLING_SSH_GATEWAY")), "open the loopback SSH-tunnel gateway listener (off by default)")
	sshGatewayAddr := fs.String("ssh-gateway-addr", "127.0.0.1:7775", "loopback address the SSH-tunnel gateway listens on")

	if err := fs.Parse(args); err != nil {
		if errors.Is(err, flag.ErrHelp) {
			return 0
		}
		return 2
	}
	if strings.TrimSpace(*hostname) == "" {
		*hostname = runtimecfg.HostnameFromInstallID("")
	}

	upstream, err := url.Parse(*upstreamRaw)
	if err != nil {
		log.Printf("invalid upstream: %v", err)
		return 2
	}
	if err := ensurePrivateDir(*stateDir); err != nil {
		log.Printf("cannot prepare state dir: %v", err)
		return 1
	}
	info, err := currentBuildInfo()
	if err != nil {
		log.Printf("cannot read build identity: %v", err)
		return 1
	}
	gatewayToken := (&internalHookTokenCache{home: home}).load

	statusStore := status.NewStore(*hostname)
	statusStore.SetControlURLMode(controlURLMode(*controlURL))
	statusStore.SetListenPort(listenPort(*listenAddr))
	statusStore.SetBuildIdentity(os.Getpid(), info.Version, info.SourceRevision, info.SourceDirty)
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	authOpener := newAuthOpenGate()
	statusServer, err := startStatusServer(*statusAddr, statusStore, home, authOpener)
	if err != nil {
		log.Printf("cannot start status server: %v", err)
		return 1
	}
	defer shutdownHTTPServer(statusServer)
	controlServer, err := startUnixControlServer(*controlSocket, statusStore, authOpener)
	if err != nil {
		log.Printf("cannot start Unix control server: %v", err)
		return 1
	}
	defer controlServer.Close()

	srv := &tsnet.Server{
		Dir:        *stateDir,
		Hostname:   *hostname,
		ControlURL: strings.TrimSpace(*controlURL),
		UserLogf:   userLogf(statusStore),
	}
	// WS1: tagged auth-key registration. A tagged node never expires its key,
	// eliminating the 180-day re-auth cliff without any Tailscale REST call.
	if authKey := loadTailscaleAuthKey(appSupport); authKey != "" {
		srv.AuthKey = authKey
		if tag := strings.TrimSpace(*authKeyTag); tag != "" {
			srv.AdvertiseTags = []string{tag}
		}
		statusStore.SetAuthKeyMode("tagged")
		log.Printf("pairling-connectd registering with tagged auth key (tag=%s)", strings.TrimSpace(*authKeyTag))
	} else {
		statusStore.SetAuthKeyMode("interactive")
	}
	if *verbose {
		srv.Logf = func(format string, args ...any) {
			log.Printf("tsnet: "+format, args...)
		}
	}

	handler, err := gateway.NewHandler(gateway.Options{
		Upstream:     upstream,
		MaxBodyBytes: *maxBodyBytes,
		Mode:         gateway.ExposureModePairlingConnect,
		Logger:       gatewayLogger{store: statusStore},
		RateLimiter:  gateway.NewMemoryRateLimiter(20, 5*time.Minute),
		PeerNodeResolver: tailscalePeerNodeResolver{localClient: func() (whoIsClient, error) {
			return srv.LocalClient()
		}},
		GatewayToken: gatewayToken,
	})
	if err != nil {
		log.Printf("cannot create gateway: %v", err)
		return 1
	}
	go monitorUpstream(ctx, upstream, statusStore, func(probeContext context.Context) bool {
		return probePairlingRoute(probeContext, *pairlingControlSocket, statusStore)
	})

	ln, err := srv.Listen("tcp", *listenAddr)
	if err != nil {
		statusStore.SetLastError(err.Error())
		log.Printf("cannot start tailnet listener: %v", err)
		return 1
	}
	defer srv.Close()
	statusStore.SetListenerRunning(true)
	go monitorTailnetIPs(ctx, srv, statusStore)

	// Optional public Funnel listener for the pre-pair bootstrap claim, off by
	// default. A SEPARATE handler in ExposureModeFunnelBootstrap,
	// never the tailnet pairling_connect handler, so the bearer post-pair surface
	// is structurally unreachable over Funnel. A failure to open is logged and
	// recorded but does not bring down the tailnet listener.
	var funnelServer *http.Server
	if *funnelEnabled {
		funnelMacIDHash := ""
		if id := strings.TrimSpace(runtimecfg.LoadInstallID(appSupport)); id != "" {
			sum := sha256.Sum256([]byte(id))
			funnelMacIDHash = hex.EncodeToString(sum[:])
		}
		funnelHandler, ferr := gateway.NewHandler(gateway.Options{
			Upstream:        upstream,
			MaxBodyBytes:    *maxBodyBytes,
			Mode:            gateway.ExposureModeFunnelBootstrap,
			Logger:          gatewayLogger{store: statusStore},
			FunnelLimiter:   gateway.NewFunnelLimiter(120, 5, 6),
			FunnelMacIDHash: funnelMacIDHash,
			GatewayToken:    gatewayToken,
		})
		if ferr != nil {
			log.Printf("cannot create funnel gateway: %v", ferr)
			return 1
		}
		funnelLn, ferr := srv.ListenFunnel("tcp", ":443", tsnet.FunnelOnly())
		if ferr != nil {
			statusStore.SetLastError(ferr.Error())
			log.Printf("cannot start funnel listener: %v", ferr)
		} else {
			funnelServer = newFunnelHTTPServer(funnelHandler)
			if domains := srv.CertDomains(); len(domains) > 0 {
				statusStore.SetFunnelHostname(domains[0])
				log.Printf("pairling-connectd funnel listener open host=%s", domains[0])
			}
			go func() {
				if serr := funnelServer.Serve(funnelLn); serr != nil && !errors.Is(serr, http.ErrServerClosed) {
					statusStore.SetLastError(serr.Error())
					log.Printf("funnel server stopped: %v", serr)
				}
			}()
		}
	}

	// SPEC-p5 §2.1: the loopback SSH-tunnel gateway, off by default. A
	// SEPARATE handler in ExposureModeSSH — post-pair surface only, no
	// /pair/* lifecycle — so a user-supplied `ssh -L` targets the perimeter
	// and never the daemon's loopback trust tier. A failure to open is
	// logged but does not bring down the tailnet listener.
	var sshServer *http.Server
	if *sshGatewayEnabled {
		sshHandler, serr := newSSHGatewayHandler(upstream, *maxBodyBytes, *sshGatewayAddr, gatewayToken)
		if serr != nil {
			log.Printf("cannot create ssh gateway: %v", serr)
			return 1
		}
		sshLn, serr := net.Listen("tcp", *sshGatewayAddr)
		if serr != nil {
			statusStore.SetLastError(serr.Error())
			log.Printf("cannot start ssh gateway listener: %v", serr)
		} else {
			sshServer = &http.Server{Handler: sshHandler, ReadHeaderTimeout: 10 * time.Second}
			log.Printf("pairling-connectd ssh gateway open addr=%s", *sshGatewayAddr)
			go func() {
				if serveErr := sshServer.Serve(sshLn); serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
					statusStore.SetLastError(serveErr.Error())
					log.Printf("ssh gateway server stopped: %v", serveErr)
				}
			}()
		}
	}

	log.Printf("pairling-connectd hostname=%s state_dir=%s listen=%s upstream=%s status=%s", *hostname, *stateDir, *listenAddr, upstream.String(), *statusAddr)
	server := &http.Server{
		Handler:           handler,
		ReadHeaderTimeout: 10 * time.Second,
	}

	errCh := make(chan error, 1)
	go func() {
		errCh <- server.Serve(ln)
	}()

	select {
	case <-ctx.Done():
		shutdownHTTPServer(server)
		shutdownHTTPServer(funnelServer)
		shutdownHTTPServer(sshServer)
		return 0
	case err := <-errCh:
		if err != nil && !errors.Is(err, http.ErrServerClosed) {
			statusStore.SetLastError(err.Error())
			log.Printf("tailnet server stopped: %v", err)
			return 1
		}
		return 0
	}
}

func newFunnelHTTPServer(handler http.Handler) *http.Server {
	return &http.Server{
		Handler:           handler,
		ReadHeaderTimeout: 10 * time.Second,
		ReadTimeout:       15 * time.Second,
		WriteTimeout:      30 * time.Second,
		IdleTimeout:       60 * time.Second,
	}
}

func currentBuildInfo() (buildInfo, error) {
	dirty, err := strconv.ParseBool(strings.TrimSpace(buildSourceDirty))
	if err != nil {
		return buildInfo{}, fmt.Errorf("invalid source dirty stamp: %w", err)
	}
	return buildInfo{
		SchemaVersion:  1,
		Version:        strings.TrimSpace(buildVersion),
		SourceRevision: strings.TrimSpace(buildSourceRevision),
		SourceDirty:    dirty,
	}, nil
}

func writeBuildInfo(writer io.Writer) error {
	info, err := currentBuildInfo()
	if err != nil {
		return err
	}
	encoder := json.NewEncoder(writer)
	encoder.SetEscapeHTML(false)
	return encoder.Encode(info)
}

func controlURLMode(raw string) string {
	if strings.TrimSpace(raw) == "" {
		return status.DefaultControlURLMode
	}
	return status.CustomControlURLMode
}

// defaultAuthKeyTag is the ACL tag applied to Pairling Connect nodes that
// register with a tagged auth key. Tagged nodes do not expire their keys.
func defaultAuthKeyTag() string {
	if tag := strings.TrimSpace(os.Getenv("PAIRLING_TS_AUTHKEY_TAG")); tag != "" {
		return tag
	}
	return "tag:pairling-connect"
}

// loadTailscaleAuthKey resolves a tagged auth key, preferring the environment
// (PAIRLING_TS_AUTHKEY) over a mode-600 credential file under Application
// Support. Returns "" when neither is present (legacy interactive auth).
func loadTailscaleAuthKey(appSupport string) string {
	if key := strings.TrimSpace(os.Getenv("PAIRLING_TS_AUTHKEY")); key != "" {
		return key
	}
	path := filepath.Join(appSupport, "connectd", "connectd-ts-authkey")
	data, err := os.ReadFile(path)
	if err != nil {
		return ""
	}
	return strings.TrimSpace(string(data))
}

func listenPort(addr string) int {
	addr = strings.TrimSpace(addr)
	if addr == "" {
		return status.DefaultListenPort
	}
	_, portString, err := net.SplitHostPort(addr)
	if err != nil {
		if strings.HasPrefix(addr, ":") {
			portString = strings.TrimPrefix(addr, ":")
		} else {
			idx := strings.LastIndex(addr, ":")
			if idx >= 0 && idx < len(addr)-1 {
				portString = addr[idx+1:]
			}
		}
	}
	port, err := strconv.Atoi(portString)
	if err != nil || port <= 0 {
		return status.DefaultListenPort
	}
	return port
}

type gatewayLogger struct {
	store *status.Store
}

func (l gatewayLogger) Log(event gateway.Event) {
	log.Printf("gateway method=%s path=%s status=%d outcome=%s", event.Method, event.Path, event.Status, event.Outcome)
	if l.store != nil {
		l.store.RecordGatewayEvent(event.Method, event.Path, event.Status, event.Outcome)
	}
}

func userLogf(store *status.Store) func(string, ...any) {
	return func(format string, args ...any) {
		msg := fmt.Sprintf(format, args...)
		redacted := status.Redact(msg)
		log.Printf("tailscale: %s", redacted)
		if strings.Contains(msg, "login.tailscale.com") || strings.Contains(strings.ToLower(msg), "auth") {
			store.SetAuthPending(msg)
		}
	}
}

func ensurePrivateDir(path string) error {
	if strings.TrimSpace(path) == "" {
		return errors.New("state dir is required")
	}
	cleaned := filepath.Clean(path)
	if err := os.MkdirAll(cleaned, 0o700); err != nil {
		return err
	}
	return os.Chmod(cleaned, 0o700)
}

func newStatusHandler(home string, store *status.Store, openers ...*authOpenGate) http.Handler {
	mux := http.NewServeMux()
	authOpener := newAuthOpenGate()
	if len(openers) > 0 && openers[0] != nil {
		authOpener = openers[0]
	}
	mux.Handle("/status", store.Handler())
	mux.HandleFunc("/auth/open", func(w http.ResponseWriter, r *http.Request) {
		handleAuthOpen(w, r, store, authOpener)
	})
	mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(`{"ok":true}` + "\n"))
	})
	return requireInternalStatusToken(home, mux)
}

func startStatusServer(addr string, store *status.Store, home string, openers ...*authOpenGate) (*http.Server, error) {
	server := &http.Server{
		Addr:              addr,
		Handler:           newStatusHandler(home, store, openers...),
		ReadHeaderTimeout: 5 * time.Second,
	}
	listener, err := net.Listen("tcp", addr)
	if err != nil {
		return nil, err
	}
	go func() {
		if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
			log.Printf("status server stopped: %v", err)
			store.SetLastError(err.Error())
		}
	}()
	return server, nil
}

const internalStatusTokenHeader = "X-Pairling-Internal-Token"

func requireInternalStatusToken(home string, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if !isLoopbackRemote(r.RemoteAddr) {
			http.Error(w, "loopback required", http.StatusForbidden)
			return
		}
		expected := loadInternalHookToken(home)
		provided := strings.TrimSpace(r.Header.Get(internalStatusTokenHeader))
		if expected == "" {
			http.Error(w, "internal status authentication unavailable", http.StatusServiceUnavailable)
			return
		}
		if len(provided) != len(expected) || subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) != 1 {
			http.Error(w, "unauthorized", http.StatusUnauthorized)
			return
		}
		next.ServeHTTP(w, r)
	})
}

const authOpenRetryCooldown = 30 * time.Second

type authOpenProcess interface {
	Wait() error
}

type authOpenStarter func(rawURL string) (authOpenProcess, error)

type authOpenOutcome uint8

const (
	authOpenStarted authOpenOutcome = iota
	authOpenAlreadyRequested
	authOpenRetryBlocked
)

type authOpenGate struct {
	mu                 sync.Mutex
	currentURL         string
	lastAttempt        time.Time
	lastStartSucceeded bool
	inFlight           bool
	retryCooldown      time.Duration
	now                func() time.Time
	start              authOpenStarter
	runWorker          func(func())
}

func newAuthOpenGate() *authOpenGate {
	return &authOpenGate{
		retryCooldown: authOpenRetryCooldown,
		now:           time.Now,
		start: func(rawURL string) (authOpenProcess, error) {
			command := exec.Command("/usr/bin/open", rawURL)
			if err := command.Start(); err != nil {
				return nil, err
			}
			return command, nil
		},
		runWorker: func(work func()) {
			go work()
		},
	}
}

func (g *authOpenGate) open(rawURL string) (authOpenOutcome, error) {
	now := g.now()
	g.mu.Lock()
	urlChanged := rawURL != g.currentURL
	if urlChanged {
		g.currentURL = rawURL
		g.lastAttempt = time.Time{}
		g.lastStartSucceeded = false
	}
	if g.inFlight {
		g.mu.Unlock()
		if urlChanged {
			return authOpenRetryBlocked, nil
		}
		return authOpenAlreadyRequested, nil
	}
	if !g.lastAttempt.IsZero() && now.Before(g.lastAttempt.Add(g.retryCooldown)) {
		startSucceeded := g.lastStartSucceeded
		g.mu.Unlock()
		if startSucceeded {
			return authOpenAlreadyRequested, nil
		}
		return authOpenRetryBlocked, nil
	}
	g.lastAttempt = now
	process, err := g.start(rawURL)
	if err != nil {
		g.lastStartSucceeded = false
		g.mu.Unlock()
		return authOpenRetryBlocked, err
	}
	g.lastStartSucceeded = true
	g.inFlight = true
	g.mu.Unlock()

	g.runWorker(func() {
		_ = process.Wait()
		g.mu.Lock()
		g.inFlight = false
		g.mu.Unlock()
	})
	return authOpenStarted, nil
}

func handleAuthOpen(w http.ResponseWriter, r *http.Request, store *status.Store, opener *authOpenGate) {
	if r.Method != http.MethodPost {
		http.Error(w, "POST required", http.StatusMethodNotAllowed)
		return
	}
	if !isLoopbackRemote(r.RemoteAddr) {
		http.Error(w, "loopback required", http.StatusForbidden)
		return
	}
	writeAuthOpenResponse(w, store, opener)
}

func writeAuthOpenResponse(w http.ResponseWriter, store *status.Store, opener *authOpenGate) {

	w.Header().Set("Content-Type", "application/json")
	rawURL, ok := store.AuthURLForOpen()
	if !ok {
		w.WriteHeader(http.StatusConflict)
		_ = json.NewEncoder(w).Encode(map[string]any{
			"ok":               false,
			"opened":           false,
			"auth_url_present": store.Snapshot().AuthURLPresent,
			"error":            "Pairling Connect auth URL is not available yet.",
		})
		return
	}
	outcome, err := opener.open(rawURL)
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		_ = json.NewEncoder(w).Encode(map[string]any{
			"ok":               false,
			"opened":           false,
			"auth_url_present": true,
			"error":            "Pairling Connect could not open browser approval.",
		})
		return
	}
	if outcome == authOpenRetryBlocked {
		w.WriteHeader(http.StatusServiceUnavailable)
		_ = json.NewEncoder(w).Encode(map[string]any{
			"ok":               false,
			"opened":           false,
			"auth_url_present": true,
			"error":            "Pairling Connect browser approval is temporarily unavailable.",
		})
		return
	}
	response := map[string]any{
		"ok":               true,
		"opened":           outcome == authOpenStarted,
		"auth_url_present": true,
	}
	if outcome == authOpenAlreadyRequested {
		response["error"] = "Pairling Connect browser approval was already requested."
	}
	_ = json.NewEncoder(w).Encode(response)
}

func isLoopbackRemote(remoteAddr string) bool {
	host, _, err := net.SplitHostPort(remoteAddr)
	if err != nil {
		host = remoteAddr
	}
	ip := net.ParseIP(strings.TrimSpace(host))
	return ip != nil && ip.IsLoopback()
}

func shutdownHTTPServer(server *http.Server) {
	if server == nil {
		return
	}
	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()
	_ = server.Shutdown(ctx)
}

func monitorUpstream(
	ctx context.Context,
	upstream *url.URL,
	store *status.Store,
	routeProbe func(context.Context) bool,
) {
	ticker := time.NewTicker(5 * time.Second)
	defer ticker.Stop()
	check := func() {
		refreshUpstreamStatus(ctx, upstream, store, routeProbe)
	}
	check()
	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
			check()
		}
	}
}

func refreshUpstreamStatus(
	ctx context.Context,
	upstream *url.URL,
	store *status.Store,
	routeProbe func(context.Context) bool,
) {
	reachable := checkUpstream(upstream)
	store.SetUpstreamReachable(reachable)
	if !reachable || routeProbe == nil {
		return
	}
	proofs := status.GatewayRecoveryProofs
	if store.Snapshot().GatewayHealthy {
		proofs = 1
	}
	for range proofs {
		probeContext, cancel := context.WithTimeout(ctx, 3*time.Second)
		verified := routeProbe(probeContext)
		cancel()
		if !verified {
			// The Unix route probe records verified successes. Record a
			// fail-closed result for transport, peer-auth, and validation
			// failures that never produce a trusted route proof.
			store.RecordGatewayEvent(http.MethodGet, "/routez", 0, "validation_failed")
			return
		}
	}
}

type internalHookTokenCache struct {
	mu        sync.Mutex
	home      string
	token     string
	checkedAt time.Time
}

func (cache *internalHookTokenCache) load() string {
	cache.mu.Lock()
	defer cache.mu.Unlock()
	now := time.Now()
	if !cache.checkedAt.IsZero() && now.Sub(cache.checkedAt) < time.Second {
		return cache.token
	}
	cache.token = loadInternalHookToken(cache.home)
	cache.checkedAt = now
	return cache.token
}

func loadInternalHookToken(home string) string {
	path := strings.TrimSpace(os.Getenv("PAIRLING_INTERNAL_HOOK_TOKEN_FILE"))
	if path == "" {
		path = filepath.Join(home, ".claude", "companion", "internal-hook-token")
	}
	metadata, err := os.Lstat(path)
	if err != nil || !metadata.Mode().IsRegular() || metadata.Mode().Perm()&0o077 != 0 {
		return ""
	}
	if system, ok := metadata.Sys().(*syscall.Stat_t); !ok || int(system.Uid) != os.Getuid() {
		return ""
	}
	payload, err := os.ReadFile(path)
	if err != nil {
		return ""
	}
	token := strings.TrimSpace(string(payload))
	if len(token) != 64 {
		return ""
	}
	if _, err := hex.DecodeString(token); err != nil {
		return ""
	}
	return token
}

func checkUpstream(upstream *url.URL) bool {
	if upstream == nil {
		return false
	}
	healthURL := *upstream
	healthURL.Path = strings.TrimRight(healthURL.Path, "/") + "/readyz"
	healthURL.RawQuery = ""
	client := http.Client{Timeout: 2 * time.Second}
	resp, err := client.Get(healthURL.String())
	if err != nil {
		return false
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))
		return false
	}
	var payload struct {
		OK              bool   `json:"ok"`
		ContractVersion string `json:"contract_version"`
	}
	if err := json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&payload); err != nil {
		return false
	}
	return payload.OK && payload.ContractVersion == "pairling-runtime-v1"
}

func monitorTailnetIPs(ctx context.Context, srv *tsnet.Server, store *status.Store) {
	ticker := time.NewTicker(2 * time.Second)
	defer ticker.Stop()
	update := func() {
		identity := NodeIdentity{}
		if lc, err := srv.LocalClient(); err == nil {
			if lock, err := lc.NetworkLockStatus(ctx); err == nil {
				store.SetTailnetLockEnabled(lock.Enabled)
			}
			if st, err := lc.StatusWithoutPeers(ctx); err == nil {
				identity = nodeIdentityFromStatus(st)
				store.SetTailnetIdentity(identity.NodeID, identity.Tags, identity.TailnetIPs)
			}
		}
		if len(identity.TailnetIPs) > 0 {
			store.SetTailnetIP(identity.TailnetIPs[0])
			store.SetAuthenticated()
			return
		}
		ip4, _ := srv.TailscaleIPs()
		if ip4.IsValid() {
			store.SetTailnetIP(ip4.String())
			store.SetAuthenticated()
			return
		}
		store.SetTailnetIdentity("", nil, nil)
		store.SetAuthPending("")
	}
	update()
	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
			update()
		}
	}
}

type NodeIdentity struct {
	NodeID     string
	Tags       []string
	TailnetIPs []string
}

type whoIsClient interface {
	WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsResponse, error)
}

type tailscalePeerNodeResolver struct {
	localClient func() (whoIsClient, error)
}

func (r tailscalePeerNodeResolver) PeerNodeID(ctx context.Context, remoteAddr string) (string, string, bool) {
	if r.localClient == nil {
		return "", "", false
	}
	lc, err := r.localClient()
	if err != nil || lc == nil {
		return "", "", false
	}
	who, err := lc.WhoIs(ctx, remoteAddr)
	if err != nil {
		return "", "", false
	}
	return peerNodeIDFromWhoIs(who)
}

// peerNodeIDFromWhoIs resolves a peer's tailnet node ID and its provenance from
// a WhoIs response. The node ID is always the WhoIs StableID; it is never
// derived from any client-controlled value. Two provenance paths are admitted:
//
//   - "tagged": the old minted path, where the node carries tag:pairling-phone.
//   - "interactive": an untagged, user-owned Pairling iOS node from the D2
//     sign-in path, identified by a WhoIs hostname with the pairling-ios- prefix.
//
// connectd's hostname gate here is defense-in-depth; pairlingd performs the real
// bearer + request-proof gate. As a guard against a non-iOS node spoofing the
// pairling-ios- hostname, an untagged node is rejected when its Hostinfo reports
// a non-empty OS that is not iOS.
func peerNodeIDFromWhoIs(who *apitype.WhoIsResponse) (string, string, bool) {
	if who == nil || who.Node == nil {
		return "", "", false
	}
	nodeID := strings.TrimSpace(string(who.Node.StableID))
	if nodeID == "" {
		return "", "", false
	}
	for _, tag := range who.Node.Tags {
		if tag == "tag:pairling-phone" {
			return nodeID, "tagged", true
		}
	}
	hostname := who.Node.ComputedName
	if hostname == "" && who.Node.Hostinfo.Valid() {
		hostname = who.Node.Hostinfo.Hostname()
	}
	if strings.HasPrefix(strings.ToLower(hostname), "pairling-ios-") {
		if who.Node.Hostinfo.Valid() {
			if os := who.Node.Hostinfo.OS(); os != "" && !strings.EqualFold(os, "iOS") {
				return "", "", false
			}
		}
		return nodeID, "interactive", true
	}
	return "", "", false
}

func nodeIdentityFromStatus(st *ipnstate.Status) NodeIdentity {
	if st == nil || st.Self == nil {
		return NodeIdentity{}
	}
	self := st.Self
	identity := NodeIdentity{NodeID: string(self.ID)}
	if self.Tags != nil {
		for _, tag := range self.Tags.All() {
			identity.Tags = append(identity.Tags, tag)
		}
	}
	for _, ip := range self.TailscaleIPs {
		if ip.IsValid() {
			identity.TailnetIPs = append(identity.TailnetIPs, ip.String())
		}
	}
	return identity
}
