package catalog

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"github.com/seaweedfs/seaweedfs/test/testutil"
)

// oauthTestEnv holds a weed mini instance started with IAM credentials
// so that the OAuth token endpoint is functional.
type oauthTestEnv struct {
	seaweedDir     string
	weedBinary     string
	dataDir        string
	bindIP         string
	s3Port         int
	s3GrpcPort     int
	icebergPort    int
	masterPort     int
	masterGrpcPort int
	filerPort      int
	filerGrpcPort  int
	volumePort     int
	volumeGrpcPort int
	webdavPort     int
	weedProcess    *exec.Cmd
	weedCancel     context.CancelFunc
	accessKey      string
	secretKey      string
	// s3ExternalURL, when set before start, is the S3 endpoint the catalog
	// advertises to clients in LoadTable FileIO config.
	s3ExternalURL string
}

func newOAuthTestEnv(t *testing.T) *oauthTestEnv {
	t.Helper()

	wd, err := os.Getwd()
	if err != nil {
		t.Fatalf("get working directory: %v", err)
	}

	seaweedDir := wd
	for i := 0; i < 6; i++ {
		if _, err := os.Stat(filepath.Join(seaweedDir, "go.mod")); err == nil {
			break
		}
		seaweedDir = filepath.Dir(seaweedDir)
	}

	weedBinary := filepath.Join(seaweedDir, "weed", "weed")
	if info, err := os.Stat(weedBinary); err == nil && !info.IsDir() {
		// Say which binary and how old it is. `make test` rebuilds first, but
		// `go test` on its own happily runs a weeks-old binary and reports a
		// pass for code that is not being exercised.
		t.Logf("using %s, built %s", weedBinary, info.ModTime().Format(time.RFC3339))
	} else {
		weedBinary = "weed"
		if _, err := exec.LookPath(weedBinary); err != nil {
			t.Skip("weed binary not found, skipping integration test")
		}
	}

	dataDir, err := os.MkdirTemp("", "seaweed-oauth-test-*")
	if err != nil {
		t.Fatalf("create temp dir: %v", err)
	}

	bindIP := testutil.FindBindIP()
	ports := testutil.MustAllocatePorts(t, 10)

	return &oauthTestEnv{
		seaweedDir:     seaweedDir,
		weedBinary:     weedBinary,
		dataDir:        dataDir,
		bindIP:         bindIP,
		masterPort:     ports[0],
		masterGrpcPort: ports[1],
		volumePort:     ports[2],
		volumeGrpcPort: ports[3],
		filerPort:      ports[4],
		filerGrpcPort:  ports[5],
		s3Port:         ports[6],
		s3GrpcPort:     ports[7],
		icebergPort:    ports[8],
		webdavPort:     ports[9],
		accessKey:      "AKIAIOSFODNN7EXAMPLE",
		secretKey:      "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
	}
}

func (env *oauthTestEnv) start(t *testing.T) {
	t.Helper()

	iamConfigPath, err := testutil.WriteIAMConfig(env.dataDir, env.accessKey, env.secretKey)
	if err != nil {
		t.Fatalf("write IAM config: %v", err)
	}

	securityToml := filepath.Join(env.dataDir, "security.toml")
	if err := os.WriteFile(securityToml, []byte("# Empty security config for testing\n"), 0644); err != nil {
		t.Fatalf("write security.toml: %v", err)
	}

	ctx, cancel := context.WithCancel(context.Background())
	env.weedCancel = cancel

	args := []string{"mini",
		"-master.port", fmt.Sprintf("%d", env.masterPort),
		"-master.port.grpc", fmt.Sprintf("%d", env.masterGrpcPort),
		"-volume.port", fmt.Sprintf("%d", env.volumePort),
		"-volume.port.grpc", fmt.Sprintf("%d", env.volumeGrpcPort),
		"-filer.port", fmt.Sprintf("%d", env.filerPort),
		"-filer.port.grpc", fmt.Sprintf("%d", env.filerGrpcPort),
		"-s3.port", fmt.Sprintf("%d", env.s3Port),
		"-s3.port.grpc", fmt.Sprintf("%d", env.s3GrpcPort),
		"-s3.port.iceberg", fmt.Sprintf("%d", env.icebergPort),
		"-webdav.port", fmt.Sprintf("%d", env.webdavPort),
		"-s3.config", iamConfigPath,
		"-ip", env.bindIP,
		"-ip.bind", "0.0.0.0",
		"-dir", env.dataDir,
	}
	if env.s3ExternalURL != "" {
		args = append(args, "-s3.externalUrl", env.s3ExternalURL)
	}

	cmd := exec.CommandContext(ctx, env.weedBinary, args...)
	cmd.Dir = env.dataDir
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	cmd.Env = append(os.Environ(),
		"AWS_ACCESS_KEY_ID="+env.accessKey,
		"AWS_SECRET_ACCESS_KEY="+env.secretKey,
	)

	if err := cmd.Start(); err != nil {
		cancel()
		t.Fatalf("start weed mini: %v", err)
	}
	env.weedProcess = cmd

	icebergURL := fmt.Sprintf("http://%s:%d/v1/config", env.bindIP, env.icebergPort)
	if !testutil.WaitForService(icebergURL, 30*time.Second) {
		cancel()
		cmd.Wait()
		t.Fatalf("Iceberg REST API did not become ready at %s", icebergURL)
	}
}

