package dialectsfcrm

import (
	"context"
	"fmt"
	"net/http"
	"path"
	"strings"
	"testing"

	"github.com/nexuscrm/shared/pkg/contract"
	"github.com/nexuscrm/shared/pkg/dialect"
)

// The shape rule for a standard action's identifier, and the reasoning behind it, belong to the
// sfcrm/wire owner that spells the URL — see standard_action_endpoint_class_test.go there for the
// exhaustive table and for why escaping cannot substitute for a shape rule.
//
// What these tests own is this repository's projection: that the action list carries the owner's
// answer rather than a second spelling of its own, and that the handler does not advertise an action
// it cannot address.
const actionsSurfaceClass = "actions"

// TestTheActionListCarriesTheOwnersEndpointSpelling pins the projection against the owner. If this
// file ever grows its own format string again, one of these two assertions breaks.
func TestTheActionListCarriesTheOwnersEndpointSpelling(t *testing.T) {
	for _, name := range []string{"convertLead", "Case.Escalate", "Lead.Convert", "add_products"} {
		t.Run(name, func(t *testing.T) {
			entry := standardActionListEntryFromContract(contract.StandardActionDescriptor{
				Name:                   name,
				CreatePreviewSupported: true,
			}, "v63.0")

			want := "/services/data/v63.0/actions/standard/" + name
			if entry.Endpoint != want {
				t.Fatalf("endpoint = %q, want %q", entry.Endpoint, want)
			}
			// The preview endpoint is the endpoint it previews plus one literal, so it cannot land
			// in a different class than the action itself.
			if entry.PreviewEndpoint != want+"/preview" {
				t.Fatalf("previewEndpoint = %q, want %q", entry.PreviewEndpoint, want+"/preview")
			}
			for _, endpoint := range []string{entry.Endpoint, entry.PreviewEndpoint} {
				if got := surfaceClassOf(t, endpoint); got != actionsSurfaceClass {
					t.Fatalf("endpoint %q is in class %q, want %q", endpoint, got, actionsSurfaceClass)
				}
			}
		})
	}
}

// TestAnUnaddressableNameProjectsNoEndpoint checks that the projection passes the owner's refusal
// through instead of falling back to a spelling of its own. A representative sample is enough here;
// the owner's test walks the whole table.
func TestAnUnaddressableNameProjectsNoEndpoint(t *testing.T) {
	for _, name := range []string{"../../composite/batch", "..", "a/b", "Convert Lead", " convertLead ", "", "Case..Escalate"} {
		t.Run(fmt.Sprintf("%q", name), func(t *testing.T) {
			entry := standardActionListEntryFromContract(contract.StandardActionDescriptor{
				Name:                   name,
				CreatePreviewSupported: true,
			}, "v63.0")

			if entry.Endpoint != "" {
				t.Fatalf("name %q projected endpoint %q; a name that is not one path segment has none", name, entry.Endpoint)
			}
			if entry.PreviewEndpoint != "" {
				t.Fatalf("name %q projected preview endpoint %q", name, entry.PreviewEndpoint)
			}
			// The reported name stays exactly what metadata said. Reporting one name beside another
			// name's endpoint is the mismatch that refusing — rather than canonicalising — avoids.
			if entry.Name != name {
				t.Fatalf("reported name = %q, want the raw %q", entry.Name, name)
			}
		})
	}
}

