package app

import (
	"context"
	"net/http"
	"testing"
	"time"

	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/internal/api"
	"github.com/tidbcloud/AgentTeam/internal/api/testkit/httputil"
	"github.com/tidbcloud/AgentTeam/internal/extension/runner"
	storeapp "github.com/tidbcloud/AgentTeam/internal/store/app"
)

type fakeProviderIndex struct {
	resolved     ResolvedExtensionProvider
	resolveErr   error
	capabilities map[string]ProviderConnectionCapability
}

func (fake *fakeProviderIndex) ResolveProviderForInstallation(
	_ context.Context,
	_ string,
	_ string,
) (ResolvedExtensionProvider, error) {
	return fake.resolved, fake.resolveErr
}

func (fake *fakeProviderIndex) ProviderConnectionCapabilityForAppID(
	appID string,
) (ProviderConnectionCapability, bool) {
	capability, ok := fake.capabilities[appID]
	return capability, ok
}

func testProviderIndex() *fakeProviderIndex {
	return &fakeProviderIndex{resolved: ResolvedExtensionProvider{
		Target: storeapp.ExtensionProviderTarget{
			AppID:   "app_todoist",
			AppSlug: "todoist",
			Label:   "Todoist",
		},
		Auth: runner.AuthSpec{
			Kind: runner.AuthBearer,
			Fields: []runner.CredentialField{
				runner.APIKeyField("Todoist API token", "Paste a Todoist API token."),
			},
		},
		PrepareConnection: func(
			_ context.Context,
			credentials runner.Credentials,
		) (runner.ConnectionConfig, error) {
			if credentials["apiKey"] == "rejected" {
				return nil, runner.Errorf(
					runner.ErrorProviderAuthUnavailable, "todoist rejected the token", nil)
			}
			return nil, nil
		},
	}}
}

func testTopologyConfigContract() runner.ConnectionConfigContract {
	return runner.ConnectionConfigContract{
		Fields: []runner.ConnectionConfigField{
			{
				Name:     "topology",
				Required: true,
				Values: []string{
					"aggregate",
					"fallback",
					"server-only-aggregate",
				},
			},
		},
	}
}

func testCompositeProviderIndex() *fakeProviderIndex {
	index := testProviderIndex()
	index.resolved.Auth = runner.AuthSpec{
		Kind:               runner.AuthBasic,
		BasicUsernameField: "username",
		BasicPasswordField: "apiToken",
		Fields: []runner.CredentialField{
			{
				Name:        "baseUrl",
				DisplayName: "Jenkins URL",
				Description: "HTTPS URL of the Jenkins instance.",
				Required:    true,
			},
			{
				Name:        "username",
				DisplayName: "Jenkins username",
				Description: "Jenkins account name.",
				Required:    true,
			},
			{
				Name:        "apiToken",
				DisplayName: "Jenkins API token",
				Description: "Write-only Jenkins API token.",
				Required:    true,
				Secret:      true,
			},
		},
	}
	index.resolved.PrepareConnection = func(
		_ context.Context,
		credentials runner.Credentials,
	) (runner.ConnectionConfig, error) {
		if credentials["apiToken"] == "rejected" {
			return nil, runner.Errorf(
				runner.ErrorProviderAuthUnavailable,
				"jenkins rejected the token",
				nil,
			)
		}
		return nil, nil
	}
	return index
}

