package api

import (
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/tidwall/gjson"

	"github.com/photoprism/photoprism/internal/config"
	"github.com/photoprism/photoprism/internal/service/cluster"
	"github.com/photoprism/photoprism/internal/service/cluster/provisioner"
	reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
	"github.com/photoprism/photoprism/pkg/fs"
	"github.com/photoprism/photoprism/pkg/rnd"
)

func TestClusterNodesRegister(t *testing.T) {
	t.Run("FeatureDisabled", func(t *testing.T) {
		app, router, conf := NewApiTest()
		conf.Options().NodeRole = cluster.RoleApp
		ClusterNodesRegister(router)

		r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"NodeName":"pp-node-01"}`)
		assert.Equal(t, http.StatusForbidden, r.Code)
	})

	// Register with existing ClientID requires ClientSecret
	t.Run("ExistingClientRequiresSecret", func(t *testing.T) {
		app, router, conf := NewApiTest()
		conf.Options().NodeRole = cluster.RolePortal
		conf.Options().JoinToken = cluster.ExampleJoinToken
		ClusterNodesRegister(router)

		// Pre-create a node via registry and rotate to get a plaintext secret for tests
		regy, err := reg.NewClientRegistryWithConfig(conf)
		assert.NoError(t, err)
		n := &reg.Node{Node: cluster.Node{UUID: rnd.UUIDv7(), Name: "pp-auth", Role: cluster.RoleApp}}
		assert.NoError(t, regy.Put(n))
		nr, err := regy.RotateSecret(n.UUID)
		assert.NoError(t, err)
		secret := nr.ClientSecret

		// Missing secret → 401
		body := `{"NodeName":"pp-auth","ClientID":"` + nr.ClientID + `"}`
		r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, cluster.ExampleJoinToken)
		assert.Equal(t, http.StatusUnauthorized, r.Code)

		// Wrong secret → 401
		body = `{"NodeName":"pp-auth","ClientID":"` + nr.ClientID + `","ClientSecret":"WRONG"}`
		r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, cluster.ExampleJoinToken)
		assert.Equal(t, http.StatusUnauthorized, r.Code)

		// Correct secret → 200 (existing-node path)
		body = `{"NodeName":"pp-auth","ClientID":"` + nr.ClientID + `","ClientSecret":"` + secret + `"}`
		r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, cluster.ExampleJoinToken)
		assert.Equal(t, http.StatusOK, r.Code)
		cleanupRegisterProvisioning(t, conf, r)
	})
	t.Run("MissingToken", func(t *testing.T) {
		app, router, conf := NewApiTest()
		conf.Options().NodeRole = cluster.RolePortal
		ClusterNodesRegister(router)

		r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"NodeName":"pp-node-01"}`)
		assert.Equal(t, http.StatusUnauthorized, r.Code)
	})
	t.Run("CreateNodeWithoutRotateSkipsProvisioner", func(t *testing.T) {
		app, router, conf := NewApiTest()
		conf.Options().NodeRole = cluster.RolePortal
		conf.Options().JoinToken = cluster.ExampleJoinToken
		ClusterNodesRegister(router)

		// Provisioner is independent of the main DB; with MariaDB admin DSN configured
		// it should successfully provision and return 201.
		r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"NodeName":"pp-node-01"}`, cluster.ExampleJoinToken)
		assert.Equal(t, http.StatusCreated, r.Code)
		body := r.Body.String()
		assert.Contains(t, body, "\"Database\"")
		assert.Contains(t, body, "\"Secrets\"")
		assert.Contains(t, body, "\"ClientSecret\"")
		assert.Equal(t, "", gjson.Get(body, "Database.Name").String())
		assert.False(t, gjson.Get(body, "AlreadyProvisioned").Bool())
		cleanupRegisterProvisioning(t, conf, r)
	})
	t.Run("CreateNodeRotateDatabaseProvisioned", func(t *testing.T) {
		app, router, conf := NewApiTest()
		conf.Options().NodeRole = cluster.RolePortal
		conf.Options().JoinToken = cluster.ExampleJoinToken
		ClusterNodesRegister(router)

		r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"NodeName":"pp-node-rotate","RotateDatabase":true}`, cluster.ExampleJoinToken)
		assert.Equal(t, http.StatusCreated, r.Code)
		body := r.Body.String()
		assert.NotEqual(t, "", gjson.Get(body, "Database.Name").String())
		assert.NotEqual(t, "", gjson.Get(body, "Database.Password").String())
		assert.True(t, gjson.Get(body, "AlreadyProvisioned").Bool())
		cleanupRegisterProvisioning(t, conf, r)
	})
	t.Run("UUIDChangeRequiresSecret", func(t *testing.T) {
		app, router, conf := NewApiTest()
		conf.Options().NodeRole = cluster.RolePortal
		conf.Options().JoinToken = cluster.ExampleJoinToken
		ClusterNodesRegister(router)

		regy, err := reg.NewClientRegistryWithConfig(conf)
		assert.NoError(t, err)

		// Pre-create node with a UUID
		n := &reg.Node{Node: cluster.Node{UUID: rnd.UUIDv7(), Name: "pp-lock", Role: cluster.RoleApp}}
		assert.NoError(t, regy.Put(n))

		// Attempt to change UUID via name without client credentials → 409
		newUUID := rnd.UUIDv7()
		r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"NodeName":"pp-lock","NodeUUID":"`+newUUID+`"}`, cluster.ExampleJoinToken)
		assert.Equal(t, http.StatusConflict, r.Code)
	})
	t.Run("BadAdvertiseUrlRejected", func(t *testing.T) {
		app, router, conf := NewApiTest()
		conf.Options().NodeRole = cluster.RolePortal
		conf.Options().JoinToken = cluster.ExampleJoinToken
		ClusterNodesRegister(router)

		// http scheme for public host must be rejected (require https unless localhost).
		r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"NodeName":"pp-node-03","AdvertiseUrl":"http://example.com"}`, cluster.ExampleJoinToken)
		assert.Equal(t, http.StatusBadRequest, r.Code)
	})
	t.Run("GoodAdvertiseUrlAccepted", func(t *testing.T) {
		app, router, conf := NewApiTest()
		conf.Options().NodeRole = cluster.RolePortal
		conf.Options().JoinToken = cluster.ExampleJoinToken
		ClusterNodesRegister(router)

		// https is allowed for public host
		r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"NodeName":"pp-node-04","AdvertiseUrl":"https://example.com"}`, cluster.ExampleJoinToken)
		assert.Equal(t, http.StatusCreated, r.Code)
		cleanupRegisterProvisioning(t, conf, r)

		// http is allowed for localhost
		r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"NodeName":"pp-node-04b","AdvertiseUrl":"http://localhost:2342"}`, cluster.ExampleJoinToken)
		assert.Equal(t, http.StatusCreated, r.Code)
		cleanupRegisterProvisioning(t, conf, r)
	})
	t.Run("SiteUrlValidation", func(t *testing.T) {
		app, router, conf := NewApiTest()
		conf.Options().NodeRole = cluster.RolePortal
		conf.Options().JoinToken = cluster.ExampleJoinToken
		ClusterNodesRegister(router)

		// Reject http SiteUrl for public host
		r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"NodeName":"pp-node-05","SiteUrl":"http://example.com"}`, cluster.ExampleJoinToken)
		assert.Equal(t, http.StatusBadRequest, r.Code)

		// Accept https SiteUrl
		r = AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"NodeName":"pp-node-06","SiteUrl":"https://photos.example.com"}`, cluster.ExampleJoinToken)
		assert.Equal(t, http.StatusCreated, r.Code)
		cleanupRegisterProvisioning(t, conf, r)
	})
	t.Run("NormalizeName", func(t *testing.T) {
		app, router, conf := NewApiTest()
		conf.Options().NodeRole = cluster.RolePortal
		conf.Options().JoinToken = cluster.ExampleJoinToken
		ClusterNodesRegister(router)

		// Mixed separators and case should normalize to DNS label
		body := `{"NodeName":"My.Node/Name:Prod"}`
		r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, cluster.ExampleJoinToken)
		assert.Equal(t, http.StatusCreated, r.Code)
		cleanupRegisterProvisioning(t, conf, r)

		regy, err := reg.NewClientRegistryWithConfig(conf)
		assert.NoError(t, err)
		n, err := regy.FindByName("my-node-name-prod")
		assert.NoError(t, err)
		if assert.NotNil(t, n) {
			assert.Equal(t, "my-node-name-prod", n.Name)
		}
	})
	t.Run("BadName", func(t *testing.T) {
		app, router, conf := NewApiTest()
		conf.Options().NodeRole = cluster.RolePortal
		conf.Options().JoinToken = cluster.ExampleJoinToken
		ClusterNodesRegister(router)

		// Empty nodeName → 400
		r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"NodeName":""}`, cluster.ExampleJoinToken)
		assert.Equal(t, http.StatusBadRequest, r.Code)
	})
	t.Run("RotateSecretPersistsAndRespondsOK", func(t *testing.T) {
		app, router, conf := NewApiTest()
		conf.Options().NodeRole = cluster.RolePortal
		conf.Options().JoinToken = cluster.ExampleJoinToken
		ClusterNodesRegister(router)

		// Pre-create node in registry so handler goes through existing-node path
		// and rotates the secret before attempting DB ensure. Don't reuse the
		// Monitoring fixture client ID to avoid changing its secret, which is
		// used by OAuth tests running in the same package.
		regy, err := reg.NewClientRegistryWithConfig(conf)
		assert.NoError(t, err)
		n := &reg.Node{Node: cluster.Node{Name: "pp-node-01", Role: cluster.RoleApp}}
		assert.NoError(t, regy.Put(n))

		r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"NodeName":"pp-node-01","RotateSecret":true}`, cluster.ExampleJoinToken)
		assert.Equal(t, http.StatusOK, r.Code)
		cleanupRegisterProvisioning(t, conf, r)

		// Secret should have rotated and been persisted even though DB ensure failed.
		// Fetch by name (most-recently-updated) to avoid flakiness if another test adds
		// a node with the same name and a different id.
		n2, err := regy.FindByName("pp-node-01")
		assert.NoError(t, err)
		// With client-backed registry, plaintext secret is not persisted; only rotation timestamp is updated.
		assert.NotEmpty(t, n2.RotatedAt)
	})
	t.Run("ExistingNodeSiteUrlPersistsAndRespondsOK", func(t *testing.T) {
		app, router, conf := NewApiTest()
		conf.Options().NodeRole = cluster.RolePortal
		conf.Options().JoinToken = cluster.ExampleJoinToken
		ClusterNodesRegister(router)

		// Pre-create node in registry so handler goes through existing-node path.
		regy, err := reg.NewClientRegistryWithConfig(conf)
		assert.NoError(t, err)
		n := &reg.Node{Node: cluster.Node{Name: "pp-node-02", Role: cluster.RoleApp}}
		assert.NoError(t, regy.Put(n))

		// Provisioner is independent; endpoint should respond 200 and persist metadata.
		r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"NodeName":"pp-node-02","SiteUrl":"https://Photos.Example.COM"}`, cluster.ExampleJoinToken)
		assert.Equal(t, http.StatusOK, r.Code)
		cleanupRegisterProvisioning(t, conf, r)

		// Ensure normalized/persisted SiteUrl.
		n2, err := regy.FindByName("pp-node-02")
		assert.NoError(t, err)
		assert.Equal(t, "https://photos.example.com", n2.SiteUrl)
	})
	t.Run("AssignNodeUUIDWhenMissing", func(t *testing.T) {
		app, router, conf := NewApiTest()
		conf.Options().NodeRole = cluster.RolePortal
		conf.Options().JoinToken = cluster.ExampleJoinToken
		ClusterNodesRegister(router)

		// Register without nodeUUID; server should assign one (UUID v7 preferred).
		r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"NodeName":"pp-node-uuid"}`, cluster.ExampleJoinToken)
		assert.Equal(t, http.StatusCreated, r.Code)
		cleanupRegisterProvisioning(t, conf, r)

		// Response must include Node.UUID
		body := r.Body.String()
		assert.NotEmpty(t, gjson.Get(body, "Node.UUID").String())

		// Verify it is persisted in the registry
		regy, err := reg.NewClientRegistryWithConfig(conf)
		assert.NoError(t, err)
		n, err := regy.FindByName("pp-node-uuid")
		assert.NoError(t, err)
		if assert.NotNil(t, n) {
			assert.NotEmpty(t, n.UUID)
		}
	})
	t.Run("ThemeHintProvided", func(t *testing.T) {
		app, router, conf := NewApiTest()
		conf.Options().NodeRole = cluster.RolePortal
		conf.Options().JoinToken = cluster.ExampleJoinToken
		ClusterNodesRegister(router)

		themeDir := conf.PortalThemePath()
		assert.NoError(t, os.MkdirAll(themeDir, fs.ModeDir))
		assert.NoError(t, os.WriteFile(filepath.Join(themeDir, fs.AppJsFile), []byte("// app\n"), fs.ModeFile))
		assert.NoError(t, os.WriteFile(filepath.Join(themeDir, fs.VersionTxtFile), []byte(" 2.0.0\n"), fs.ModeFile))
		t.Cleanup(func() { _ = os.RemoveAll(themeDir) })

		body := `{"NodeName":"pp-node-theme","Theme":"1.0.0"}`
		r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, cluster.ExampleJoinToken)
		assert.Equal(t, http.StatusCreated, r.Code)
		assert.Equal(t, "2.0.0", gjson.Get(r.Body.String(), "Theme").String())
		cleanupRegisterProvisioning(t, conf, r)

		regy, err := reg.NewClientRegistryWithConfig(conf)
		assert.NoError(t, err)
		node, err := regy.FindByName("pp-node-theme")
		assert.NoError(t, err)
		if assert.NotNil(t, node) {
			assert.Equal(t, "1.0.0", node.Theme)
		}

		body = `{"NodeName":"pp-node-theme","Theme":"2.0.0"}`
		r2 := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, cluster.ExampleJoinToken)
		assert.Equal(t, http.StatusOK, r2.Code)
		assert.Equal(t, "2.0.0", gjson.Get(r2.Body.String(), "Theme").String())
		cleanupRegisterProvisioning(t, conf, r2)
	})
}