func (env *oauthTestEnv) cleanup(t *testing.T) {
	t.Helper()
	if env.weedCancel != nil {
		env.weedCancel()
	}
	if env.weedProcess != nil {
		env.weedProcess.Wait()
	}
	if env.dataDir != "" {
		os.RemoveAll(env.dataDir)
	}
}

func (env *oauthTestEnv) icebergURL() string {
	return fmt.Sprintf("http://%s:%d", env.bindIP, env.icebergPort)
}

// TestOAuthTokenEndpoint tests the /v1/oauth/tokens endpoint directly via HTTP.
func TestOAuthTokenEndpoint(t *testing.T) {
	if testing.Short() {
		t.Skip("Skipping integration test in short mode")
	}

	env := newOAuthTestEnv(t)
	defer env.cleanup(t)
	env.start(t)

	t.Run("valid credentials", func(t *testing.T) {
		token := requestOAuthToken(t, env, env.accessKey, env.secretKey)
		if token == "" {
			t.Fatal("expected non-empty token")
		}
	})

	t.Run("invalid secret", func(t *testing.T) {
		resp, err := http.PostForm(env.icebergURL()+"/v1/oauth/tokens", url.Values{
			"grant_type":    {"client_credentials"},
			"client_id":     {env.accessKey},
			"client_secret": {"wrong-secret"},
		})
		if err != nil {
			t.Fatalf("POST /v1/oauth/tokens: %v", err)
		}
		defer resp.Body.Close()

		if resp.StatusCode != http.StatusUnauthorized {
			body, _ := io.ReadAll(resp.Body)
			t.Fatalf("expected 401, got %d: %s", resp.StatusCode, body)
		}
	})

	t.Run("missing grant_type", func(t *testing.T) {
		resp, err := http.PostForm(env.icebergURL()+"/v1/oauth/tokens", url.Values{
			"client_id":     {env.accessKey},
			"client_secret": {env.secretKey},
		})
		if err != nil {
			t.Fatalf("POST /v1/oauth/tokens: %v", err)
		}
		defer resp.Body.Close()

		if resp.StatusCode != http.StatusBadRequest {
			body, _ := io.ReadAll(resp.Body)
			t.Fatalf("expected 400, got %d: %s", resp.StatusCode, body)
		}
	})

	t.Run("bearer token auth on catalog endpoint", func(t *testing.T) {
		token := requestOAuthToken(t, env, env.accessKey, env.secretKey)

		// Point the call at a table bucket that exists, so anything but 200 is an
		// auth or routing fault rather than the catalog reporting a missing bucket.
		bucketName := "oauth-bearer-" + randomSuffix()
		createTableBucketViaShell(t, env, bucketName)

		req, err := http.NewRequest(http.MethodGet, env.icebergURL()+"/v1/"+bucketName+"/namespaces", nil)
		if err != nil {
			t.Fatalf("create request: %v", err)
		}
		req.Header.Set("Authorization", "Bearer "+token)

		resp, err := http.DefaultClient.Do(req)
		if err != nil {
			t.Fatalf("GET /v1/%s/namespaces with Bearer: %v", bucketName, err)
		}
		defer resp.Body.Close()

		body, _ := io.ReadAll(resp.Body)
		if resp.StatusCode != http.StatusOK {
			t.Fatalf("Bearer auth failed unexpectedly: status=%d body=%s", resp.StatusCode, body)
		}
	})
}

