//go:build !windows

package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"net"
	"net/http"
	"net/http/httputil"
	"net/url"
	"os"
	"strings"
	"sync"
	"time"

	"github.com/spf13/cobra"

	incus "github.com/lxc/incus/v6/client"
	"github.com/lxc/incus/v6/cmd/incus/color"
	u "github.com/lxc/incus/v6/cmd/incus/usage"
	"github.com/lxc/incus/v6/internal/i18n"
	"github.com/lxc/incus/v6/shared/api"
	cli "github.com/lxc/incus/v6/shared/cmd"
)

type cmdRemoteProxy struct {
	global *cmdGlobal
	remote *cmdRemote

	flagTimeout int
}

// Not the most beautiful way to encode it, but this command is an outlier in that regard.
var cmdRemoteProxyUsage = u.Usage{u.Either(u.Remote, u.Colon(u.Remote)), u.Target(u.Placeholder(i18n.G("socket file")))}

// Command returns a cobra.Command for use with (*cobra.Command).AddCommand.
func (c *cmdRemoteProxy) Command() *cobra.Command {
	cmd := &cobra.Command{}
	cmd.Use = cli.U("proxy", cmdRemoteProxyUsage...)
	cmd.Short = i18n.G("Run a local API proxy")
	cmd.Long = cli.FormatSection(color.DescriptionPrefix, i18n.G(
		`Run a local API proxy for the remote`))

	cmd.RunE = c.Run

	cmd.Flags().IntVar(&c.flagTimeout, "timeout", 0, i18n.G("Proxy timeout (exits when no connections)")+"``")

	return cmd
}

// Run runs the actual command logic.
func (c *cmdRemoteProxy) Run(cmd *cobra.Command, args []string) error {
	parsed, err := cmdRemoteProxyUsage.Parse(c.global.conf, cmd, args)
	if err != nil {
		return err
	}

	remoteName := parsed[0].String
	path := parsed[1].String

	// Detect remote name.
	if !strings.HasSuffix(remoteName, ":") {
		remoteName = remoteName + ":"
	}

	remote := c.global.conf.Remotes[strings.TrimSuffix(remoteName, ":")]
	remote.KeepAlive = 0
	c.global.conf.Remotes[strings.TrimSuffix(remoteName, ":")] = remote

	resources, err := c.global.parseServers(remoteName)
	if err != nil {
		return err
	}

	s := resources[0].server

	// Create proxy socket.
	err = os.Remove(path)
	if err != nil && !errors.Is(err, os.ErrNotExist) {
		return fmt.Errorf("Failed to delete pre-existing unix socket: %w", err)
	}

	unixAddr, err := net.ResolveUnixAddr("unix", path)
	if err != nil {
		return fmt.Errorf("Unable to resolve unix socket: %w", err)
	}

	server, err := net.ListenUnix("unix", unixAddr)
	if err != nil {
		return fmt.Errorf("Unable to setup unix socket: %w", err)
	}

	err = os.Chmod(path, 0o600)
	if err != nil {
		return fmt.Errorf("Unable to set socket permissions: %w", err)
	}

	// Get the connection info.
	info, err := s.GetConnectionInfo()
	if err != nil {
		return err
	}

	uri, err := url.Parse(info.URL)
	if err != nil {
		return err
	}

	// Enable keep-alive for proxied connections.
	httpClient, err := s.GetHTTPClient()
	if err != nil {
		return err
	}

	httpTransport, ok := httpClient.Transport.(*http.Transport)
	if ok {
		httpTransport.DisableKeepAlives = false
	}

	// Get server info.
	api10, api10Etag, err := s.GetServer()
	if err != nil {
		return err
	}

	// Handle inbound connections.
	transport := remoteProxyTransport{
		s:       s,
		baseURL: uri,
	}

	connections := uint64(0)
	transactions := uint64(0)

	handler := remoteProxyHandler{
		s:         s,
		transport: transport,
		api10:     api10,
		api10Etag: api10Etag,

		mu:           &sync.RWMutex{},
		connections:  &connections,
		transactions: &transactions,
	}

	// Handle the timeout.
	if c.flagTimeout > 0 {
		go func() {
			for {
				time.Sleep(time.Duration(c.flagTimeout) * time.Second)

				// Check for active connections.
				handler.mu.RLock()
				if *handler.connections > 0 {
					handler.mu.RUnlock()
					continue
				}

				// Look for recent activity
				oldCount := uint64(*handler.transactions)
				handler.mu.RUnlock()

				time.Sleep(5 * time.Second)

				handler.mu.RLock()
				if oldCount == *handler.transactions {
					handler.mu.RUnlock()

					// Daemon has been inactive for 10s, exit.
					os.Exit(0) // nolint:revive
				}

				handler.mu.RUnlock()
			}
		}()
	}

	// Start the server.
	err = http.Serve(server, handler)
	if err != nil {
		return err
	}

	return nil
}