func cleanupRegisterProvisioning(t *testing.T, conf *config.Config, r *httptest.ResponseRecorder) {
	t.Helper()

	if r.Code != http.StatusOK && r.Code != http.StatusCreated {
		return
	}

	var resp cluster.RegisterResponse
	if err := json.Unmarshal(r.Body.Bytes(), &resp); err != nil {
		t.Fatalf("unmarshal register response: %v", err)
	}

	if !resp.AlreadyProvisioned {
		return
	}

	name := resp.Database.Name
	user := resp.Database.User

	if conf != nil && (name == "" || user == "") && resp.Node.Name != "" && resp.Node.UUID != "" {
		genName, genUser, _ := provisioner.GenerateCredentials(conf, resp.Node.UUID, resp.Node.Name)
		if name == "" {
			name = genName
		}
		if user == "" {
			user = genUser
		}
	}

	if name == "" && user == "" {
		return
	}

	t.Cleanup(func() {
		ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
		defer cancel()
		if err := provisioner.DropCredentials(ctx, name, user); err != nil {
			t.Fatalf("drop credentials for %s/%s: %v", name, user, err)
		}
	})
}

// TestValidateAdvertiseURL ensures the validator accepts HTTPS everywhere and allows
// HTTP only for loopback or cluster-internal service domains.
func TestValidateAdvertiseURL(t *testing.T) {
	cases := []struct {
		u  string
		ok bool
	}{
		{"https://example.com", true},
		{"http://example.com", false},
		{"http://localhost:2342", true},
		{"http://photoprism.default.svc", true},
		{"http://photoprism.default.svc.cluster.local", true},
		{"http://photoprism.internal", true},
		{"https://127.0.0.1", true},
		{"ftp://example.com", false},
		{"https://", false},
		{"", false},
	}
	for _, c := range cases {
		if got := validateAdvertiseURL(c.u); got != c.ok {
			t.Fatalf("validateAdvertiseURL(%q) = %v, want %v", c.u, got, c.ok)
		}
	}
}

// TestValidateSiteURL mirrors the advertise URL rules for site URLs.
func TestValidateSiteURL(t *testing.T) {
	cases := []struct {
		u  string
		ok bool
	}{
		{"https://photos.example.com", true},
		{"http://photos.example.com", false},
		{"http://127.0.0.1:2342", true},
		{"mailto:me@example.com", false},
		{"://bad", false},
	}
	for _, c := range cases {
		if got := validateSiteURL(c.u); got != c.ok {
			t.Fatalf("validateSiteURL(%q) = %v, want %v", c.u, got, c.ok)
		}
	}
}
