package coderd_test

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"net/http/httputil"
	"net/url"
	"testing"
	"time"

	"github.com/google/uuid"
	"github.com/moby/moby/pkg/namesgenerator"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"cdr.dev/slog"
	"cdr.dev/slog/sloggers/slogtest"
	"github.com/coder/coder/v2/agent"
	"github.com/coder/coder/v2/buildinfo"
	"github.com/coder/coder/v2/coderd/coderdtest"
	"github.com/coder/coder/v2/coderd/database"
	"github.com/coder/coder/v2/coderd/database/dbtestutil"
	"github.com/coder/coder/v2/coderd/workspaceapps"
	"github.com/coder/coder/v2/codersdk"
	"github.com/coder/coder/v2/codersdk/agentsdk"
	"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
	"github.com/coder/coder/v2/enterprise/coderd/license"
	"github.com/coder/coder/v2/enterprise/wsproxy/wsproxysdk"
	"github.com/coder/coder/v2/provisioner/echo"
	"github.com/coder/coder/v2/testutil"
)

func TestRegions(t *testing.T) {
	t.Parallel()

	const appHostname = "*.apps.coder.test"

	t.Run("OK", func(t *testing.T) {
		t.Parallel()

		dv := coderdtest.DeploymentValues(t)
		dv.Experiments = []string{
			string(codersdk.ExperimentMoons),
			"*",
		}

		db, pubsub := dbtestutil.NewDB(t)

		client, _ := coderdenttest.New(t, &coderdenttest.Options{
			Options: &coderdtest.Options{
				AppHostname:      appHostname,
				Database:         db,
				Pubsub:           pubsub,
				DeploymentValues: dv,
			},
		})

		ctx := testutil.Context(t, testutil.WaitLong)
		deploymentID, err := db.GetDeploymentID(ctx)
		require.NoError(t, err, "get deployment ID")

		regions, err := client.Regions(ctx)
		require.NoError(t, err)

		require.Len(t, regions, 1)
		require.NotEqual(t, uuid.Nil, regions[0].ID)
		require.Equal(t, regions[0].ID.String(), deploymentID)
		require.Equal(t, "primary", regions[0].Name)
		require.Equal(t, "Default", regions[0].DisplayName)
		require.NotEmpty(t, regions[0].IconURL)
		require.True(t, regions[0].Healthy)
		require.Equal(t, client.URL.String(), regions[0].PathAppURL)
		require.Equal(t, appHostname, regions[0].WildcardHostname)

		// Ensure the primary region ID is constant.
		regions2, err := client.Regions(ctx)
		require.NoError(t, err)
		require.Equal(t, regions[0].ID, regions2[0].ID)
	})

	t.Run("WithProxies", func(t *testing.T) {
		t.Parallel()

		dv := coderdtest.DeploymentValues(t)
		dv.Experiments = []string{
			string(codersdk.ExperimentMoons),
			"*",
		}

		db, pubsub := dbtestutil.NewDB(t)

		client, closer, api, _ := coderdenttest.NewWithAPI(t, &coderdenttest.Options{
			Options: &coderdtest.Options{
				AppHostname:      appHostname,
				Database:         db,
				Pubsub:           pubsub,
				DeploymentValues: dv,
			},
			LicenseOptions: &coderdenttest.LicenseOptions{
				Features: license.Features{
					codersdk.FeatureWorkspaceProxy: 1,
				},
			},
		})
		t.Cleanup(func() {
			_ = closer.Close()
		})
		ctx := testutil.Context(t, testutil.WaitLong)
		deploymentID, err := db.GetDeploymentID(ctx)
		require.NoError(t, err, "get deployment ID")

		const proxyName = "hello"
		_ = coderdenttest.NewWorkspaceProxy(t, api, client, &coderdenttest.ProxyOptions{
			Name:        proxyName,
			AppHostname: appHostname + ".proxy",
		})
		proxy, err := db.GetWorkspaceProxyByName(ctx, proxyName)
		require.NoError(t, err)

		// Refresh proxy health.
		err = api.ProxyHealth.ForceUpdate(ctx)
		require.NoError(t, err)

		regions, err := client.Regions(ctx)
		require.NoError(t, err)
		require.Len(t, regions, 2)

		// Region 0 is the primary	require.Len(t, regions, 1)
		require.NotEqual(t, uuid.Nil, regions[0].ID)
		require.Equal(t, regions[0].ID.String(), deploymentID)
		require.Equal(t, "primary", regions[0].Name)
		require.Equal(t, "Default", regions[0].DisplayName)
		require.NotEmpty(t, regions[0].IconURL)
		require.True(t, regions[0].Healthy)
		require.Equal(t, client.URL.String(), regions[0].PathAppURL)
		require.Equal(t, appHostname, regions[0].WildcardHostname)

		// Region 1 is the proxy.
		require.NotEqual(t, uuid.Nil, regions[1].ID)
		require.Equal(t, proxy.ID, regions[1].ID)
		require.Equal(t, proxy.Name, regions[1].Name)
		require.Equal(t, proxy.DisplayName, regions[1].DisplayName)
		require.Equal(t, proxy.Icon, regions[1].IconURL)
		require.True(t, regions[1].Healthy)
		require.Equal(t, proxy.Url, regions[1].PathAppURL)
		require.Equal(t, proxy.WildcardHostname, regions[1].WildcardHostname)
	})

	t.Run("RequireAuth", func(t *testing.T) {
		t.Parallel()

		dv := coderdtest.DeploymentValues(t)
		dv.Experiments = []string{
			string(codersdk.ExperimentMoons),
			"*",
		}

		ctx := testutil.Context(t, testutil.WaitLong)
		client, _ := coderdenttest.New(t, &coderdenttest.Options{
			Options: &coderdtest.Options{
				AppHostname:      appHostname,
				DeploymentValues: dv,
			},
		})

		unauthedClient := codersdk.New(client.URL)
		regions, err := unauthedClient.Regions(ctx)
		require.Error(t, err)
		require.Empty(t, regions)
	})
}

