package sfcrmappservice

import (
	"encoding/json"
	"fmt"
	"os"
	"path"
	"path/filepath"
	"slices"
	"strings"
	"testing"

	permissionsemantic "github.com/pingcap-inc/nexuscrm-contract/pkg/runtime/effectivepermissionsv1"
)

// The shape rule for a standard action identifier belongs 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 this file owns is the AppService's two surfaces agreeing with that owner: what discovery
// advertises, and what the route parser will execute. Those used to be three different rules — the
// projection escaped the name, the parser unescaped it and refused separators, and the owner knew
// nothing about either — so a name like `../../composite/batch` was advertised here and refused
// there.
func TestTheAdvertisedActionSetEqualsTheExecutableActionSet(t *testing.T) {
	for _, actionID := range []string{
		"convertLead", "Case.Escalate", "Lead.Convert", "add_products", "A",
		"../../composite/batch", "..", ".", "a/b", "/sobjects/Account",
		"Convert Lead", " convertLead ", "convertLead ", "", "   ",
		"Case..Escalate", ".Escalate", "Case.", "convert-lead", "9leading",
		"convertLead?targetObject=x", "convertLead#fragment",
	} {
		t.Run(fmt.Sprintf("%q", actionID), func(t *testing.T) {
			advertised := standardActionToWire(standardActionDefinition{Name: actionID}, permissionsemantic.Response{}, "v63.0").Endpoint
			executable := standardActionIDAddressable(actionID)

			if executable != (advertised != "") {
				t.Fatalf("identifier %q: executable=%t but advertised endpoint %q; the two sets must agree",
					actionID, executable, advertised)
			}
			if advertised == "" {
				return
			}
			// Everything advertised is in the actions class and survives normalisation, so a client
			// sends the path this named rather than one it rewrote.
			want := "/services/data/v63.0/actions/standard/" + actionID
			if advertised != want {
				t.Fatalf("endpoint = %q, want %q", advertised, want)
			}
			if cleaned := path.Clean(advertised); cleaned != advertised {
				t.Fatalf("endpoint %q normalises to %q", advertised, cleaned)
			}
			if segments := strings.Split(strings.TrimPrefix(advertised, "/"), "/"); segments[3] != "actions" {
				t.Fatalf("endpoint %q is in class %q, want actions", advertised, segments[3])
			}
		})
	}
}

// TestABlankVersionStillYieldsAVersionedPath covers what the hand-built path got wrong beside the
// name: it concatenated the version without defaulting it, so a blank version produced
// `/services/data//actions/standard/x`, which normalises to `/services/data/actions/standard/x` —
// one segment short, and no longer a versioned data-API path at all.
func TestABlankVersionStillYieldsAVersionedPath(t *testing.T) {
	for _, version := range []string{"", "   "} {
		endpoint := standardActionToWire(standardActionDefinition{Name: "convertLead"}, permissionsemantic.Response{}, version).Endpoint
		if endpoint != "/services/data/v63.0/actions/standard/convertLead" {
			t.Fatalf("version %q gave endpoint %q, want the v63.0 fallback", version, endpoint)
		}
		if cleaned := path.Clean(endpoint); cleaned != endpoint {
			t.Fatalf("endpoint %q normalises to %q", endpoint, cleaned)
		}
	}
}

// TestTheGuardMessageNamesNoEndpointOutsideTheActionsClass covers the other consumer of the pinned
// owner's spelling.
//
// The create-from-source guard tells a caller which action to invoke instead. The pinned
// sfcrm/wire v0.1.1 spells an endpoint for whatever identifier it is handed, so an unaddressable one
// would put a URL in this error that a client normalises onto an unrelated API surface — an error
// message that says "invoke this instead" and points somewhere else.
func TestTheGuardMessageNamesNoEndpointOutsideTheActionsClass(t *testing.T) {
	filtered := addressableStandardActionIDs([]string{
		"Case.Escalate", "../../composite/batch", "Convert Lead", "Lead.Convert", "", "..",
	})
	if want := []string{"Case.Escalate", "Lead.Convert"}; !slices.Equal(filtered, want) {
		t.Fatalf("filtered = %v, want %v", filtered, want)
	}
	for _, actionID := range filtered {
		if !standardActionIDAddressable(actionID) {
			t.Fatalf("filter kept %q, which has no endpoint", actionID)
		}
	}
	// And nothing survives that would leave the class, checked through the spelling itself rather
	// than through the filter that produced it.
	for _, actionID := range filtered {
		endpoint := standardActionEndpoint("v63.0", actionID)
		if endpoint == "" || path.Clean(endpoint) != endpoint {
			t.Fatalf("kept identifier %q spells %q", actionID, endpoint)
		}
		if segments := strings.Split(strings.TrimPrefix(endpoint, "/"), "/"); segments[3] != "actions" {
			t.Fatalf("kept identifier %q is in class %q", actionID, segments[3])
		}
	}
}

