package wire

import (
	"fmt"
	"path"
	"strings"
	"testing"
)

// The surface class of a data-API path is the segment that follows the version. The browser
// credential-lane inventory in salesforce-crm-standard/frontend classifies every endpoint the app
// calls by that segment alone, and the migration off a JS-readable bearer is planned per class. So a
// published endpoint whose class depends on metadata is not a cosmetic defect: it means the class of
// a request cannot be decided without knowing what a pack author wrote.
//
// StandardActionEndpoint is the one place that spells this family of URLs, so the bound belongs here.
const actionsSurfaceClass = "actions"

// addressableActionIDs are the identifiers that must keep working. Case.Escalate and Lead.Convert are
// frozen as canonical wire bytes by this package's goldens; convertLead and its siblings are what the
// pack ships. The rest walk the edges of the accepted shape.
var addressableActionIDs = []string{
	"Case.Escalate",
	"Lead.Convert",
	"convertLead",
	"A",
	"Clone",
	"ChangeOwner",
	"add_products",
	"Action9",
	"a_______9",
	"A.b.C.d9_",
	strings.Repeat("n", 240),
}

// unaddressableActionIDs are the identifiers that must never reach a published path. Each is a value
// a pack or overlay author could write.
var unaddressableActionIDs = []string{
	"../../composite/batch",      // climbs out of the class entirely
	"..",                         // climbs one level, off the standard sub-path
	".",                          // stays put, but is not an identifier
	"a/b",                        // invents a sub-path
	"/sobjects/Account",          // absolute, replacing the tail
	"%2F..%2Fcomposite",          // pre-escaped: decoded back to a separator before routing
	"%2E%2E%2F%2E%2E",            // pre-escaped dots, likewise decoded
	"a..b",                       // a dot segment hiding inside a qualified name
	".Escalate",                  // leading dot: the first segment would be empty
	"Case.",                      // trailing dot
	"Case..Escalate",             // consecutive dots
	"Convert Lead",               // a label where an identifier belongs
	"convertLead?targetObject=x", // smuggles a query string
	"convertLead#fragment",       // smuggles a fragment
	"",                           // absent
	"   ",                        // blank
	" convertLead ",              // padded: see the trimming test below
	"convertLead ",
	" convertLead",
	"9leading",            // digit-led, not a DeveloperName
	"_leading",            // underscore-led, likewise
	"convert-lead",        // hyphen: legal in a URL, not in a DeveloperName
	"convertLead/preview", // reaches a sibling route by identifier alone
	"..%00",               // a NUL escape after a dot segment
	"∕composite",          // division slash: a separator look-alike
}

// TestEveryAddressableActionEndpointStaysInTheActionsClass is the property the bound exists for. Not
// "Case.Escalate maps to this string" — the goldens already pin that — but that no accepted
// identifier can produce a path in another class.
func TestEveryAddressableActionEndpointStaysInTheActionsClass(t *testing.T) {
	for _, actionID := range addressableActionIDs {
		t.Run(actionID, func(t *testing.T) {
			endpoint := StandardActionEndpoint("v63.0", actionID)
			if endpoint == "" {
				t.Fatalf("identifier %q is a DeveloperName but has no endpoint", actionID)
			}
			if got := surfaceClassOf(t, endpoint); got != actionsSurfaceClass {
				t.Fatalf("endpoint %q is in class %q, want %q", endpoint, got, actionsSurfaceClass)
			}
			// A path that normalisation rewrites is a path whose class was never decided here.
			if cleaned := path.Clean(endpoint); cleaned != endpoint {
				t.Fatalf("endpoint %q normalises to %q, so a client would send a different path", endpoint, cleaned)
			}
			if want := "/services/data/v63.0/actions/standard/" + actionID; endpoint != want {
				t.Fatalf("endpoint = %q, want %q", endpoint, want)
			}
		})
	}
}