func TestWorkspaceProxyCRUD(t *testing.T) {
	t.Parallel()

	t.Run("CreateAndUpdate", func(t *testing.T) {
		t.Parallel()

		dv := coderdtest.DeploymentValues(t)
		dv.Experiments = []string{
			string(codersdk.ExperimentMoons),
			"*",
		}
		client, _ := coderdenttest.New(t, &coderdenttest.Options{
			Options: &coderdtest.Options{
				DeploymentValues: dv,
			},
			LicenseOptions: &coderdenttest.LicenseOptions{
				Features: license.Features{
					codersdk.FeatureWorkspaceProxy: 1,
				},
			},
		})
		ctx := testutil.Context(t, testutil.WaitLong)
		proxyRes, err := client.CreateWorkspaceProxy(ctx, codersdk.CreateWorkspaceProxyRequest{
			Name: namesgenerator.GetRandomName(1),
			Icon: "/emojis/flag.png",
		})
		require.NoError(t, err)

		found, err := client.WorkspaceProxyByID(ctx, proxyRes.Proxy.ID)
		require.NoError(t, err)
		// This will be different, so set it to the same
		found.Status = proxyRes.Proxy.Status
		require.Equal(t, proxyRes.Proxy, found, "expected proxy")
		require.NotEmpty(t, proxyRes.ProxyToken)

		// Update the proxy
		expName := namesgenerator.GetRandomName(1)
		expDisplayName := namesgenerator.GetRandomName(1)
		expIcon := namesgenerator.GetRandomName(1)
		_, err = client.PatchWorkspaceProxy(ctx, codersdk.PatchWorkspaceProxy{
			ID:          proxyRes.Proxy.ID,
			Name:        expName,
			DisplayName: expDisplayName,
			Icon:        expIcon,
		})
		require.NoError(t, err, "expected no error updating proxy")

		found, err = client.WorkspaceProxyByID(ctx, proxyRes.Proxy.ID)
		require.NoError(t, err)
		require.Equal(t, expName, found.Name, "name")
		require.Equal(t, expDisplayName, found.DisplayName, "display name")
		require.Equal(t, expIcon, found.IconURL, "icon")
	})

	t.Run("Delete", func(t *testing.T) {
		t.Parallel()

		dv := coderdtest.DeploymentValues(t)
		dv.Experiments = []string{
			string(codersdk.ExperimentMoons),
			"*",
		}
		client, _ := coderdenttest.New(t, &coderdenttest.Options{
			Options: &coderdtest.Options{
				DeploymentValues: dv,
			},
			LicenseOptions: &coderdenttest.LicenseOptions{
				Features: license.Features{
					codersdk.FeatureWorkspaceProxy: 1,
				},
			},
		})
		ctx := testutil.Context(t, testutil.WaitLong)
		proxyRes, err := client.CreateWorkspaceProxy(ctx, codersdk.CreateWorkspaceProxyRequest{
			Name: namesgenerator.GetRandomName(1),
			Icon: "/emojis/flag.png",
		})
		require.NoError(t, err)

		err = client.DeleteWorkspaceProxyByID(ctx, proxyRes.Proxy.ID)
		require.NoError(t, err, "failed to delete workspace proxy")

		proxies, err := client.WorkspaceProxies(ctx)
		require.NoError(t, err)
		// Default proxy is always there
		require.Len(t, proxies.Regions, 1)
	})
}