// TestDuckDBOAuthIntegration tests that DuckDB can connect to the Iceberg REST
// catalog using the OAuth2 client_credentials flow (CREATE SECRET with client_id
// and client_secret). This is the scenario reported in issue #9015.
func TestDuckDBOAuthIntegration(t *testing.T) {
	if testing.Short() {
		t.Skip("Skipping integration test in short mode")
	}
	if !testutil.HasDocker() {
		t.Skip("Docker not available, skipping DuckDB OAuth integration test")
	}

	env := newOAuthTestEnv(t)
	defer env.cleanup(t)
	env.start(t)

	// Create a table bucket and namespace so DuckDB has something to query
	bucketName := "duckdb-oauth-" + randomSuffix()
	createTableBucketViaShell(t, env, bucketName)

	// Create a namespace via the Iceberg REST API using OAuth token
	token := requestOAuthToken(t, env, env.accessKey, env.secretKey)
	createNamespaceWithToken(t, env, token, bucketName, "testns")

	sqlContent := fmt.Sprintf(`
INSTALL iceberg;
LOAD iceberg;

CREATE SECRET iceberg_secret (
    TYPE ICEBERG,
    ENDPOINT 'http://host.docker.internal:%d',
    CLIENT_ID '%s',
    CLIENT_SECRET '%s',
    SCOPE 's3://%s/'
);

CREATE SECRET s3_secret (
    TYPE S3,
    KEY_ID '%s',
    SECRET '%s',
    ENDPOINT 'host.docker.internal:%d',
    URL_STYLE 'path',
    USE_SSL false,
    SCOPE 's3://%s/'
);

SELECT 'OAuth token obtained successfully' as status;

-- Try listing namespaces via the Iceberg catalog
SELECT * FROM iceberg_scan('iceberg_secret', ALLOW_MOVED_PATHS => TRUE) LIMIT 0;
`, env.icebergPort, env.accessKey, env.secretKey, bucketName,
		env.accessKey, env.secretKey, env.s3Port, bucketName)

	sqlFile := filepath.Join(env.dataDir, "duckdb_oauth_test.sql")
	if err := os.WriteFile(sqlFile, []byte(sqlContent), 0644); err != nil {
		t.Fatalf("write SQL file: %v", err)
	}

	// Run DuckDB in Docker.
	// We use a simple test: get a token, create a secret, and verify the
	// Iceberg extension can communicate with the catalog.
	// The simpler fallback test just verifies CREATE SECRET succeeds (no 404).
	fallbackSQL := fmt.Sprintf(`
INSTALL iceberg;
LOAD iceberg;

CREATE SECRET (
    TYPE ICEBERG,
    ENDPOINT 'http://host.docker.internal:%d',
    CLIENT_ID '%s',
    CLIENT_SECRET '%s'
);

SELECT 'DuckDB Iceberg OAuth secret created successfully' as result;
`, env.icebergPort, env.accessKey, env.secretKey)

	fallbackFile := filepath.Join(env.dataDir, "duckdb_oauth_fallback.sql")
	if err := os.WriteFile(fallbackFile, []byte(fallbackSQL), 0644); err != nil {
		t.Fatalf("write fallback SQL file: %v", err)
	}

	// Try the full SQL script first (CREATE SECRET + catalog access).
	// Fall back to the simple CREATE SECRET test if iceberg_scan isn't supported.
	cmd := exec.Command("docker", "run", "--rm",
		"-v", fmt.Sprintf("%s:/test", env.dataDir),
		"--add-host", "host.docker.internal:host-gateway",
		"--entrypoint", "duckdb",
		"duckdb/duckdb:latest",
		"-init", "/test/duckdb_oauth_test.sql",
		"-c", "SELECT 1",
	)

	output, err := cmd.CombinedOutput()
	outputStr := string(output)
	t.Logf("DuckDB output:\n%s", outputStr)

	if err != nil {
		if strings.Contains(outputStr, "iceberg extension is not available") ||
			strings.Contains(outputStr, "Failed to load") {
			t.Skip("Skipping: Iceberg extension not available in DuckDB Docker image")
		}
		// The key check: the old error was "HTTP NotFound_404" on /v1/oauth/tokens.
		// With our fix, this should no longer happen.
		if strings.Contains(outputStr, "NotFound_404") && strings.Contains(outputStr, "/v1/oauth/tokens") {
			t.Fatal("OAuth token endpoint returned 404 - the fix is not working")
		}
		// If iceberg_scan failed but CREATE SECRET worked, fall back to simpler test
		if strings.Contains(outputStr, "OAuth token obtained successfully") {
			t.Logf("Full SQL had partial success (token obtained), iceberg_scan may not be supported. Continuing.")
		} else {
			// Try the fallback script that only tests CREATE SECRET
			t.Logf("Full SQL failed, trying fallback CREATE SECRET test...")
			fallbackCmd := exec.Command("docker", "run", "--rm",
				"-v", fmt.Sprintf("%s:/test", env.dataDir),
				"--add-host", "host.docker.internal:host-gateway",
				"--entrypoint", "duckdb",
				"duckdb/duckdb:latest",
				"-init", "/test/duckdb_oauth_fallback.sql",
				"-c", "SELECT 1",
			)
			fallbackOutput, fallbackErr := fallbackCmd.CombinedOutput()
			fallbackStr := string(fallbackOutput)
			t.Logf("DuckDB fallback output:\n%s", fallbackStr)

			if fallbackErr != nil {
				if strings.Contains(fallbackStr, "NotFound_404") && strings.Contains(fallbackStr, "/v1/oauth/tokens") {
					t.Fatal("OAuth token endpoint returned 404 - the fix is not working")
				}
				t.Fatalf("DuckDB fallback also failed: %v\nOutput: %s", fallbackErr, fallbackStr)
			}
			if !strings.Contains(fallbackStr, "DuckDB Iceberg OAuth secret created successfully") {
				t.Errorf("expected success message in fallback output, got:\n%s", fallbackStr)
			}
			return
		}
	}

	if strings.Contains(outputStr, "OAuth token obtained successfully") {
		t.Logf("DuckDB OAuth CREATE SECRET succeeded")
	} else {
		t.Errorf("expected OAuth success message in output, got:\n%s", outputStr)
	}
}

