package openapi

import (
	"sort"
	"strings"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// TestParseFrameworkAutoGeneratedOperationIDs pins that endpoints from specs
// using framework-auto-generated operationIds (API Platform / Symfony shape:
// "api_<concatenated-path-tokens>_<verb>[_subresource]") receive readable
// command names derived from the path rather than the unreadable concatenation
// of path tokens that the operationId carries.
//
// Issue #1256.
func TestParseFrameworkAutoGeneratedOperationIDs(t *testing.T) {
	t.Parallel()

	spec := []byte(`
openapi: 3.0.3
info:
  title: Systeme Repro
  version: 1.0.0
servers:
  - url: https://api.systeme.io
paths:
  /api/payment/subscriptions:
    get:
      operationId: api_paymentsubscriptions_get_collection
      tags: ["Payment"]
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/PaymentSubscription"
  /api/community/communities/{communityId}/memberships:
    post:
      operationId: api_communitycommunities_communityIdmemberships_post
      tags: ["Community"]
      parameters:
        - name: communityId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                userId: { type: string }
      responses:
        "200":
          description: ok
  /api/school/courses/{courseId}/enrollments:
    post:
      operationId: api_schoolcourses_courseIdenrollments_post
      tags: ["School"]
      parameters:
        - name: courseId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                userId: { type: string }
      responses:
        "200":
          description: ok
  /api/school/enrollments/{id}:
    delete:
      operationId: api_schoolenrollments_id_delete
      tags: ["School"]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "204":
          description: no content
  /api/school/enrollments:
    get:
      operationId: api_schoolenrollments_get_collection
      tags: ["School"]
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema:
                type: array
                items: { type: object }
components:
  schemas:
    PaymentSubscription:
      type: object
      properties:
        id: { type: string }
`)
	parsed, err := Parse(spec)
	require.NoError(t, err)

	tree := map[string][]string{}
	for rname, r := range parsed.Resources {
		names := make([]string, 0, len(r.Endpoints))
		for ename := range r.Endpoints {
			names = append(names, ename)
		}
		sort.Strings(names)
		tree[rname] = names
	}

	// Resources are derived from path segments after the basePath, not from
	// tag names, so "payment", "community", "school" are the expected keys.
	require.Contains(t, tree, "payment")
	require.Contains(t, tree, "community")
	require.Contains(t, tree, "school")

	for resource, endpoints := range tree {
		for _, ep := range endpoints {
			// The unreadable forms produced by the framework-id passthrough
			// must never appear as command names.
			for _, bad := range []string{
				"paymentsubscriptions",
				"communitycommunities",
				"schoolcourses",
				"schoolenrollments",
				"communityidmemberships",
				"courseidenrollments",
				"idmemberships",
				"idenrollments",
			} {
				assert.NotContains(t, ep, bad,
					"resource %s endpoint %q must not retain framework-concatenated token %q", resource, ep, bad)
			}
		}
	}

	// Positive assertions: each endpoint must reduce to a recognizable
	// CRUD verb (with optional path-collision suffix).
	containsVerb := func(name string) bool {
		for _, verb := range []string{"list", "get", "create", "update", "delete"} {
			if name == verb || strings.HasPrefix(name, verb+"-") || strings.HasSuffix(name, "-"+verb) || strings.Contains(name, "-"+verb+"-") {
				return true
			}
		}
		return false
	}
	for resource, endpoints := range tree {
		for _, ep := range endpoints {
			assert.True(t, containsVerb(ep),
				"resource %s endpoint %q should reduce to a recognizable CRUD verb after framework-id fallback", resource, ep)
		}
	}

	// Negative: a hand-curated operationId should keep its derived name.
	negSpec := []byte(`
openapi: 3.0.3
info:
  title: Curated
  version: 1.0.0
servers:
  - url: https://api.example.com
paths:
  /v1/customers:
    get:
      operationId: customersListAll
      tags: ["Customers"]
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema:
                type: array
                items: { type: object }
`)
	parsedNeg, err := Parse(negSpec)
	require.NoError(t, err)
	require.Contains(t, parsedNeg.Resources, "customers")
	customers := parsedNeg.Resources["customers"]
	negNames := make([]string, 0, len(customers.Endpoints))
	for ename := range customers.Endpoints {
		negNames = append(negNames, ename)
	}
	sort.Strings(negNames)
	_, ok := customers.Endpoints["list-all"]
	assert.True(t, ok, "hand-curated customersListAll must keep its operationId-derived name list-all; got endpoints: %v", negNames)
}

// TestIsFrameworkAutoGeneratedOperationID pins the detector boundary so
// regressions on edge cases (Google Discovery, single-segment paths,
// hand-curated operationIds) surface as table-test failures.
func TestIsFrameworkAutoGeneratedOperationID(t *testing.T) {
	t.Parallel()

	cases := []struct {
		name        string
		operationID string
		path        string
		want        bool
	}{
		{
			name:        "API Platform list-collection on two-segment path",
			operationID: "api_paymentsubscriptions_get_collection",
			path:        "/api/payment/subscriptions",
			want:        true,
		},
		{
			name:        "API Platform with path param between segments",
			operationID: "api_communitycommunities_communityIdmemberships_post",
			path:        "/api/community/communities/{communityId}/memberships",
			want:        true,
		},
		{
			name:        "API Platform delete-by-id",
			operationID: "api_schoolenrollments_id_delete",
			path:        "/api/school/enrollments/{id}",
			want:        true,
		},
		{
			name:        "hand-curated Stripe-style operationId",
			operationID: "customersListAll",
			path:        "/v1/customers",
			want:        false,
		},
		{
			name:        "hand-curated Petstore operationId",
			operationID: "addPet",
			path:        "/v1/pet",
			want:        false,
		},
		{
			name:        "api_ prefix but no path-token concatenation",
			operationID: "api_user_v1_create",
			path:        "/api/user",
			want:        false,
		},
		{
			name:        "Google Discovery dotted operationId",
			operationID: "run.projects.locations.services.create",
			path:        "/v2/projects/{projectsId}/locations/{locationsId}/services",
			want:        false,
		},
		{
			name:        "Cal.com controller-prefixed operationId",
			operationID: "BookingsController_2024-08-13_getBooking",
			path:        "/v2/bookings/{uid}",
			want:        false,
		},
		{
			name:        "empty operationId",
			operationID: "",
			path:        "/api/foo/bar",
			want:        false,
		},
		{
			name:        "empty path",
			operationID: "api_foo_bar_post",
			path:        "",
			want:        false,
		},
		{
			// Hand-curated compound-noun id (e.g. searchresults) over a path
			// whose segments happen to be the component words. Ends in
			// English verb "list", not a framework HTTP-method suffix, so
			// the verb-suffix gate filters it out.
			name:        "compound-noun hand-curated id with English verb",
			operationID: "api_searchresults_list",
			path:        "/api/search/results",
			want:        false,
		},
		{
			// Path-param token (userid) must not contribute to concat
			// candidates. Even though "userid"+"settings" = "useridsettings"
			// could appear in a hand-curated id, path-param segments are
			// excluded from normSegs so the candidate is never formed.
			name:        "path-param adjacency does not contribute to concat",
			operationID: "users_userid_settings_get",
			path:        "/api/users/{userId}/settings",
			want:        false,
		},
		{
			// Sub-resource list (API Platform composite suffix).
			name:        "API Platform get_subresource suffix",
			operationID: "api_invoicelines_invoiceIdrefunds_get_subresource",
			path:        "/api/invoice/lines/{invoiceId}/refunds",
			want:        true,
		},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			got := isFrameworkAutoGeneratedOperationID(tc.operationID, tc.path)
			assert.Equal(t, tc.want, got)
		})
	}
}