func TestProxyRegisterDeregister(t *testing.T) {
	t.Parallel()

	setup := func(t *testing.T) (*codersdk.Client, database.Store) {
		dv := coderdtest.DeploymentValues(t)
		dv.Experiments = []string{
			string(codersdk.ExperimentMoons),
			"*",
		}

		db, pubsub := dbtestutil.NewDB(t)
		client, _ := coderdenttest.New(t, &coderdenttest.Options{
			Options: &coderdtest.Options{
				DeploymentValues:         dv,
				Database:                 db,
				Pubsub:                   pubsub,
				IncludeProvisionerDaemon: true,
			},
			ReplicaSyncUpdateInterval: time.Minute,
			LicenseOptions: &coderdenttest.LicenseOptions{
				Features: license.Features{
					codersdk.FeatureWorkspaceProxy: 1,
				},
			},
		})

		return client, db
	}

	t.Run("OK", func(t *testing.T) {
		t.Parallel()

		client, db := setup(t)

		ctx := testutil.Context(t, testutil.WaitLong)
		const (
			proxyName        = "hello"
			proxyDisplayName = "Hello World"
			proxyIcon        = "/emojis/flag.png"
		)
		createRes, err := client.CreateWorkspaceProxy(ctx, codersdk.CreateWorkspaceProxyRequest{
			Name:        proxyName,
			DisplayName: proxyDisplayName,
			Icon:        proxyIcon,
		})
		require.NoError(t, err)

		proxyClient := wsproxysdk.New(client.URL)
		proxyClient.SetSessionToken(createRes.ProxyToken)

		// Register
		req := wsproxysdk.RegisterWorkspaceProxyRequest{
			AccessURL:           "https://proxy.coder.test",
			WildcardHostname:    "*.proxy.coder.test",
			DerpEnabled:         true,
			ReplicaID:           uuid.New(),
			ReplicaHostname:     "mars",
			ReplicaError:        "",
			ReplicaRelayAddress: "http://127.0.0.1:8080",
			Version:             buildinfo.Version(),
		}
		registerRes1, err := proxyClient.RegisterWorkspaceProxy(ctx, req)
		require.NoError(t, err)
		require.NotEmpty(t, registerRes1.AppSecurityKey)
		require.NotEmpty(t, registerRes1.DERPMeshKey)
		require.EqualValues(t, 10001, registerRes1.DERPRegionID)
		require.Empty(t, registerRes1.SiblingReplicas)

		proxy, err := client.WorkspaceProxyByID(ctx, createRes.Proxy.ID)
		require.NoError(t, err)
		require.Equal(t, createRes.Proxy.ID, proxy.ID)
		require.Equal(t, proxyName, proxy.Name)
		require.Equal(t, proxyDisplayName, proxy.DisplayName)
		require.Equal(t, proxyIcon, proxy.IconURL)
		require.Equal(t, req.AccessURL, proxy.PathAppURL)
		require.Equal(t, req.AccessURL, proxy.PathAppURL)
		require.Equal(t, req.WildcardHostname, proxy.WildcardHostname)
		require.Equal(t, req.DerpEnabled, proxy.DerpEnabled)
		require.False(t, proxy.Deleted)

		// Get the replica from the DB.
		replica, err := db.GetReplicaByID(ctx, req.ReplicaID)
		require.NoError(t, err)
		require.Equal(t, req.ReplicaID, replica.ID)
		require.Equal(t, req.ReplicaHostname, replica.Hostname)
		require.Equal(t, req.ReplicaError, replica.Error)
		require.Equal(t, req.ReplicaRelayAddress, replica.RelayAddress)
		require.Equal(t, req.Version, replica.Version)
		require.EqualValues(t, 10001, replica.RegionID)
		require.False(t, replica.StoppedAt.Valid)
		require.Zero(t, replica.DatabaseLatency)
		require.False(t, replica.Primary)

		// Re-register with most fields changed.
		req = wsproxysdk.RegisterWorkspaceProxyRequest{
			AccessURL:           "https://cool.proxy.coder.test",
			WildcardHostname:    "*.cool.proxy.coder.test",
			DerpEnabled:         false,
			ReplicaID:           req.ReplicaID,
			ReplicaHostname:     "venus",
			ReplicaError:        "error",
			ReplicaRelayAddress: "http://127.0.0.1:9090",
			Version:             buildinfo.Version(),
		}
		registerRes2, err := proxyClient.RegisterWorkspaceProxy(ctx, req)
		require.NoError(t, err)
		require.Equal(t, registerRes1, registerRes2)

		// Get the proxy to ensure nothing has changed except updated_at.
		proxyNew, err := client.WorkspaceProxyByID(ctx, createRes.Proxy.ID)
		require.NoError(t, err)
		require.Equal(t, createRes.Proxy.ID, proxyNew.ID)
		require.Equal(t, proxyName, proxyNew.Name)
		require.Equal(t, proxyDisplayName, proxyNew.DisplayName)
		require.Equal(t, proxyIcon, proxyNew.IconURL)
		require.Equal(t, req.AccessURL, proxyNew.PathAppURL)
		require.Equal(t, req.AccessURL, proxyNew.PathAppURL)
		require.Equal(t, req.WildcardHostname, proxyNew.WildcardHostname)
		require.Equal(t, req.DerpEnabled, proxyNew.DerpEnabled)
		require.False(t, proxyNew.Deleted)

		// Get the replica from the DB and ensure the fields have been updated,
		// especially the updated_at.
		replica, err = db.GetReplicaByID(ctx, req.ReplicaID)
		require.NoError(t, err)
		require.Equal(t, req.ReplicaID, replica.ID)
		require.Equal(t, req.ReplicaHostname, replica.Hostname)
		require.Equal(t, req.ReplicaError, replica.Error)
		require.Equal(t, req.ReplicaRelayAddress, replica.RelayAddress)
		require.Equal(t, req.Version, replica.Version)
		require.EqualValues(t, 10001, replica.RegionID)
		require.False(t, replica.StoppedAt.Valid)
		require.Zero(t, replica.DatabaseLatency)
		require.False(t, replica.Primary)

		// Deregister
		err = proxyClient.DeregisterWorkspaceProxy(ctx, wsproxysdk.DeregisterWorkspaceProxyRequest{
			ReplicaID: req.ReplicaID,
		})
		require.NoError(t, err)

		// Ensure the replica has been fully stopped.
		replica, err = db.GetReplicaByID(ctx, req.ReplicaID)
		require.NoError(t, err)
		require.Equal(t, req.ReplicaID, replica.ID)
		require.True(t, replica.StoppedAt.Valid)

		// Re-register should fail
		_, err = proxyClient.RegisterWorkspaceProxy(ctx, wsproxysdk.RegisterWorkspaceProxyRequest{})
		require.Error(t, err)
	})

	t.Run("BlockMismatchingVersion", func(t *testing.T) {
		t.Parallel()

		client, _ := setup(t)

		ctx := testutil.Context(t, testutil.WaitLong)
		createRes, err := client.CreateWorkspaceProxy(ctx, codersdk.CreateWorkspaceProxyRequest{
			Name: "hi",
		})
		require.NoError(t, err)

		proxyClient := wsproxysdk.New(client.URL)
		proxyClient.SetSessionToken(createRes.ProxyToken)

		_, err = proxyClient.RegisterWorkspaceProxy(ctx, wsproxysdk.RegisterWorkspaceProxyRequest{
			AccessURL:           "https://proxy.coder.test",
			WildcardHostname:    "*.proxy.coder.test",
			DerpEnabled:         true,
			ReplicaID:           uuid.New(),
			ReplicaHostname:     "mars",
			ReplicaError:        "",
			ReplicaRelayAddress: "http://127.0.0.1:8080",
			Version:             "v0.0.0",
		})
		require.Error(t, err)
		var sdkErr *codersdk.Error
		require.ErrorAs(t, err, &sdkErr)
		require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
		require.Contains(t, sdkErr.Response.Message, "Version mismatch")
	})

	t.Run("ReregisterUpdateReplica", func(t *testing.T) {
		t.Parallel()

		client, db := setup(t)

		ctx := testutil.Context(t, testutil.WaitLong)
		createRes, err := client.CreateWorkspaceProxy(ctx, codersdk.CreateWorkspaceProxyRequest{
			Name: "hi",
		})
		require.NoError(t, err)

		proxyClient := wsproxysdk.New(client.URL)
		proxyClient.SetSessionToken(createRes.ProxyToken)

		req := wsproxysdk.RegisterWorkspaceProxyRequest{
			AccessURL:           "https://proxy.coder.test",
			WildcardHostname:    "*.proxy.coder.test",
			DerpEnabled:         true,
			ReplicaID:           uuid.New(),
			ReplicaHostname:     "mars",
			ReplicaError:        "",
			ReplicaRelayAddress: "http://127.0.0.1:8080",
			Version:             buildinfo.Version(),
		}
		_, err = proxyClient.RegisterWorkspaceProxy(ctx, req)
		require.NoError(t, err)

		// Get the replica from the DB.
		replica, err := db.GetReplicaByID(ctx, req.ReplicaID)
		require.NoError(t, err)
		require.Equal(t, req.ReplicaID, replica.ID)

		time.Sleep(time.Millisecond)

		// Re-register with no changed fields.
		_, err = proxyClient.RegisterWorkspaceProxy(ctx, req)
		require.NoError(t, err)

		// Get the replica from the DB and make sure updated_at has changed.
		replica, err = db.GetReplicaByID(ctx, req.ReplicaID)
		require.NoError(t, err)
		require.Equal(t, req.ReplicaID, replica.ID)
		require.Greater(t, replica.UpdatedAt.UnixNano(), replica.CreatedAt.UnixNano())
	})

	t.Run("DeregisterNonExistentReplica", func(t *testing.T) {
		t.Parallel()

		client, _ := setup(t)

		ctx := testutil.Context(t, testutil.WaitLong)
		createRes, err := client.CreateWorkspaceProxy(ctx, codersdk.CreateWorkspaceProxyRequest{
			Name: "hi",
		})
		require.NoError(t, err)

		proxyClient := wsproxysdk.New(client.URL)
		proxyClient.SetSessionToken(createRes.ProxyToken)

		err = proxyClient.DeregisterWorkspaceProxy(ctx, wsproxysdk.DeregisterWorkspaceProxyRequest{
			ReplicaID: uuid.New(),
		})
		require.Error(t, err)
		var sdkErr *codersdk.Error
		require.ErrorAs(t, err, &sdkErr)
		require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
	})

	t.Run("ReturnSiblings", func(t *testing.T) {
		t.Parallel()

		client, _ := setup(t)

		ctx := testutil.Context(t, testutil.WaitLong)
		createRes1, err := client.CreateWorkspaceProxy(ctx, codersdk.CreateWorkspaceProxyRequest{
			Name: "one",
		})
		require.NoError(t, err)
		createRes2, err := client.CreateWorkspaceProxy(ctx, codersdk.CreateWorkspaceProxyRequest{
			Name: "two",
		})
		require.NoError(t, err)

		// Register a replica on proxy 2. This shouldn't be returned by replicas
		// for proxy 1.
		proxyClient2 := wsproxysdk.New(client.URL)
		proxyClient2.SetSessionToken(createRes2.ProxyToken)
		_, err = proxyClient2.RegisterWorkspaceProxy(ctx, wsproxysdk.RegisterWorkspaceProxyRequest{
			AccessURL:           "https://other.proxy.coder.test",
			WildcardHostname:    "*.other.proxy.coder.test",
			DerpEnabled:         true,
			ReplicaID:           uuid.New(),
			ReplicaHostname:     "venus",
			ReplicaError:        "",
			ReplicaRelayAddress: "http://127.0.0.1:9090",
			Version:             buildinfo.Version(),
		})
		require.NoError(t, err)

		// Register replica 1.
		proxyClient1 := wsproxysdk.New(client.URL)
		proxyClient1.SetSessionToken(createRes1.ProxyToken)
		req1 := wsproxysdk.RegisterWorkspaceProxyRequest{
			AccessURL:           "https://one.proxy.coder.test",
			WildcardHostname:    "*.one.proxy.coder.test",
			DerpEnabled:         true,
			ReplicaID:           uuid.New(),
			ReplicaHostname:     "mars1",
			ReplicaError:        "",
			ReplicaRelayAddress: "http://127.0.0.1:8081",
			Version:             buildinfo.Version(),
		}
		registerRes1, err := proxyClient1.RegisterWorkspaceProxy(ctx, req1)
		require.NoError(t, err)
		require.Empty(t, registerRes1.SiblingReplicas)

		// Register replica 2 and expect to get replica 1 as a sibling.
		req2 := wsproxysdk.RegisterWorkspaceProxyRequest{
			AccessURL:           "https://two.proxy.coder.test",
			WildcardHostname:    "*.two.proxy.coder.test",
			DerpEnabled:         true,
			ReplicaID:           uuid.New(),
			ReplicaHostname:     "mars2",
			ReplicaError:        "",
			ReplicaRelayAddress: "http://127.0.0.1:8082",
			Version:             buildinfo.Version(),
		}
		registerRes2, err := proxyClient1.RegisterWorkspaceProxy(ctx, req2)
		require.NoError(t, err)
		require.Len(t, registerRes2.SiblingReplicas, 1)
		require.Equal(t, req1.ReplicaID, registerRes2.SiblingReplicas[0].ID)
		require.Equal(t, req1.ReplicaHostname, registerRes2.SiblingReplicas[0].Hostname)
		require.Equal(t, req1.ReplicaRelayAddress, registerRes2.SiblingReplicas[0].RelayAddress)
		require.EqualValues(t, 10001, registerRes2.SiblingReplicas[0].RegionID)

		// Re-register replica 1 and expect to get replica 2 as a sibling.
		registerRes1, err = proxyClient1.RegisterWorkspaceProxy(ctx, req1)
		require.NoError(t, err)
		require.Len(t, registerRes1.SiblingReplicas, 1)
		require.Equal(t, req2.ReplicaID, registerRes1.SiblingReplicas[0].ID)
		require.Equal(t, req2.ReplicaHostname, registerRes1.SiblingReplicas[0].Hostname)
		require.Equal(t, req2.ReplicaRelayAddress, registerRes1.SiblingReplicas[0].RelayAddress)
		require.EqualValues(t, 10001, registerRes1.SiblingReplicas[0].RegionID)
	})

	// ReturnSiblings2 tries to create 100 proxy replicas and ensures that they
	// all return the correct number of siblings.
	t.Run("ReturnSiblings2", func(t *testing.T) {
		t.Parallel()

		client, _ := setup(t)
		ctx := testutil.Context(t, testutil.WaitLong)

		createRes, err := client.CreateWorkspaceProxy(ctx, codersdk.CreateWorkspaceProxyRequest{
			Name: "proxy",
		})
		require.NoError(t, err)

		proxyClient := wsproxysdk.New(client.URL)
		proxyClient.SetSessionToken(createRes.ProxyToken)

		for i := 0; i < 100; i++ {
			ok := false
			for j := 0; j < 2; j++ {
				registerRes, err := proxyClient.RegisterWorkspaceProxy(ctx, wsproxysdk.RegisterWorkspaceProxyRequest{
					AccessURL:           "https://proxy.coder.test",
					WildcardHostname:    "*.proxy.coder.test",
					DerpEnabled:         true,
					ReplicaID:           uuid.New(),
					ReplicaHostname:     "venus",
					ReplicaError:        "",
					ReplicaRelayAddress: fmt.Sprintf("http://127.0.0.1:%d", 8080+i),
					Version:             buildinfo.Version(),
				})
				require.NoErrorf(t, err, "register proxy %d", i)

				// If the sibling replica count is wrong, try again. The impact
				// of this not being immediate is that proxies may not function
				// as DERP relays until they register again in 30 seconds.
				//
				// In the real world, replicas will not be registering this
				// quickly. Kubernetes rolls out gradually in practice.
				if len(registerRes.SiblingReplicas) != i {
					t.Logf("%d: expected %d siblings, got %d", i, i, len(registerRes.SiblingReplicas))
					time.Sleep(100 * time.Millisecond)
					continue
				}

				ok = true
				break
			}

			require.True(t, ok, "expected to register replica %d", i)
		}
	})
}

