// Copyright 2026 Anthropic, PBC. Licensed under Apache-2.0.

package generator

import (
	"os"
	"path/filepath"
	"strings"
	"testing"

	"github.com/mvanhorn/cli-printing-press/v4/internal/naming"
	"github.com/mvanhorn/cli-printing-press/v4/internal/spec"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// TestGenerateSyncParamPassthrough verifies the sync template emits the
// --param / --resource-param / --global-param flags, parses them through
// parseSyncUserParams, and applies them after spec-derived params. Some
// APIs mark filter params optional in the spec but reject requests
// without them at runtime; without this passthrough, the only workaround
// is hand-editing the generated client.
func TestGenerateSyncParamPassthrough(t *testing.T) {
	t.Parallel()

	apiSpec := minimalSpec("syncparam")
	outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
	gen := New(apiSpec, outputDir)
	require.NoError(t, gen.Generate())

	syncGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "sync.go"))
	require.NoError(t, err)
	syncSrc := string(syncGo)

	// Flag declarations exist with the expected help text shape (operator
	// has to know what to type without reading the source).
	assert.Contains(t, syncSrc, `cmd.Flags().StringArrayVar(&paramFlags, "param"`,
		"sync should expose a repeatable --param flag")
	assert.Contains(t, syncSrc, `cmd.Flags().StringArrayVar(&resourceParamFlags, "resource-param"`,
		"sync should expose a repeatable --resource-param flag")
	assert.Contains(t, syncSrc, `cmd.Flags().StringArrayVar(&globalParamFlags, "global-param"`,
		"sync should expose a repeatable --global-param flag for the apply-everywhere semantic")
	assert.Contains(t, syncSrc, "key=value",
		"--param help text should describe the key=value shape")
	assert.Contains(t, syncSrc, "resource:key=value",
		"--resource-param help text should describe the resource:key=value shape")

	// Parsing runs before client construction so a malformed flag fails fast
	// (and as usageErr, not a generic runtime error).
	assert.Contains(t, syncSrc, "parseSyncUserParams(paramFlags, resourceParamFlags, globalParamFlags)",
		"sync must parse user params at RunE entry with all three flag slices")
	parseIdx := strings.Index(syncSrc, "parseSyncUserParams(paramFlags, resourceParamFlags, globalParamFlags)")
	newClientIdx := strings.Index(syncSrc, "flags.newClient()")
	require.NotEqual(t, -1, parseIdx)
	require.NotEqual(t, -1, newClientIdx)
	assert.Less(t, parseIdx, newClientIdx,
		"--param must parse before newClient so usage errors don't waste an HTTP handshake")

	// userParams flows through to the syncResource worker. The exact call
	// site differs by template branch (HasTierRouting vs not), so assert the
	// last arg is userParams.
	assert.Contains(t, syncSrc, ", userParams)",
		"syncResource and syncDependentResources must receive userParams")

	// applyTo is called in the page loop AFTER cursor/since/limit are set,
	// so user flags win on conflict.
	loopIdx := strings.Index(syncSrc, "params[pageSize.limitParam] = strconv.Itoa(pageSize.limit)")
	applyIdx := strings.Index(syncSrc, "userParams.applyTo(resource, params, false)")
	require.NotEqual(t, -1, loopIdx, "page loop should set the limit param")
	require.NotEqual(t, -1, applyIdx, "syncResource should apply user params before c.Get")
	assert.Less(t, loopIdx, applyIdx,
		"user params must apply after spec-derived params so flags can override")

	// --resource-param keys must be validated against known resources before
	// sync starts, so a typo errors instead of silently no-op'ing.
	assert.Contains(t, syncSrc, "userParams.validateResourceNames(knownSyncResourceNames())",
		"sync should validate --resource-param keys against the known resource set")
	assert.Contains(t, syncSrc, "func knownSyncResourceNames() []string",
		"knownSyncResourceNames helper must be emitted alongside defaultSyncResources")
}

// dependentResourceSpec builds a minimal spec with a parent + child
// resource so syncDependentResource is actually emitted. The
// dependent-resource profiler requires paginated list endpoints (not
// bare GETs) with a parameterized child path for the child to be
// classified as syncable.
func dependentResourceSpec(name string) *spec.APISpec {
	paginated := func(path string) spec.Endpoint {
		return spec.Endpoint{
			Method:     "GET",
			Path:       path,
			Response:   spec.ResponseDef{Type: "array"},
			Pagination: &spec.Pagination{Type: "cursor", LimitParam: "limit", CursorParam: "after"},
		}
	}
	apiSpec := minimalSpec(name)
	apiSpec.Resources = map[string]spec.Resource{
		"projects": {
			Description: "Projects",
			Endpoints:   map[string]spec.Endpoint{"list": paginated("/projects")},
		},
		"tasks": {
			Description: "Tasks (child of projects)",
			Endpoints:   map[string]spec.Endpoint{"list": paginated("/projects/{project_id}/tasks")},
		},
	}
	return apiSpec
}

