package agentapi_test

import (
	"context"
	"database/sql"
	"encoding/json"
	"fmt"
	"net/url"
	"testing"
	"time"

	"github.com/google/uuid"
	"github.com/sqlc-dev/pqtype"
	"github.com/stretchr/testify/require"
	"go.uber.org/mock/gomock"
	"google.golang.org/protobuf/types/known/durationpb"
	"tailscale.com/tailcfg"

	agentproto "github.com/coder/coder/v2/agent/proto"
	"github.com/coder/coder/v2/coderd/agentapi"
	"github.com/coder/coder/v2/coderd/database"
	"github.com/coder/coder/v2/coderd/database/dbmock"
	"github.com/coder/coder/v2/coderd/database/dbtime"
	"github.com/coder/coder/v2/coderd/externalauth"
	"github.com/coder/coder/v2/codersdk"
	"github.com/coder/coder/v2/tailnet"
)

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

	someTime, err := time.Parse(time.RFC3339, "2023-01-01T00:00:00Z")
	require.NoError(t, err)
	someTime = dbtime.Time(someTime)

	expectedEnvVars := map[string]string{
		"FOO":      "bar",
		"COOL_ENV": "dean was here",
	}
	expectedEnvVarsJSON, err := json.Marshal(expectedEnvVars)
	require.NoError(t, err)

	var (
		owner = database.User{
			ID:       uuid.New(),
			Username: "cool-user",
		}
		workspace = database.Workspace{
			ID:      uuid.New(),
			OwnerID: owner.ID,
			Name:    "cool-workspace",
		}
		agent = database.WorkspaceAgent{
			ID:   uuid.New(),
			Name: "cool-agent",
			EnvironmentVariables: pqtype.NullRawMessage{
				RawMessage: expectedEnvVarsJSON,
				Valid:      true,
			},
			Directory: "/cool/dir",
			MOTDFile:  "/cool/motd",
		}
		apps = []database.WorkspaceApp{
			{
				ID:                   uuid.New(),
				Url:                  sql.NullString{String: "http://localhost:1234", Valid: true},
				External:             false,
				Slug:                 "cool-app-1",
				DisplayName:          "app 1",
				Command:              sql.NullString{String: "cool command", Valid: true},
				Icon:                 "/icon.png",
				Subdomain:            true,
				SharingLevel:         database.AppSharingLevelAuthenticated,
				Health:               database.WorkspaceAppHealthHealthy,
				HealthcheckUrl:       "http://localhost:1234/health",
				HealthcheckInterval:  10,
				HealthcheckThreshold: 3,
			},
			{
				ID:           uuid.New(),
				Url:          sql.NullString{String: "http://google.com", Valid: true},
				External:     true,
				Slug:         "google",
				DisplayName:  "Literally Google",
				Command:      sql.NullString{Valid: false},
				Icon:         "/google.png",
				Subdomain:    false,
				SharingLevel: database.AppSharingLevelPublic,
				Health:       database.WorkspaceAppHealthDisabled,
			},
			{
				ID:                   uuid.New(),
				Url:                  sql.NullString{String: "http://localhost:4321", Valid: true},
				External:             true,
				Slug:                 "cool-app-2",
				DisplayName:          "another COOL app",
				Command:              sql.NullString{Valid: false},
				Icon:                 "",
				Subdomain:            false,
				SharingLevel:         database.AppSharingLevelOwner,
				Health:               database.WorkspaceAppHealthUnhealthy,
				HealthcheckUrl:       "http://localhost:4321/health",
				HealthcheckInterval:  20,
				HealthcheckThreshold: 5,
			},
		}
		scripts = []database.WorkspaceAgentScript{
			{
				WorkspaceAgentID: agent.ID,
				LogSourceID:      uuid.New(),
				LogPath:          "/cool/log/path/1",
				Script:           "cool script 1",
				Cron:             "30 2 * * *",
				StartBlocksLogin: true,
				RunOnStart:       true,
				RunOnStop:        false,
				TimeoutSeconds:   60,
			},
			{
				WorkspaceAgentID: agent.ID,
				LogSourceID:      uuid.New(),
				LogPath:          "/cool/log/path/2",
				Script:           "cool script 2",
				Cron:             "",
				StartBlocksLogin: false,
				RunOnStart:       false,
				RunOnStop:        true,
				TimeoutSeconds:   30,
			},
		}
		metadata = []database.WorkspaceAgentMetadatum{
			{
				WorkspaceAgentID: agent.ID,
				DisplayName:      "cool metadata 1",
				Key:              "cool-key-1",
				Script:           "cool script 1",
				Value:            "cool value 1",
				Error:            "",
				Timeout:          int64(time.Minute),
				Interval:         int64(time.Minute),
				CollectedAt:      someTime,
			},
			{
				WorkspaceAgentID: agent.ID,
				DisplayName:      "cool metadata 2",
				Key:              "cool-key-2",
				Script:           "cool script 2",
				Value:            "cool value 2",
				Error:            "some uncool error",
				Timeout:          int64(5 * time.Second),
				Interval:         int64(20 * time.Minute),
				CollectedAt:      someTime.Add(time.Hour),
			},
		}
		derpMapFn = func() *tailcfg.DERPMap {
			return &tailcfg.DERPMap{
				Regions: map[int]*tailcfg.DERPRegion{
					1: {RegionName: "cool region"},
				},
			}
		}
	)

	// These are done manually to ensure the conversion logic matches what a
	// human expects.
	var (
		protoApps = []*agentproto.WorkspaceApp{
			{
				Id:            apps[0].ID[:],
				Url:           apps[0].Url.String,
				External:      apps[0].External,
				Slug:          apps[0].Slug,
				DisplayName:   apps[0].DisplayName,
				Command:       apps[0].Command.String,
				Icon:          apps[0].Icon,
				Subdomain:     apps[0].Subdomain,
				SubdomainName: fmt.Sprintf("%s--%s--%s--%s", apps[0].Slug, agent.Name, workspace.Name, owner.Username),
				SharingLevel:  agentproto.WorkspaceApp_AUTHENTICATED,
				Healthcheck: &agentproto.WorkspaceApp_Healthcheck{
					Url:       apps[0].HealthcheckUrl,
					Interval:  durationpb.New(time.Duration(apps[0].HealthcheckInterval) * time.Second),
					Threshold: apps[0].HealthcheckThreshold,
				},
				Health: agentproto.WorkspaceApp_HEALTHY,
			},
			{
				Id:            apps[1].ID[:],
				Url:           apps[1].Url.String,
				External:      apps[1].External,
				Slug:          apps[1].Slug,
				DisplayName:   apps[1].DisplayName,
				Command:       apps[1].Command.String,
				Icon:          apps[1].Icon,
				Subdomain:     false,
				SubdomainName: "",
				SharingLevel:  agentproto.WorkspaceApp_PUBLIC,
				Healthcheck: &agentproto.WorkspaceApp_Healthcheck{
					Url:       "",
					Interval:  durationpb.New(0),
					Threshold: 0,
				},
				Health: agentproto.WorkspaceApp_DISABLED,
			},
			{
				Id:            apps[2].ID[:],
				Url:           apps[2].Url.String,
				External:      apps[2].External,
				Slug:          apps[2].Slug,
				DisplayName:   apps[2].DisplayName,
				Command:       apps[2].Command.String,
				Icon:          apps[2].Icon,
				Subdomain:     false,
				SubdomainName: "",
				SharingLevel:  agentproto.WorkspaceApp_OWNER,
				Healthcheck: &agentproto.WorkspaceApp_Healthcheck{
					Url:       apps[2].HealthcheckUrl,
					Interval:  durationpb.New(time.Duration(apps[2].HealthcheckInterval) * time.Second),
					Threshold: apps[2].HealthcheckThreshold,
				},
				Health: agentproto.WorkspaceApp_UNHEALTHY,
			},
		}
		protoScripts = []*agentproto.WorkspaceAgentScript{
			{
				LogSourceId:      scripts[0].LogSourceID[:],
				LogPath:          scripts[0].LogPath,
				Script:           scripts[0].Script,
				Cron:             scripts[0].Cron,
				RunOnStart:       scripts[0].RunOnStart,
				RunOnStop:        scripts[0].RunOnStop,
				StartBlocksLogin: scripts[0].StartBlocksLogin,
				Timeout:          durationpb.New(time.Duration(scripts[0].TimeoutSeconds) * time.Second),
			},
			{
				LogSourceId:      scripts[1].LogSourceID[:],
				LogPath:          scripts[1].LogPath,
				Script:           scripts[1].Script,
				Cron:             scripts[1].Cron,
				RunOnStart:       scripts[1].RunOnStart,
				RunOnStop:        scripts[1].RunOnStop,
				StartBlocksLogin: scripts[1].StartBlocksLogin,
				Timeout:          durationpb.New(time.Duration(scripts[1].TimeoutSeconds) * time.Second),
			},
		}
		protoMetadata = []*agentproto.WorkspaceAgentMetadata_Description{
			{
				DisplayName: metadata[0].DisplayName,
				Key:         metadata[0].Key,
				Script:      metadata[0].Script,
				Interval:    durationpb.New(time.Duration(metadata[0].Interval)),
				Timeout:     durationpb.New(time.Duration(metadata[0].Timeout)),
			},
			{
				DisplayName: metadata[1].DisplayName,
				Key:         metadata[1].Key,
				Script:      metadata[1].Script,
				Interval:    durationpb.New(time.Duration(metadata[1].Interval)),
				Timeout:     durationpb.New(time.Duration(metadata[1].Timeout)),
			},
		}
	)

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

		mDB := dbmock.NewMockStore(gomock.NewController(t))

		api := &agentapi.ManifestAPI{
			AccessURL:   &url.URL{Scheme: "https", Host: "example.com"},
			AppHostname: "*--apps.example.com",
			ExternalAuthConfigs: []*externalauth.Config{
				{Type: string(codersdk.EnhancedExternalAuthProviderGitHub)},
				{Type: "some-provider"},
				{Type: string(codersdk.EnhancedExternalAuthProviderGitLab)},
			},
			DisableDirectConnections: true,
			DerpForceWebSockets:      true,

			AgentFn: func(ctx context.Context) (database.WorkspaceAgent, error) {
				return agent, nil
			},
			WorkspaceIDFn: func(ctx context.Context, _ *database.WorkspaceAgent) (uuid.UUID, error) {
				return workspace.ID, nil
			},
			Database:  mDB,
			DerpMapFn: derpMapFn,
		}

		mDB.EXPECT().GetWorkspaceAppsByAgentID(gomock.Any(), agent.ID).Return(apps, nil)
		mDB.EXPECT().GetWorkspaceAgentScriptsByAgentIDs(gomock.Any(), []uuid.UUID{agent.ID}).Return(scripts, nil)
		mDB.EXPECT().GetWorkspaceAgentMetadata(gomock.Any(), database.GetWorkspaceAgentMetadataParams{
			WorkspaceAgentID: agent.ID,
			Keys:             nil, // all
		}).Return(metadata, nil)
		mDB.EXPECT().GetWorkspaceByID(gomock.Any(), workspace.ID).Return(workspace, nil)
		mDB.EXPECT().GetUserByID(gomock.Any(), workspace.OwnerID).Return(owner, nil)

		got, err := api.GetManifest(context.Background(), &agentproto.GetManifestRequest{})
		require.NoError(t, err)

		expected := &agentproto.Manifest{
			AgentId:                  agent.ID[:],
			AgentName:                agent.Name,
			OwnerUsername:            owner.Username,
			WorkspaceId:              workspace.ID[:],
			WorkspaceName:            workspace.Name,
			GitAuthConfigs:           2, // two "enhanced" external auth configs
			EnvironmentVariables:     expectedEnvVars,
			Directory:                agent.Directory,
			VsCodePortProxyUri:       fmt.Sprintf("https://{{port}}--%s--%s--%s--apps.example.com", agent.Name, workspace.Name, owner.Username),
			MotdPath:                 agent.MOTDFile,
			DisableDirectConnections: true,
			DerpForceWebsockets:      true,
			// tailnet.DERPMapToProto() is extensively tested elsewhere, so it's
			// not necessary to manually recreate a big DERP map here like we
			// did for apps and metadata.
			DerpMap:  tailnet.DERPMapToProto(derpMapFn()),
			Scripts:  protoScripts,
			Apps:     protoApps,
			Metadata: protoMetadata,
		}

		// Log got and expected with spew.
		// t.Log("got:\n" + spew.Sdump(got))
		// t.Log("expected:\n" + spew.Sdump(expected))

		require.Equal(t, expected, got)
	})

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

		mDB := dbmock.NewMockStore(gomock.NewController(t))

		api := &agentapi.ManifestAPI{
			AccessURL:   &url.URL{Scheme: "https", Host: "example.com"},
			AppHostname: "",
			ExternalAuthConfigs: []*externalauth.Config{
				{Type: string(codersdk.EnhancedExternalAuthProviderGitHub)},
				{Type: "some-provider"},
				{Type: string(codersdk.EnhancedExternalAuthProviderGitLab)},
			},
			DisableDirectConnections: true,
			DerpForceWebSockets:      true,

			AgentFn: func(ctx context.Context) (database.WorkspaceAgent, error) {
				return agent, nil
			},
			WorkspaceIDFn: func(ctx context.Context, _ *database.WorkspaceAgent) (uuid.UUID, error) {
				return workspace.ID, nil
			},
			Database:  mDB,
			DerpMapFn: derpMapFn,
		}

		mDB.EXPECT().GetWorkspaceAppsByAgentID(gomock.Any(), agent.ID).Return(apps, nil)
		mDB.EXPECT().GetWorkspaceAgentScriptsByAgentIDs(gomock.Any(), []uuid.UUID{agent.ID}).Return(scripts, nil)
		mDB.EXPECT().GetWorkspaceAgentMetadata(gomock.Any(), database.GetWorkspaceAgentMetadataParams{
			WorkspaceAgentID: agent.ID,
			Keys:             nil, // all
		}).Return(metadata, nil)
		mDB.EXPECT().GetWorkspaceByID(gomock.Any(), workspace.ID).Return(workspace, nil)
		mDB.EXPECT().GetUserByID(gomock.Any(), workspace.OwnerID).Return(owner, nil)

		got, err := api.GetManifest(context.Background(), &agentproto.GetManifestRequest{})
		require.NoError(t, err)

		expected := &agentproto.Manifest{
			AgentId:                  agent.ID[:],
			AgentName:                agent.Name,
			OwnerUsername:            owner.Username,
			WorkspaceId:              workspace.ID[:],
			WorkspaceName:            workspace.Name,
			GitAuthConfigs:           2, // two "enhanced" external auth configs
			EnvironmentVariables:     expectedEnvVars,
			Directory:                agent.Directory,
			VsCodePortProxyUri:       "", // empty with no AppHost
			MotdPath:                 agent.MOTDFile,
			DisableDirectConnections: true,
			DerpForceWebsockets:      true,
			// tailnet.DERPMapToProto() is extensively tested elsewhere, so it's
			// not necessary to manually recreate a big DERP map here like we
			// did for apps and metadata.
			DerpMap:  tailnet.DERPMapToProto(derpMapFn()),
			Scripts:  protoScripts,
			Apps:     protoApps,
			Metadata: protoMetadata,
		}

		// Log got and expected with spew.
		// t.Log("got:\n" + spew.Sdump(got))
		// t.Log("expected:\n" + spew.Sdump(expected))

		require.Equal(t, expected, got)
	})
}