type remoteProxyTransport struct {
	s incus.InstanceServer

	baseURL *url.URL
}

// RoundTrip handles an HTTP request.
func (t remoteProxyTransport) RoundTrip(r *http.Request) (*http.Response, error) {
	// Fix the request.
	r.URL.Scheme = t.baseURL.Scheme
	r.URL.Host = t.baseURL.Host
	r.RequestURI = ""

	resp, err := t.s.DoHTTP(r)
	if errors.Is(err, incus.ErrOIDCExpired) {
		// Override the response so the client knows to retry the request.
		resp.StatusCode = http.StatusUseProxy
		resp.Status = "Retry the request for OIDC refresh"

		return resp, nil
	}

	return resp, err
}

type remoteProxyHandler struct {
	s         incus.InstanceServer
	transport http.RoundTripper
	url       string

	mu           *sync.RWMutex
	connections  *uint64
	transactions *uint64

	api10     *api.Server
	api10Etag string

	token string
}

func (h remoteProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	// Increase counters.
	defer func() {
		h.mu.Lock()
		*h.connections--
		h.mu.Unlock()
	}()

	h.mu.Lock()
	*h.transactions++
	*h.connections++
	h.mu.Unlock()

	// Don't allow cross-origin requests.
	origin := r.Header.Get("Origin")
	if origin != "" && origin != h.url {
		return
	}

	// Basic auth.
	if h.token != "" {
		// Parse query URL.
		values, err := url.ParseQuery(r.URL.RawQuery)
		if err != nil {
			return
		}

		token := values.Get("auth_token")
		if token != "" {
			// If a token was passed through the URL, persist it as a cookie.

			tokenCookie := http.Cookie{
				Name:     "auth_token",
				Value:    token,
				Path:     "/",
				Secure:   false,
				HttpOnly: true,
				SameSite: http.SameSiteStrictMode,
			}

			http.SetCookie(w, &tokenCookie)
		} else {
			// If not, attempt to pull it from the cookie.
			cookie, err := r.Cookie("auth_token")
			if err != nil {
				// Fail authentication if no cookie can be found.
				w.WriteHeader(http.StatusUnauthorized)
				return
			}

			token = cookie.Value
		}

		// Check the user token against the expected value.
		if token != h.token {
			w.WriteHeader(http.StatusUnauthorized)
			return
		}
	}

	// Handle /1.0 internally (saves a round-trip).
	if r.RequestURI == "/1.0" || strings.HasPrefix(r.RequestURI, "/1.0?project=") {
		// Parse query URL.
		values, err := url.ParseQuery(r.URL.RawQuery)
		if err != nil {
			return
		}

		// Update project name to match.
		projectName := values.Get("project")
		if projectName == "" {
			projectName = api.ProjectDefaultName
		}

		api10 := api.Server(*h.api10)
		api10.Environment.Project = projectName

		// Set the request headers.
		w.Header().Set("Content-Type", "application/json")
		w.Header().Set("ETag", h.api10Etag)
		w.WriteHeader(http.StatusOK)

		// Generate a body from the cached data.
		serverBody, err := json.Marshal(api10)
		if err != nil {
			return
		}

		apiResponse := api.Response{
			Type:       "sync",
			Status:     "success",
			StatusCode: 200,
			Metadata:   serverBody,
		}

		body, err := json.Marshal(apiResponse)
		if err != nil {
			return
		}

		_, _ = w.Write(body)

		return
	}

	// Forward everything else.
	proxy := httputil.ReverseProxy{
		Transport: h.transport,
		Director:  func(*http.Request) {},
	}

	proxy.ServeHTTP(w, r)
}