// TestTheActionListOmitsAnActionItCannotAddress checks the handler, not the projection. An entry with
// no endpoint is an action no client can call: dispatch is POST /actions/standard/:name, one segment,
// so a name carrying a separator could never reach it. Advertising it would describe a surface that
// does not exist.
func TestTheActionListOmitsAnActionItCannotAddress(t *testing.T) {
	svc := &standardActionServiceStub{
		listDescriptors: []contract.StandardActionDescriptor{
			{Name: "../../composite/batch", Label: "Escapes its class"},
			{Name: "convertLead", Label: "Convert Lead"},
			{Name: " convertLead ", Label: "Padded, so not this action"},
		},
	}

	resp, err := ListStandardActionsNeutralHandler(context.Background(), &dialect.Request{
		Version: "v63.0",
		User:    testUser(),
	}, standardActionDeps(svc))
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if resp.Status != http.StatusOK {
		t.Fatalf("status = %d, want 200; body=%#v", resp.Status, resp.Body)
	}

	body, ok := resp.Body.(map[string]any)
	if !ok {
		t.Fatalf("body = %#v, want a map", resp.Body)
	}
	actions, ok := body["actions"].([]StandardActionListEntry)
	if !ok {
		t.Fatalf("actions = %#v, want a list of entries", body["actions"])
	}
	if len(actions) != 1 {
		t.Fatalf("listed %d actions, want only the addressable one: %#v", len(actions), actions)
	}
	if actions[0].Name != "convertLead" {
		t.Fatalf("listed %q, want convertLead", actions[0].Name)
	}
	if got := surfaceClassOf(t, actions[0].Endpoint); got != actionsSurfaceClass {
		t.Fatalf("listed endpoint %q is in class %q", actions[0].Endpoint, got)
	}
}

// TestNoActionIsExecutableWithoutBeingAdvertised is the route side of the same invariant.
//
// Omitting an unaddressable action from the list is only half an answer while the dispatch, preview
// and output-preview routes still accept any :name they are given. A name like `Case..Escalate`
// would then be hidden from discovery and still executable — an action reachable only by knowing it
// exists, which is the opposite of what a discovery surface is for.
//
// The three handlers are checked individually rather than through the shared helper, because "they
// all call the same helper" is the thing that stops being true.
func TestNoActionIsExecutableWithoutBeingAdvertised(t *testing.T) {
	handlers := map[string]func(context.Context, *dialect.Request, dialect.Deps) (*dialect.Response, error){
		"dispatch":       DispatchStandardActionNeutralHandler,
		"preview":        PreviewStandardActionNeutralHandler,
		"output-preview": OutputPreviewStandardActionNeutralHandler,
	}
	unadvertised := []string{"Case..Escalate", "Convert Lead", " convertLead ", "..", ".", "convert-lead", ""}

	for handlerName, handler := range handlers {
		for _, actionID := range unadvertised {
			t.Run(handlerName+"/"+fmt.Sprintf("%q", actionID), func(t *testing.T) {
				// A service that resolves this exact name to an active action, so a 404 can only
				// come from the route's own bound. Without this the test would pass for the wrong
				// reason — an unresolvable name is refused whether or not the bound exists — and
				// removing the bound would leave it green.
				svc := &standardActionServiceStub{
					listDescriptors: []contract.StandardActionDescriptor{{Name: actionID, Label: "Would resolve"}},
					resolution:      activeStandardActionResolution(actionID, true),
				}
				resp, err := handler(context.Background(), &dialect.Request{
					Version: "v63.0",
					User:    testUser(),
					Params:  map[string]string{"name": actionID},
				}, standardActionDeps(svc))
				if err != nil {
					t.Fatalf("unexpected error: %v", err)
				}
				if resp.Status != http.StatusNotFound {
					t.Fatalf("status = %d, want 404: a name the action list omits must not be executable", resp.Status)
				}
			})
		}
	}
}

// surfaceClassOf reads a path the way a client does — after normalisation — because that is the path
// a request actually takes.
func surfaceClassOf(t *testing.T, endpoint string) string {
	t.Helper()
	normalized := path.Clean(endpoint)
	segments := strings.Split(strings.TrimPrefix(normalized, "/"), "/")
	if len(segments) < 4 || segments[0] != "services" || segments[1] != "data" {
		t.Fatalf("endpoint %q (normalised %q) is not a versioned data-API path", endpoint, normalized)
	}
	return segments[3]
}