const providerConnectionPath = "/api/v1/workspaces/wsp_1/app-installations/appinst_todoist/extension-connection"

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

	fake := newFakeStore()
	fake.providerConnection = storeapp.ExtensionProviderConnectionView{
		Active:        true,
		Slug:          "todoist",
		CredentialSet: true,
	}
	fake.runtimeProviderCredentials = map[string]string{
		"baseUrl":  "https://jenkins.example/jenkins",
		"username": "builder",
		"apiToken": "server-only-secret",
	}
	fake.runtimeProviderConfig = runner.ConnectionConfig{
		"topology": "server-only-aggregate",
	}
	index := testCompositeProviderIndex()
	index.resolved.ConfigContract = testTopologyConfigContract()
	index.resolved.PrepareConnection = func(
		context.Context,
		runner.Credentials,
	) (runner.ConnectionConfig, error) {
		return runner.ConnectionConfig{
			"topology": "server-only-aggregate",
		}, nil
	}
	router := newAppRouter(Dependencies{
		AppStore:           fake,
		ExtensionProviders: index,
	})

	resp := httputil.DoJSON(t, router, http.MethodGet, providerConnectionPath, nil)
	require.Equal(t, http.StatusOK, resp.Code)
	require.Equal(t, "no-store", resp.Header().Get("Cache-Control"))
	require.JSONEq(t, `{
		"slug":"todoist",
		"active":true,
		"credentialSet":true,
		"values":{"baseUrl":"https://jenkins.example/jenkins","username":"builder"},
		"fields":[
			{"name":"baseUrl","displayName":"Jenkins URL","description":"HTTPS URL of the Jenkins instance.","required":true,"secret":false,"configured":true},
			{"name":"username","displayName":"Jenkins username","description":"Jenkins account name.","required":true,"secret":false,"configured":true},
			{"name":"apiToken","displayName":"Jenkins API token","description":"Write-only Jenkins API token.","required":true,"secret":true,"configured":true}
		]
	}`, resp.Body.String())
	require.Equal(t, "resolve", fake.lastProviderOperation)
	require.NotContains(t, resp.Body.String(), "server-only-secret")
	require.NotContains(t, resp.Body.String(), "server-only-aggregate")
	require.NotContains(t, resp.Body.String(), "topology")

	expectedUpdatedAt := time.Now().UTC()
	resp = httputil.DoJSON(t, router, http.MethodPut, providerConnectionPath, map[string]any{
		"credentials": map[string]any{
			"baseUrl":  "https://jenkins.example/jenkins",
			"username": "builder",
		},
		"expectedUpdatedAt": expectedUpdatedAt.Format(time.RFC3339Nano),
	})
	require.Equal(t, http.StatusOK, resp.Code)
	require.Equal(t, "put", fake.lastProviderOperation)
	require.NotNil(t, fake.lastProviderExpectedUpdatedAt)
	require.True(t, fake.lastProviderExpectedUpdatedAt.Equal(expectedUpdatedAt))
	require.Equal(t, map[string]string{
		"baseUrl":  "https://jenkins.example/jenkins",
		"username": "builder",
		"apiToken": "server-only-secret",
	}, fake.lastProviderCredentials)
	require.NotContains(t, resp.Body.String(), "server-only-secret")

	resp = httputil.DoJSON(t, router, http.MethodPost, providerConnectionPath+":test", nil)
	require.Equal(t, http.StatusOK, resp.Code)
	require.JSONEq(t, `{"ok":true}`, resp.Body.String())
	require.Equal(t, "resolve", fake.lastProviderOperation)
	require.NotContains(t, resp.Body.String(), "server-only-secret")

	resp = httputil.DoJSON(t, router, http.MethodDelete, providerConnectionPath, nil)
	require.Equal(t, http.StatusOK, resp.Code)
	require.Equal(t, "delete", fake.lastProviderOperation)
}

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

	fake := newFakeStore()
	router := newAppRouter(Dependencies{
		AppStore:           fake,
		ExtensionProviders: testCompositeProviderIndex(),
	})

	resp := httputil.DoJSON(t, router, http.MethodPut, providerConnectionPath, map[string]any{
		"credentials": map[string]any{
			"baseUrl":  "https://jenkins.example/jenkins",
			"username": "builder",
		},
	})
	require.Equal(t, http.StatusBadRequest, resp.Code)
	require.NotEqual(t, "put", fake.lastProviderOperation)
}

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

	fake := newFakeStore()
	fake.runtimeProviderCredentials = map[string]string{
		"baseUrl":  "https://old-jenkins.example",
		"username": "builder",
		"apiToken": "old-target-secret",
	}
	router := newAppRouter(Dependencies{
		AppStore:           fake,
		ExtensionProviders: testCompositeProviderIndex(),
	})

	resp := httputil.DoJSON(t, router, http.MethodPut, providerConnectionPath, map[string]any{
		"credentials": map[string]any{
			"baseUrl":  "https://new-jenkins.example",
			"username": "builder",
		},
	})
	require.Equal(t, http.StatusBadRequest, resp.Code)
	require.NotEqual(t, "put", fake.lastProviderOperation)
	require.NotContains(t, resp.Body.String(), "old-target-secret")
}

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

	fake := newFakeStore()
	router := newAppRouter(Dependencies{
		AppStore:           fake,
		ExtensionProviders: testProviderIndex(),
	})

	resp := httputil.DoJSON(t, router, http.MethodPut, providerConnectionPath, map[string]any{
		"credentials": map[string]any{"apiKey": "rejected"},
	})
	require.Equal(t, http.StatusBadRequest, resp.Code)
	require.Empty(t, fake.lastProviderOperation)
}