// TestGenerateSyncDependentSkipsFlatGlobalParam verifies the dependent-
// resource sync path calls applyTo with isDependent=true so --param
// (flatGlobal) is skipped on path-scoped requests. Without this gate, a
// top-level scope flag like --param workspace=<gid> double-applies to
// dependent calls like /projects/<gid>/tasks, and Asana-style APIs
// reject the call ("Must specify exactly one of project, tag, ...").
func TestGenerateSyncDependentSkipsFlatGlobalParam(t *testing.T) {
	t.Parallel()

	apiSpec := dependentResourceSpec("dependent-param")
	outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
	require.NoError(t, New(apiSpec, outputDir).Generate())

	syncGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "sync.go"))
	require.NoError(t, err)
	syncSrc := string(syncGo)

	require.Contains(t, syncSrc, "func syncDependentResource(",
		"dependent-resource sync should render when the spec has a {parent_id} child path")
	assert.Contains(t, syncSrc, "userParams.applyTo(dep.Name, params, true)",
		"dependent-resource call site must pass isDependent=true so --param is skipped on path-scoped calls")
	assert.Contains(t, syncSrc, "userParams.applyTo(resource, params, false)",
		"flat-list call site must pass isDependent=false so --param applies as before")
}

// TestGenerateSyncUserParamsHelperRespectsFlatVsTrueGlobal pins the
// emitted applyTo helper: flatGlobal entries (--param) skip when
// isDependent=true, while trueGlobal entries (--global-param) and
// perResource entries always apply.
func TestGenerateSyncUserParamsHelperRespectsFlatVsTrueGlobal(t *testing.T) {
	t.Parallel()

	apiSpec := minimalSpec("scope-helper")
	outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
	require.NoError(t, New(apiSpec, outputDir).Generate())

	helpersGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "helpers.go"))
	require.NoError(t, err)
	helpersSrc := string(helpersGo)

	require.Contains(t, helpersSrc, "type syncUserParams struct",
		"syncUserParams struct must render")
	for _, want := range []string{
		"flatGlobal  map[string]string",
		"trueGlobal  map[string]string",
		"perResource map[string]map[string]string",
	} {
		assert.Contains(t, helpersSrc, want, "syncUserParams field %q must render", want)
	}
	assert.Contains(t, helpersSrc, "func (p *syncUserParams) applyTo(resource string, params map[string]string, isDependent bool)",
		"applyTo signature must include the isDependent flag")
	assert.Contains(t, helpersSrc, "if !isDependent {",
		"applyTo must gate flatGlobal on isDependent=false")
}

// TestGenerateSyncErrorJSONIncludesAPIBody verifies that the sync_error JSON
// event surfaces the API response body as a structured field, not just
// embedded inside an opaque err.Error() string. Without this surfacing, a
// 4xx whose JSON event stream contains only `errored: 1` leaves operators
// without the body needed to diagnose required-but-not-spec'd filter params.
func TestGenerateSyncErrorJSONIncludesAPIBody(t *testing.T) {
	t.Parallel()

	apiSpec := minimalSpec("syncerr")
	outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
	gen := New(apiSpec, outputDir)
	require.NoError(t, gen.Generate())

	helpersGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "helpers.go"))
	require.NoError(t, err)
	helpersSrc := string(helpersGo)

	// The helper unwraps *client.APIError and populates Status/Method/Path/Body.
	assert.Contains(t, helpersSrc, "func syncErrorJSON(resource, parent string, err error) string",
		"syncErrorJSON helper must exist with the resource/parent/err signature")
	assert.Contains(t, helpersSrc, "var apiErr *client.APIError",
		"helper must extract *client.APIError for structured fields")
	for _, snippet := range []string{
		"payload.Status = apiErr.StatusCode",
		"payload.Method = apiErr.Method",
		"payload.Path = apiErr.Path",
		"payload.Body = apiErr.Body",
	} {
		assert.Contains(t, helpersSrc, snippet,
			"sync_error payload should surface %s from APIError", snippet)
	}

	// The flat path now uses the helper instead of a hand-rolled fmt.Fprintf
	// that embedded the body inside err.Error(). Confirm the old form is
	// gone (otherwise the body would still be lost in a wrapped string).
	syncGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "sync.go"))
	require.NoError(t, err)
	syncSrc := string(syncGo)
	assert.NotContains(t, syncSrc, `{"event":"sync_error","resource":"%s","error":"%s"}`,
		"sync_error event should be emitted via syncErrorJSON, not the legacy fmt.Fprintf shape")
	assert.Contains(t, syncSrc, `syncErrorJSON(resource, "", err)`,
		"syncResource flat path should emit sync_error via the helper")
}