// TestTheWirePinStillPredatesTheSharedPredicate is the seal that keeps the local mirror temporary.
//
// standardActionIDPattern duplicates sfcrm/wire.IsAddressableStandardActionID because this module
// consumes released dependencies with no replace directive — tests/test_sfcrm_appservice_boundary.py
// enforces that — and the shared predicate landed in sfcrm/wire after v0.1.1. A duplicated rule with
// no expiry is how two owners of one fact are born, so this fails the moment the pin moves: at that
// point delete the mirror, call the owner, and delete this test with it.
func TestTheWirePinStillPredatesTheSharedPredicate(t *testing.T) {
	manifest, err := os.ReadFile("go.mod")
	if err != nil {
		t.Fatalf("read go.mod: %v", err)
	}
	const pinned = "github.com/pingcap-inc/nexuscrm-app-packs/sfcrm/wire v0.1.1"
	if !strings.Contains(string(manifest), pinned) {
		t.Fatalf("the sfcrm/wire pin moved off v0.1.1.\n"+
			"That is the release carrying IsAddressableStandardActionID, so the local mirror in "+
			"standard_actions.go (standardActionIDPattern / standardActionIDAddressable) is now a "+
			"second owner of a fact sfcrm/wire owns. Delete the mirror, call the owner, and delete "+
			"this test.\ngo.mod still expected: %s", pinned)
	}
}

// TestTheParserRefusesWhatDiscoveryWouldNotAdvertise checks the request side directly, because the
// agreement above is only meaningful if the parser is the thing that actually decides.
func TestTheParserRefusesWhatDiscoveryWouldNotAdvertise(t *testing.T) {
	for _, actionID := range []string{"../../composite/batch", "Convert Lead", " convertLead ", "Case..Escalate"} {
		route := matchStandardActionsRoute("/services/data/v63.0/actions/standard/" + actionID)
		// Refusal takes two shapes and both count: an explicit error for a name this rejects, and
		// the zero route for a path whose segment count never matched in the first place. What must
		// not happen is an executable route carrying the name.
		if route.err == nil && route.name == actionID {
			t.Fatalf("identifier %q parsed as an executable route (kind=%v); discovery would not advertise it",
				actionID, route.kind)
		}
	}
	// The addressable ones still parse, so this is a bound rather than a blanket refusal.
	for _, actionID := range []string{"convertLead", "Case.Escalate"} {
		route := matchStandardActionsRoute("/services/data/v63.0/actions/standard/" + actionID)
		if route.err != nil || route.name != actionID {
			t.Fatalf("identifier %q did not parse: name=%q err=%v", actionID, route.name, route.err)
		}
	}
}

// TestPackValidationRefusesAnUnaddressableActionName moves the failure from runtime to authoring time.
//
// Before this, a name that is not one path segment passed validation and was then dropped from
// discovery by the projection, with only a log line to explain why — an action that disappears from
// the UI for a reason nobody sees. The four actions the pack ships must keep validating, so this
// asserts both directions.
func TestPackValidationRefusesAnUnaddressableActionName(t *testing.T) {
	valid := standardActionDefinition{
		Name: "convertLead", Label: "Convert Lead", FlowDeveloperName: "Std_ConvertLead",
		RequiredPermissions: []string{"ConvertLeads"}, TargetObjects: []string{"Lead"},
	}
	if err := validateStandardActionDefinition(valid); err != nil {
		t.Fatalf("a shipped-shape definition was refused: %v", err)
	}

	for _, name := range []string{
		"../../composite/batch", "a/b", "..", ".", "Convert Lead",
		"Case..Escalate", "convert-lead", "9leading", "/sobjects/Account",
	} {
		unaddressable := valid
		unaddressable.Name = name
		if err := validateStandardActionDefinition(unaddressable); err == nil {
			t.Errorf("name %q passed pack validation; serving would then drop it silently", name)
		}
	}
}

// TestEveryShippedActionPassesPackValidation reads the pack rather than a fixture, because the point
// of the check above is to catch an authoring fault and it must not reject what is already shipped.
func TestEveryShippedActionPassesPackValidation(t *testing.T) {
	raw, err := os.ReadFile(filepath.Join("..", "..", "salesforce-crm-standard", "actions", "invocable_standard_actions.json"))
	if err != nil {
		t.Fatalf("read the shipped actions: %v", err)
	}
	var definitions []standardActionDefinition
	if err := json.Unmarshal(raw, &definitions); err != nil {
		t.Fatalf("decode the shipped actions: %v", err)
	}
	if len(definitions) == 0 {
		t.Fatal("no shipped actions were read, so this proves nothing")
	}
	for _, definition := range definitions {
		if err := validateStandardActionDefinition(definition); err != nil {
			t.Errorf("shipped action %q fails validation: %v", definition.Name, err)
		}
		if endpoint := standardActionEndpoint("v63.0", definition.Name); endpoint == "" {
			t.Errorf("shipped action %q has no addressable endpoint", definition.Name)
		}
	}
}