func TestExtensionProviderConnectionPutPersistsOnlyValidPreparedConfig(
	t *testing.T,
) {
	t.Parallel()

	t.Run("valid", func(t *testing.T) {
		index := testProviderIndex()
		index.resolved.ConfigContract = testTopologyConfigContract()
		index.resolved.PrepareConnection = func(
			_ context.Context,
			credentials runner.Credentials,
		) (runner.ConnectionConfig, error) {
			require.Equal(t, "valid", credentials["apiKey"])
			return runner.ConnectionConfig{"topology": "aggregate"}, nil
		}
		fake := newFakeStore()
		router := newAppRouter(Dependencies{
			AppStore:           fake,
			ExtensionProviders: index,
		})
		resp := httputil.DoJSON(
			t,
			router,
			http.MethodPut,
			providerConnectionPath,
			map[string]any{
				"credentials": map[string]any{"apiKey": "valid"},
			},
		)
		require.Equal(t, http.StatusOK, resp.Code)
		require.Equal(
			t,
			runner.ConnectionConfig{"topology": "aggregate"},
			fake.lastProviderConfig,
		)
	})

	for name, prepared := range map[string]runner.ConnectionConfig{
		"invalid key":    {"Topology": "aggregate"},
		"unknown key":    {"topology": "aggregate", "region": "cn"},
		"secret value":   {"topology": "secret"},
		"hash value":     {"topology": "deadbeef"},
		"endpoint value": {"topology": "endpoint"},
		"tool value":     {"topology": "tool"},
		"session value":  {"topology": "session"},
	} {
		name := name
		prepared := prepared
		t.Run(name, func(t *testing.T) {
			index := testProviderIndex()
			index.resolved.ConfigContract = testTopologyConfigContract()
			index.resolved.PrepareConnection = func(
				context.Context,
				runner.Credentials,
			) (runner.ConnectionConfig, error) {
				return prepared.Clone(), nil
			}
			fake := newFakeStore()
			router := newAppRouter(Dependencies{
				AppStore:           fake,
				ExtensionProviders: index,
			})
			resp := httputil.DoJSON(
				t,
				router,
				http.MethodPut,
				providerConnectionPath,
				map[string]any{
					"credentials": map[string]any{"apiKey": "valid"},
				},
			)
			require.Equal(t, http.StatusBadRequest, resp.Code)
			require.Empty(t, fake.lastProviderOperation)
		})
	}
}

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

	for _, payload := range []map[string]any{
		{
			"credentials": map[string]any{
				"apiKey": "valid",
				"extra":  "undeclared",
			},
		},
		{
			"credentials": map[string]any{"apiKey": "valid"},
			"config":      map[string]any{"topology": "browser-supplied"},
		},
	} {
		fake := newFakeStore()
		router := newAppRouter(Dependencies{
			AppStore:           fake,
			ExtensionProviders: testProviderIndex(),
		})

		resp := httputil.DoJSON(
			t,
			router,
			http.MethodPut,
			providerConnectionPath,
			payload,
		)
		require.Equal(t, http.StatusBadRequest, resp.Code)
		require.Empty(t, fake.lastProviderOperation)
	}
}

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

	fake := newFakeStore()
	fake.runtimeProviderCredentials = map[string]string{"apiKey": "rejected"}
	router := newAppRouter(Dependencies{
		AppStore:           fake,
		ExtensionProviders: testProviderIndex(),
	})

	resp := httputil.DoJSON(t, router, http.MethodPost, providerConnectionPath+":test", nil)
	require.Equal(t, http.StatusBadRequest, resp.Code)
}

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

	for _, testCase := range []struct {
		name            string
		persisted       runner.ConnectionConfig
		prepared        runner.ConnectionConfig
		expectedStatus  int
		expectedPayload string
	}{
		{
			name:            "same topology",
			persisted:       runner.ConnectionConfig{"topology": "aggregate"},
			prepared:        runner.ConnectionConfig{"topology": "aggregate"},
			expectedStatus:  http.StatusOK,
			expectedPayload: `{"ok":true}`,
		},
		{
			name:            "changed topology",
			persisted:       runner.ConnectionConfig{"topology": "aggregate"},
			prepared:        runner.ConnectionConfig{"topology": "fallback"},
			expectedStatus:  http.StatusConflict,
			expectedPayload: `{"error":"Provider connection topology changed. Reconnect to update it."}`,
		},
	} {
		testCase := testCase
		t.Run(testCase.name, func(t *testing.T) {
			t.Parallel()

			index := testProviderIndex()
			index.resolved.ConfigContract = testTopologyConfigContract()
			index.resolved.PrepareConnection = func(
				context.Context,
				runner.Credentials,
			) (runner.ConnectionConfig, error) {
				return testCase.prepared.Clone(), nil
			}
			fake := newFakeStore()
			fake.runtimeProviderCredentials = map[string]string{"apiKey": "valid"}
			fake.runtimeProviderConfig = testCase.persisted.Clone()
			router := newAppRouter(Dependencies{
				AppStore:           fake,
				ExtensionProviders: index,
			})

			resp := httputil.DoJSON(
				t,
				router,
				http.MethodPost,
				providerConnectionPath+":test",
				nil,
			)
			require.Equal(t, testCase.expectedStatus, resp.Code)
			require.JSONEq(t, testCase.expectedPayload, resp.Body.String())
		})
	}
}

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

	fake := newFakeStore()
	router := newAppRouter(Dependencies{AppStore: fake})

	resp := httputil.DoJSON(t, router, http.MethodGet, providerConnectionPath, nil)
	require.Equal(t, http.StatusServiceUnavailable, resp.Code)
}