func TestIssueSignedAppToken(t *testing.T) {
	t.Parallel()

	dv := coderdtest.DeploymentValues(t)
	dv.Experiments = []string{
		string(codersdk.ExperimentMoons),
		"*",
	}

	db, pubsub := dbtestutil.NewDB(t)
	client, user := coderdenttest.New(t, &coderdenttest.Options{
		Options: &coderdtest.Options{
			DeploymentValues:         dv,
			Database:                 db,
			Pubsub:                   pubsub,
			IncludeProvisionerDaemon: true,
		},
		LicenseOptions: &coderdenttest.LicenseOptions{
			Features: license.Features{
				codersdk.FeatureWorkspaceProxy: 1,
			},
		},
	})

	// Create a workspace + apps
	authToken := uuid.NewString()
	version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
		Parse:          echo.ParseComplete,
		ProvisionApply: echo.ProvisionApplyWithAgent(authToken),
	})
	template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
	coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
	workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
	build := coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
	workspace.LatestBuild = build

	// Connect an agent to the workspace
	agentClient := agentsdk.New(client.URL)
	agentClient.SetSessionToken(authToken)
	agentCloser := agent.New(agent.Options{
		Client: agentClient,
		Logger: slogtest.Make(t, nil).Named("agent").Leveled(slog.LevelDebug),
	})
	t.Cleanup(func() {
		_ = agentCloser.Close()
	})

	coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)

	createProxyCtx := testutil.Context(t, testutil.WaitLong)
	proxyRes, err := client.CreateWorkspaceProxy(createProxyCtx, codersdk.CreateWorkspaceProxyRequest{
		Name: namesgenerator.GetRandomName(1),
		Icon: "/emojis/flag.png",
	})
	require.NoError(t, err)

	t.Run("BadAppRequest", func(t *testing.T) {
		t.Parallel()
		proxyClient := wsproxysdk.New(client.URL)
		proxyClient.SetSessionToken(proxyRes.ProxyToken)

		ctx := testutil.Context(t, testutil.WaitLong)
		_, err := proxyClient.IssueSignedAppToken(ctx, workspaceapps.IssueTokenRequest{
			// Invalid request.
			AppRequest:   workspaceapps.Request{},
			SessionToken: client.SessionToken(),
		})
		require.Error(t, err)
	})

	goodRequest := workspaceapps.IssueTokenRequest{
		AppRequest: workspaceapps.Request{
			BasePath:      "/app",
			AccessMethod:  workspaceapps.AccessMethodTerminal,
			AgentNameOrID: build.Resources[0].Agents[0].ID.String(),
		},
		SessionToken: client.SessionToken(),
	}
	t.Run("OK", func(t *testing.T) {
		t.Parallel()
		proxyClient := wsproxysdk.New(client.URL)
		proxyClient.SetSessionToken(proxyRes.ProxyToken)

		ctx := testutil.Context(t, testutil.WaitLong)
		_, err := proxyClient.IssueSignedAppToken(ctx, goodRequest)
		require.NoError(t, err)
	})

	t.Run("OKHTML", func(t *testing.T) {
		t.Parallel()
		proxyClient := wsproxysdk.New(client.URL)
		proxyClient.SetSessionToken(proxyRes.ProxyToken)

		rw := httptest.NewRecorder()
		ctx := testutil.Context(t, testutil.WaitLong)
		_, ok := proxyClient.IssueSignedAppTokenHTML(ctx, rw, goodRequest)
		if !assert.True(t, ok, "expected true") {
			resp := rw.Result()
			defer resp.Body.Close()
			dump, err := httputil.DumpResponse(resp, true)
			require.NoError(t, err)
			t.Log(string(dump))
		}
	})
}