// TestAnUnaddressableIdentifierHasNoEndpoint covers the same property from the side that used to be
// open.
func TestAnUnaddressableIdentifierHasNoEndpoint(t *testing.T) {
	for _, actionID := range unaddressableActionIDs {
		t.Run(fmt.Sprintf("%q", actionID), func(t *testing.T) {
			if endpoint := StandardActionEndpoint("v63.0", actionID); endpoint != "" {
				t.Fatalf("identifier %q produced endpoint %q; an identifier that is not one path segment has none", actionID, endpoint)
			}
		})
	}
}

// TestAPaddedIdentifierIsRefusedRatherThanTrimmed is its own test because canonicalising is the
// tempting mistake and it is worse than refusing.
//
// Trimming " convertLead " would spell the endpoint for `convertLead` while every caller still
// reports the identifier it was given, so one action would be advertised under another's URL — and
// the resolver key the caller then dispatches on would not be the one the URL names.
func TestAPaddedIdentifierIsRefusedRatherThanTrimmed(t *testing.T) {
	trimmed := StandardActionEndpoint("v63.0", "convertLead")
	if trimmed == "" {
		t.Fatal("convertLead has no endpoint")
	}
	for _, padded := range []string{" convertLead", "convertLead ", " convertLead ", "\tconvertLead\n"} {
		if got := StandardActionEndpoint("v63.0", padded); got != "" {
			t.Fatalf("identifier %q produced %q; padding must be refused, not trimmed to %q", padded, got, trimmed)
		}
	}
}

// TestTheNaiveDerivationWouldHaveLeftTheActionsClass is what keeps the bound above from being
// decoration. It reproduces the derivation this replaced — the identifier interpolated straight into
// the path — and asserts that at least one authorable identifier really does land in another class
// under it.
//
// Without this, a future change could relax the pattern, or drop it, and the tests above would still
// pass for whatever identifiers remained accepted. This fails instead, because it asserts the danger
// is real rather than that the current code avoids it.
func TestTheNaiveDerivationWouldHaveLeftTheActionsClass(t *testing.T) {
	escaped := make([]string, 0, len(unaddressableActionIDs))
	for _, actionID := range unaddressableActionIDs {
		naive := fmt.Sprintf("/services/data/v63.0/actions/standard/%s", actionID)
		segments := strings.Split(strings.TrimPrefix(path.Clean(naive), "/"), "/")
		if len(segments) < 4 || segments[3] != actionsSurfaceClass {
			escaped = append(escaped, fmt.Sprintf("%q -> %q", actionID, path.Clean(naive)))
		}
	}
	if len(escaped) == 0 {
		t.Fatal("no refused identifier escapes the actions class under the naive derivation, so the bound proves nothing")
	}
	t.Logf("identifiers that leave the actions class when interpolated raw: %s", strings.Join(escaped, ", "))
}

// TestTheGuardMessageOmitsAnUnaddressableAction checks the other consumer of the same spelling. The
// create-from-source guard tells a caller which action to invoke instead, so an identifier with no
// endpoint has to drop out of the list rather than appear as an empty entry between two commas — and
// certainly rather than appear as a URL pointing at an unrelated API surface.
func TestTheGuardMessageOmitsAnUnaddressableAction(t *testing.T) {
	got := CreateFromSourceGuardMessage("v63.0", "Case", []string{"Case.Escalate", "../../composite/batch"})
	want := "Direct create of Case with a configured source-FK populated is not permitted. " +
		"Invoke the owning standard action endpoint instead: " +
		"/services/data/v63.0/actions/standard/Case.Escalate"
	if got != want {
		t.Fatalf("message =\n %q\nwant\n %q", got, want)
	}
	if strings.Contains(got, "composite") {
		t.Fatalf("message names an endpoint outside the actions class: %q", got)
	}
}

// surfaceClassOf answers the same question the frontend inventory's classifier does: which class does
// this path belong to? It reads the 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]
}