func TestExtensionProviderConnectionDirectRoutesRejectNonOptInProvider(
	t *testing.T,
) {
	t.Parallel()

	for _, testCase := range []struct {
		name   string
		method string
		path   string
		body   any
	}{
		{
			name:   "get",
			method: http.MethodGet,
			path:   providerConnectionPath,
		},
		{
			name:   "put",
			method: http.MethodPut,
			path:   providerConnectionPath,
			body: map[string]any{
				"credentials": map[string]any{"apiKey": "not-used"},
			},
		},
		{
			name:   "delete",
			method: http.MethodDelete,
			path:   providerConnectionPath,
		},
		{
			name:   "test",
			method: http.MethodPost,
			path:   providerConnectionPath + ":test",
		},
	} {
		testCase := testCase
		t.Run(testCase.name, func(t *testing.T) {
			t.Parallel()

			fake := newFakeStore()
			router := newAppRouter(Dependencies{
				AppStore: fake,
				ExtensionProviders: &fakeProviderIndex{
					resolveErr: api.ErrNotFound,
				},
			})
			resp := httputil.DoJSON(
				t,
				router,
				testCase.method,
				testCase.path,
				testCase.body,
			)
			require.Equal(t, http.StatusNotFound, resp.Code)
			require.Empty(t, fake.lastProviderOperation)
		})
	}
}

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

	fake := newFakeStore()
	fake.providerAuthorizeErr = api.ErrForbidden
	router := newAppRouter(Dependencies{
		AppStore:           fake,
		ExtensionProviders: testProviderIndex(),
	})

	resp := httputil.DoJSON(t, router, http.MethodPut, providerConnectionPath, map[string]any{
		"credentials": map[string]any{"apiKey": "valid-looking-token"},
	})
	require.Equal(t, http.StatusForbidden, resp.Code)
	// The credential never reached the live validator: the store recorded no
	// upsert and the provider probe was never attempted.
	require.Empty(t, fake.lastProviderOperation)
	require.Nil(t, fake.lastProviderCredentials)
}

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

	fake := newFakeStore()
	fake.providerAuthorizeErr = api.ErrForbidden
	router := newAppRouter(Dependencies{
		AppStore:           fake,
		ExtensionProviders: testProviderIndex(),
	})

	resp := httputil.DoJSON(t, router, http.MethodPost, providerConnectionPath+":test", nil)
	require.Equal(t, http.StatusForbidden, resp.Code)
	require.Empty(t, fake.lastProviderOperation)
}