// TestGenerateSyncDependentErrorNotSilent verifies the dependent-resource
// error path emits a sync_error JSON event for non-warning failures. The
// previous shape only emitted in human mode, so a 4xx on a parent request
// was invisible in agent-driven runs — operators saw missing rows with no
// diagnostic.
func TestGenerateSyncDependentErrorNotSilent(t *testing.T) {
	t.Parallel()

	apiSpec := dependentResourceSpec("dependent-err")
	outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
	gen := New(apiSpec, outputDir)
	require.NoError(t, gen.Generate())

	syncGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "sync.go"))
	require.NoError(t, err)
	syncSrc := string(syncGo)

	// syncDependentResource must actually be rendered for this spec.
	require.Contains(t, syncSrc, "func syncDependentResource(",
		"dependent-resource sync should render when the spec has a {parent_id} child path")

	// Non-warning errors must reach syncErrorJSON in the dep path. The
	// helper takes a non-empty parent ID so consumers can attribute the
	// failure to a specific parent.
	assert.Contains(t, syncSrc, "syncErrorJSON(dep.Name, parentID, err)",
		"dependent-resource non-warning error must emit a sync_error JSON event with the parent ID")
}

// noBulkListSpec mirrors the Allrecipes shape (issue #1156): a resource whose
// only GET endpoints are a parameterized detail page and a search with a
// required query param. Neither qualifies as a syncable list endpoint, so
// the generator's profile lands SyncableResources empty.
func noBulkListSpec(name string) *spec.APISpec {
	apiSpec := minimalSpec(name)
	apiSpec.Resources = map[string]spec.Resource{
		"recipes": {
			Description: "Recipes",
			Endpoints: map[string]spec.Endpoint{
				"get": {
					Method: "GET",
					Path:   "/recipe/{recipe_id}/{slug}",
					Params: []spec.Param{
						{Name: "recipe_id", PathParam: true, Required: true, Type: "string"},
						{Name: "slug", PathParam: true, Required: true, Type: "string"},
					},
					Response: spec.ResponseDef{Type: "object", Item: "Recipe"},
				},
				"search": {
					Method: "GET",
					Path:   "/search",
					Params: []spec.Param{
						{Name: "q", Required: true, Type: "string"},
					},
					Response: spec.ResponseDef{Type: "array"},
				},
			},
		},
	}
	return apiSpec
}

// TestGenerateSyncEmitsEmptyHintWhenNoBulkList covers issue #1156. When a spec
// has no bulk-list endpoint (only parameterized detail pages and required-
// query searches), defaultSyncResources renders empty and the runtime sync
// command is a no-op. The template must emit a clear hint so users and agents
// understand the silence and can find the population path (single-fetch
// commands writing to the store).
func TestGenerateSyncEmitsEmptyHintWhenNoBulkList(t *testing.T) {
	t.Parallel()

	apiSpec := noBulkListSpec("nobulk")
	outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
	require.NoError(t, New(apiSpec, outputDir).Generate())

	syncGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "sync.go"))
	require.NoError(t, err)
	syncSrc := string(syncGo)

	// Sanity: the structural precondition matches Allrecipes shape with both
	// syncable and dependent slices empty, so defaultSyncResources renders
	// with an empty body.
	assert.Regexp(t,
		`func defaultSyncResources\(\) \[\]string \{\s*return \[\]string\{\}\s*\}`,
		syncSrc,
		"defaultSyncResources should be empty for a spec with no bulk-list endpoints",
	)

	// The runtime hint surfaces in both modes so JSON-driven agents and human
	// callers both see the explanation instead of a silent total_records:0.
	assert.Contains(t, syncSrc, "no bulk-list endpoints",
		"sync should print a stderr hint when defaultSyncResources is empty")
	assert.Contains(t, syncSrc, `"reason":"no_bulk_list_endpoints"`,
		"sync should emit a sync_warning JSON event when defaultSyncResources is empty")
}

// TestGenerateSyncSkipsEmptyHintWhenBulkListExists ensures the template hint
// is template-time conditional and does not appear when the spec exposes at
// least one syncable resource. Without this guard, every CLI would carry the
// dead branch.
func TestGenerateSyncSkipsEmptyHintWhenBulkListExists(t *testing.T) {
	t.Parallel()

	apiSpec := minimalSpec("withbulk")
	outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
	require.NoError(t, New(apiSpec, outputDir).Generate())

	syncGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "sync.go"))
	require.NoError(t, err)
	syncSrc := string(syncGo)

	assert.NotContains(t, syncSrc, "no bulk-list endpoints",
		"sync should not carry the empty-list hint when a syncable resource exists")
	assert.NotContains(t, syncSrc, `"reason":"no_bulk_list_endpoints"`,
		"sync should not emit the no_bulk_list_endpoints event when a syncable resource exists")
}