// requestOAuthToken obtains a bearer token from the OAuth endpoint.
func requestOAuthToken(t *testing.T, env *oauthTestEnv, accessKey, secretKey string) string {
	t.Helper()

	resp, err := http.PostForm(env.icebergURL()+"/v1/oauth/tokens", url.Values{
		"grant_type":    {"client_credentials"},
		"client_id":     {accessKey},
		"client_secret": {secretKey},
	})
	if err != nil {
		t.Fatalf("POST /v1/oauth/tokens: %v", err)
	}
	defer resp.Body.Close()

	body, _ := io.ReadAll(resp.Body)

	if resp.StatusCode != http.StatusOK {
		t.Fatalf("OAuth token request failed: status=%d body=%s", resp.StatusCode, body)
	}

	var tokenResp struct {
		AccessToken string `json:"access_token"`
		TokenType   string `json:"token_type"`
		ExpiresIn   int    `json:"expires_in"`
	}
	if err := json.Unmarshal(body, &tokenResp); err != nil {
		t.Fatalf("decode token response: %v", err)
	}

	if tokenResp.AccessToken == "" {
		t.Fatal("got empty access_token")
	}
	if tokenResp.TokenType != "bearer" {
		t.Errorf("expected token_type=bearer, got %s", tokenResp.TokenType)
	}
	return tokenResp.AccessToken
}

// createTableBucketViaShell creates a table bucket using weed shell,
// which bypasses S3 auth. This is the same approach used by the Trino tests.
func createTableBucketViaShell(t *testing.T, env *oauthTestEnv, bucketName string) {
	t.Helper()

	cmd := exec.Command(env.weedBinary, "shell",
		fmt.Sprintf("-master=%s:%d.%d", env.bindIP, env.masterPort, env.masterGrpcPort),
	)
	cmd.Stdin = strings.NewReader(fmt.Sprintf("s3tables.bucket -create -name %s -account 000000000000\nexit\n", bucketName))
	output, err := cmd.CombinedOutput()
	if err != nil {
		t.Fatalf("create table bucket %s via weed shell: %v\nOutput: %s", bucketName, err, output)
	}
	t.Logf("Created table bucket %s", bucketName)
}

// createNamespaceWithToken creates a namespace using a Bearer token.
func createNamespaceWithToken(t *testing.T, env *oauthTestEnv, token, bucketName, namespace string) {
	t.Helper()

	path := fmt.Sprintf("/v1/%s/namespaces", bucketName)
	body := fmt.Sprintf(`{"namespace":["%s"]}`, namespace)
	req, err := http.NewRequest(http.MethodPost, env.icebergURL()+path, strings.NewReader(body))
	if err != nil {
		t.Fatalf("create request: %v", err)
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+token)

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatalf("create namespace: %v", err)
	}
	defer resp.Body.Close()

	respBody, _ := io.ReadAll(resp.Body)
	if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusConflict {
		t.Fatalf("create namespace failed: status=%d body=%s", resp.StatusCode, respBody)
	}
	t.Logf("Created namespace %s in bucket %s", namespace, bucketName)
}
