package dialectsfcrm

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

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

// statusUrl is the one server-supplied link the CRM frontend actually follows: refreshDashboard
// returns it and analytics.ts polls it by handing it straight to apiClient.get. Every other
// hypermedia link this dialect emits is stored or displayed, not fetched — checked before writing
// this.
//
// That makes the value the server's responsibility. The browser credential-lane inventory classifies
// the endpoints the app calls by the segment after the API version, and it can only classify calls it
// can see in the frontend source. A followed link whose class came from an unvalidated request
// parameter is a request whose class is not decidable from the source at all, which is the property
// the browser-lane migration reasons with (#1865).
//
// This is a decidability hole rather than a live exploit: a router hands one segment to a path
// parameter, so the reachable bad values are dot segments and percent escapes rather than an arbitrary
// path. Closing it by construction costs less than re-arguing it at each review.

// workingDashboardIDs are shapes this deployment really uses — hyphens and plain names included — so
// the rule below is about addressability and not about a naming convention.
var workingDashboardIDs = []string{"01Z-dashboard", "01Zxx0000000003AAA", "Executive", "dash-1"}

// unaddressableDashboardIDs would each change where a client polling the link ends up.
var unaddressableDashboardIDs = []string{
	"..",                // resolves off the dashboards sub-path
	".",                 // not an id
	"a/b",               // invents a sub-path
	"%2E%2E",            // pre-escaped dots, decoded before any router sees them
	"%2F..%2Fcomposite", // pre-escaped separators, likewise
	"dash-1?refresh=1",  // smuggles a query string onto a followed URL
	"dash-1#fragment",   // smuggles a fragment
	"   ",               // blank
	"",                  // absent
	`..\..\composite`,   // a browser's URL parser reads a backslash as a path separator
	`dash\1`,            // the same class, without the traversal
	"dash\t1",           // tab, stripped by that parser before the path is parsed
	"dash\r\n1",         // CR and LF, likewise stripped
	"dash 1",            // an internal space, which the client percent-encodes
	"dash+1",            // reserved in a query and ambiguous enough not to allow
	"dash:1",            // could be read as a scheme separator
	"dash@1",            // userinfo separator
	"dash[1]",           // host-literal brackets
	"da\u0161h",         // a non-ASCII letter, which the client would percent-encode
}

// TestTheFollowedStatusURLStaysInTheAnalyticsClass is the property, asserted over the ids that work.
func TestTheFollowedStatusURLStaysInTheAnalyticsClass(t *testing.T) {
	for _, dashboardID := range workingDashboardIDs {
		t.Run(dashboardID, func(t *testing.T) {
			accepted, resp := dashboardIDParam(&dialect.Request{Params: map[string]string{"dashboardId": dashboardID}})
			if resp != nil {
				t.Fatalf("id %q was refused with %d; it is a shape this deployment uses", dashboardID, resp.Status)
			}

			statusURL := dashboardStatusURL("v63.0", accepted)
			if cleaned := path.Clean(statusURL); cleaned != statusURL {
				t.Fatalf("statusUrl %q normalises to %q, so the client would poll a different path", statusURL, cleaned)
			}
			segments := strings.Split(strings.TrimPrefix(statusURL, "/"), "/")
			if len(segments) != 7 || segments[3] != "analytics" || segments[4] != "dashboards" || segments[6] != "status" {
				t.Fatalf("statusUrl %q is not the analytics dashboard status route", statusURL)
			}
			if want := "/services/data/v63.0/analytics/dashboards/" + dashboardID + "/status"; statusURL != want {
				t.Fatalf("statusUrl = %q, want %q", statusURL, want)
			}
		})
	}
}

// TestAnIdThatWouldMoveTheFollowedURLIsRefused covers the same property from the side that was open.
// The refusal is a 400 rather than a silently corrected id: correcting it would hand the client a link
// for a dashboard it did not ask about.
func TestAnIdThatWouldMoveTheFollowedURLIsRefused(t *testing.T) {
	for _, dashboardID := range unaddressableDashboardIDs {
		t.Run(dashboardID, func(t *testing.T) {
			accepted, resp := dashboardIDParam(&dialect.Request{Params: map[string]string{"dashboardId": dashboardID}})
			if resp == nil {
				t.Fatalf("id %q was accepted and would be interpolated into %q",
					dashboardID, dashboardStatusURL("v63.0", accepted))
			}
			if resp.Status != http.StatusBadRequest {
				t.Fatalf("id %q gave status %d, want 400", dashboardID, resp.Status)
			}
		})
	}
}

// TestTheNaiveStatusURLWouldHaveMovedTheFollowedRoute keeps the refusal from being decoration: it
// reproduces the interpolation without the gate and asserts that refused ids really do change where a
// client would poll.
func TestTheNaiveStatusURLWouldHaveMovedTheFollowedRoute(t *testing.T) {
	moved := []string{}
	for _, dashboardID := range unaddressableDashboardIDs {
		naive := dashboardStatusURL("v63.0", dashboardID)
		cleaned := path.Clean(naive)
		segments := strings.Split(strings.TrimPrefix(cleaned, "/"), "/")
		offRoute := len(segments) != 7 || segments[4] != "dashboards" || segments[6] != "status"
		// A query or fragment does not move the path but does change the request, which is the same
		// class of defect for something the client fetches verbatim.
		if offRoute || strings.ContainsAny(dashboardID, "?#") {
			moved = append(moved, dashboardID+" -> "+cleaned)
		}
	}
	if len(moved) == 0 {
		t.Fatal("no refused id changes the followed URL under the naive interpolation, so the gate proves nothing")
	}
	t.Logf("ids that change where the client polls when interpolated raw: %s", strings.Join(moved, ", "))
}

// TestAddressablePathSegmentRefusesWhatEscapingCannotFix pins the reasoning behind the rule, because
// the tempting simplification is to escape instead. A percent-encoded separator is decoded back into
// net/http's URL.Path before any router sees it, so `%` cannot be treated as harmless.
func TestAddressablePathSegmentRefusesWhatEscapingCannotFix(t *testing.T) {
	for _, value := range []string{"%2F", "%2E%2E", "a%2Fb", "..", ".", "a/b", "a?b", "a#b", "", " ", " a"} {
		if addressablePathSegment(value) {
			t.Errorf("addressablePathSegment(%q) = true; escaping does not make this safe to interpolate", value)
		}
	}
	for _, value := range []string{"a", "A1", "dash-1", "01Zxx0000000003AAA", "Executive", "a_b.c"} {
		if !addressablePathSegment(value) {
			t.Errorf("addressablePathSegment(%q) = false; this is a single path segment", value)
		}
	}
}