func TestReconnectingPTYSignedToken(t *testing.T) {
	t.Parallel()

	dv := coderdtest.DeploymentValues(t)
	dv.Experiments = []string{
		string(codersdk.ExperimentMoons),
		"*",
	}

	db, pubsub := dbtestutil.NewDB(t)
	client, closer, api, user := coderdenttest.NewWithAPI(t, &coderdenttest.Options{
		Options: &coderdtest.Options{
			DeploymentValues:         dv,
			Database:                 db,
			Pubsub:                   pubsub,
			IncludeProvisionerDaemon: true,
		},
		LicenseOptions: &coderdenttest.LicenseOptions{
			Features: license.Features{
				codersdk.FeatureWorkspaceProxy: 1,
			},
		},
	})
	t.Cleanup(func() {
		closer.Close()
	})

	// Create a workspace + apps
	authToken := uuid.NewString()
	version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
		Parse:          echo.ParseComplete,
		ProvisionApply: echo.ProvisionApplyWithAgent(authToken),
	})
	template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
	coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
	workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
	build := coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
	workspace.LatestBuild = build

	// Connect an agent to the workspace
	agentID := build.Resources[0].Agents[0].ID
	agentClient := agentsdk.New(client.URL)
	agentClient.SetSessionToken(authToken)
	agentCloser := agent.New(agent.Options{
		Client: agentClient,
		Logger: slogtest.Make(t, nil).Named("agent").Leveled(slog.LevelDebug),
	})
	t.Cleanup(func() {
		_ = agentCloser.Close()
	})
	coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)

	proxyURL, err := url.Parse(fmt.Sprintf("https://%s.com", namesgenerator.GetRandomName(1)))
	require.NoError(t, err)

	_ = coderdenttest.NewWorkspaceProxy(t, api, client, &coderdenttest.ProxyOptions{
		Name:        namesgenerator.GetRandomName(1),
		ProxyURL:    proxyURL,
		AppHostname: "*.sub.example.com",
	})

	u, err := url.Parse(proxyURL.String())
	require.NoError(t, err)
	if u.Scheme == "https" {
		u.Scheme = "wss"
	} else {
		u.Scheme = "ws"
	}
	u.Path = fmt.Sprintf("/api/v2/workspaceagents/%s/pty", agentID.String())

	t.Run("Validate", func(t *testing.T) {
		t.Parallel()

		ctx := testutil.Context(t, testutil.WaitLong)
		res, err := client.IssueReconnectingPTYSignedToken(ctx, codersdk.IssueReconnectingPTYSignedTokenRequest{
			URL:     "",
			AgentID: uuid.Nil,
		})
		require.Error(t, err)
		require.Empty(t, res)
		var sdkErr *codersdk.Error
		require.ErrorAs(t, err, &sdkErr)
		require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
	})

	t.Run("BadURL", func(t *testing.T) {
		t.Parallel()

		ctx := testutil.Context(t, testutil.WaitLong)
		res, err := client.IssueReconnectingPTYSignedToken(ctx, codersdk.IssueReconnectingPTYSignedTokenRequest{
			URL:     ":",
			AgentID: agentID,
		})
		require.Error(t, err)
		require.Empty(t, res)
		var sdkErr *codersdk.Error
		require.ErrorAs(t, err, &sdkErr)
		require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
		require.Contains(t, sdkErr.Response.Message, "Invalid URL")
	})

	t.Run("BadURL", func(t *testing.T) {
		t.Parallel()

		u := *u
		u.Scheme = "ftp"

		ctx := testutil.Context(t, testutil.WaitLong)
		res, err := client.IssueReconnectingPTYSignedToken(ctx, codersdk.IssueReconnectingPTYSignedTokenRequest{
			URL:     u.String(),
			AgentID: agentID,
		})
		require.Error(t, err)
		require.Empty(t, res)
		var sdkErr *codersdk.Error
		require.ErrorAs(t, err, &sdkErr)
		require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
		require.Contains(t, sdkErr.Response.Message, "Invalid URL")
		require.Contains(t, sdkErr.Response.Detail, "scheme")
	})

	t.Run("BadURLPath", func(t *testing.T) {
		t.Parallel()

		u := *u
		u.Path = "/hello"

		ctx := testutil.Context(t, testutil.WaitLong)
		res, err := client.IssueReconnectingPTYSignedToken(ctx, codersdk.IssueReconnectingPTYSignedTokenRequest{
			URL:     u.String(),
			AgentID: agentID,
		})
		require.Error(t, err)
		require.Empty(t, res)
		var sdkErr *codersdk.Error
		require.ErrorAs(t, err, &sdkErr)
		require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
		require.Contains(t, sdkErr.Response.Message, "Invalid URL")
		require.Contains(t, sdkErr.Response.Detail, "The provided URL is not a valid reconnecting PTY endpoint URL")
	})

	t.Run("BadHostname", func(t *testing.T) {
		t.Parallel()

		u := *u
		u.Host = "badhostname.com"

		ctx := testutil.Context(t, testutil.WaitLong)
		res, err := client.IssueReconnectingPTYSignedToken(ctx, codersdk.IssueReconnectingPTYSignedTokenRequest{
			URL:     u.String(),
			AgentID: agentID,
		})
		require.Error(t, err)
		require.Empty(t, res)
		var sdkErr *codersdk.Error
		require.ErrorAs(t, err, &sdkErr)
		require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
		require.Contains(t, sdkErr.Response.Message, "Invalid hostname in URL")
	})

	t.Run("NoToken", func(t *testing.T) {
		t.Parallel()

		unauthedClient := codersdk.New(client.URL)

		ctx := testutil.Context(t, testutil.WaitLong)
		res, err := unauthedClient.IssueReconnectingPTYSignedToken(ctx, codersdk.IssueReconnectingPTYSignedTokenRequest{
			URL:     u.String(),
			AgentID: agentID,
		})
		require.Error(t, err)
		require.Empty(t, res)
		var sdkErr *codersdk.Error
		require.ErrorAs(t, err, &sdkErr)
		require.Equal(t, http.StatusUnauthorized, sdkErr.StatusCode())
	})

	t.Run("NoPermissions", func(t *testing.T) {
		t.Parallel()

		userClient, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)

		ctx := testutil.Context(t, testutil.WaitLong)
		res, err := userClient.IssueReconnectingPTYSignedToken(ctx, codersdk.IssueReconnectingPTYSignedTokenRequest{
			URL:     u.String(),
			AgentID: agentID,
		})
		require.Error(t, err)
		require.Empty(t, res)
		var sdkErr *codersdk.Error
		require.ErrorAs(t, err, &sdkErr)
		require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
	})

	t.Run("OK", func(t *testing.T) {
		t.Parallel()

		ctx := testutil.Context(t, testutil.WaitLong)
		res, err := client.IssueReconnectingPTYSignedToken(ctx, codersdk.IssueReconnectingPTYSignedTokenRequest{
			URL:     u.String(),
			AgentID: agentID,
		})
		require.NoError(t, err)
		require.NotEmpty(t, res.SignedToken)

		// The token is validated in the apptest suite, so we don't need to
		// validate it here.
	})
}
