package spec

import (
	"embed"
	"fmt"
	"os"
	"path/filepath"
	"testing"
	"testing/fstest"

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

//go:embed testdata/packages/valid
var validEmbed embed.FS

// validMapFS returns an in-memory minimal-but-valid package rooted at "pkg".
// Rejection tests clone and mutate it.
func validMapFS() fstest.MapFS {
	return fstest.MapFS{
		"pkg/meta.yaml": {
			Data: []byte(`schema_version: 1
domain: demo
version: 1
name: Demo
description: Minimal valid package.
endpoint:
  base_url: https://api.demo.example.com
auth:
  default: default
  profiles:
    - id: default
      type: oauth2_bearer
actions:
  - name: do-thing
    file: actions/do-thing.yaml
`),
		},
		"pkg/actions/do-thing.yaml": {
			Data: []byte(`name: do-thing
auth:
  profile: default
catalog:
  summary: Do a thing
  description: Does one thing.
  safety:
    class: mutation
    idempotent: false
    destructive: false
  input_schema:
    $schema: https://json-schema.org/draft/2020-12/schema
    type: object
    additionalProperties: false
    required: [id]
    properties:
      id:
        type: string
        minLength: 1
binding:
  method: POST
  path: /things
  body:
    content_type: application/json
    mappings:
      - from: /id
        to: /id
  response:
    mode: passthrough
    success_statuses: [201]
    content_types: [application/json]
`),
		},
	}
}

// mutateActions rewrites the action file bytes inside the MapFS.
func TestLoadDefinition_ValidPackage(t *testing.T) {
	pkg, err := LoadDefinition(validEmbed, "testdata/packages/valid")
	require.NoError(t, err)

	assert.Equal(t, "github", pkg.Metadata.Domain)
	assert.Equal(t, 1, pkg.Metadata.Version)
	assert.Equal(t, "https://api.github.com", pkg.Metadata.Endpoint.BaseURL)
	assert.Len(t, pkg.Actions, 1)

	act := pkg.Actions[0]
	assert.Equal(t, "create-issue", act.Name)
	assert.Equal(t, "create-issue", fileBase(act.File))
	assert.Equal(t, "actions/create-issue.yaml", act.File)
	require.NotNil(t, act.Auth)
	assert.Equal(t, "default", act.Auth.Profile)
	assert.Equal(t, []string{"repo"}, act.Auth.RequiredScopes)
	assert.Equal(t, "POST", act.Binding.Method)
	assert.Equal(t, "/repos/{owner}/{repo}/issues", act.Binding.Path)
	assert.Equal(t, "mutation", act.Catalog.Safety.Class)

	// Detached, sorted copy: caller can mutate without affecting re-reads.
	pkg.Actions[0].Name = "mutated"
	pkg2, err := LoadDefinition(validEmbed, "testdata/packages/valid")
	require.NoError(t, err)
	assert.Equal(t, "create-issue", pkg2.Actions[0].Name)

	// Digest is computable and round-trips.
	d, err := DefinitionDigest(pkg2)
	require.NoError(t, err)
	assert.Regexp(t, `^sha256:[0-9a-f]{64}$`, d)
}

// validStructPackage returns the in-memory equivalent of validMapFS(). Rejection
// tests mutate a clone and serialize it through EncodeDefinition so the fixture
// YAML is always syntactically valid regardless of braces/colons in values.
func validStructPackage() Package {
	return Package{
		Metadata: Metadata{
			SchemaVersion: 1,
			Domain:        "demo",
			Version:       1,
			Name:          "Demo",
			Endpoint:      Endpoint{BaseURL: "https://api.demo.example.com"},
			Auth: Auth{
				Default: "default",
				Profiles: []AuthProfile{
					{ID: "default", Type: "oauth2_bearer"},
				},
			},
		},
		Actions: []Action{{
			Name: "do-thing",
			Auth: &ActionAuthRequirement{Profile: "default"},
			File: "actions/do-thing.yaml",
			Catalog: Catalog{
				Summary: "Do a thing",
				Safety:  Safety{Class: "mutation"},
				InputSchema: map[string]any{
					"$schema":              "https://json-schema.org/draft/2020-12/schema",
					"type":                 "object",
					"additionalProperties": false,
					"required":             []any{"id"},
					"properties": map[string]any{
						"id": map[string]any{"type": "string", "minLength": 1},
					},
				},
			},
			Binding: Binding{
				Method: "POST",
				Path:   "/things",
				Body: &Body{
					ContentType: "application/json",
					Mappings:    []BodyMapping{{From: "/id", To: "/id"}},
				},
				Response: Response{
					Mode:            "passthrough",
					SuccessStatuses: []int{201},
					ContentTypes:    []string{"application/json"},
				},
			},
		}},
	}
}

// pkgToFS encodes a Package into a MapFS rooted at "pkg".
func pkgToFS(t *testing.T, pkg Package) fstest.MapFS {
	t.Helper()
	files, err := EncodeDefinition(pkg)
	require.NoError(t, err)
	m := fstest.MapFS{}
	for name, data := range files {
		m["pkg/"+name] = &fstest.MapFile{Data: data}
	}
	return m
}

func TestLoadDefinition_Rejections(t *testing.T) {
	t.Parallel()

	cases := []struct {
		name    string
		build   func() fstest.MapFS
		wantSub string
	}{
		{
			name: "action file name differs from meta ref name",
			build: func() fstest.MapFS {
				// Hand-written YAML so the action file's `name` can disagree
				// with the meta reference, which the struct API forbids.
				return fstest.MapFS{
					"pkg/meta.yaml": {Data: []byte(`schema_version: 1
domain: demo
version: 1
name: Demo
endpoint:
  base_url: https://api.demo.example.com
auth:
  default: default
  profiles:
    - id: default
      type: oauth2_bearer
actions:
  - name: do-thing
    file: actions/do-thing.yaml
`)},
					"pkg/actions/do-thing.yaml": {Data: []byte(`name: other-thing
auth:
  profile: default
catalog:
  summary: x
  safety: {class: mutation}
  input_schema:
    $schema: https://json-schema.org/draft/2020-12/schema
    type: object
binding:
  method: POST
  path: /things
  response: {mode: passthrough}
`)},
				}
			},
			wantSub: "name",
		},
		{
			name: "meta action name differs from file basename",
			build: func() fstest.MapFS {
				p := validStructPackage()
				p.Actions[0].File = "actions/renamed.yaml"
				return pkgToFS(t, p)
			},
			wantSub: "basename",
		},
		{
			name: "absolute action path rejected",
			build: func() fstest.MapFS {
				p := validStructPackage()
				p.Actions[0].File = "/etc/passwd"
				return pkgToFS(t, p)
			},
			wantSub: "absolute",
		},
		{
			name: "traversal path rejected",
			build: func() fstest.MapFS {
				p := validStructPackage()
				p.Actions[0].File = "../secret.yaml"
				return pkgToFS(t, p)
			},
			wantSub: "traversal",
		},
		{
			name: "unlisted action file rejected",
			build: func() fstest.MapFS {
				m := pkgToFS(t, validStructPackage())
				m["pkg/actions/extra.yaml"] = &fstest.MapFile{Data: []byte(`name: extra
catalog:
  summary: x
  safety: {class: read}
  input_schema:
    $schema: https://json-schema.org/draft/2020-12/schema
    type: object
binding:
  method: GET
  path: /x
  response: {mode: passthrough}
`)}
				return m
			},
			wantSub: "unlisted",
		},
		{
			name: "duplicate action names rejected",
			build: func() fstest.MapFS {
				p := validStructPackage()
				p.Actions = append(p.Actions, p.Actions[0])
				return pkgToFS(t, p)
			},
			wantSub: "duplicate",
		},
		{
			name: "non-kebab domain rejected",
			build: func() fstest.MapFS {
				p := validStructPackage()
				p.Metadata.Domain = "Demo_X"
				return pkgToFS(t, p)
			},
			wantSub: "kebab",
		},
		{
			name: "reserved domain word rejected",
			build: func() fstest.MapFS {
				p := validStructPackage()
				p.Metadata.Domain = "list"
				return pkgToFS(t, p)
			},
			wantSub: "reserved",
		},
		{
			name: "non-kebab action name rejected",
			build: func() fstest.MapFS {
				p := validStructPackage()
				p.Actions[0].Name = "DoThing"
				return pkgToFS(t, p)
			},
			wantSub: "kebab",
		},
		{
			name: "bad auth ref rejected",
			build: func() fstest.MapFS {
				p := validStructPackage()
				p.Actions[0].Auth = &ActionAuthRequirement{Profile: "missing"}
				return pkgToFS(t, p)
			},
			wantSub: "auth",
		},
		{
			name: "reserved header in static_headers rejected",
			build: func() fstest.MapFS {
				p := validStructPackage()
				p.Actions[0].Binding.StaticHeaders = map[string]string{"Authorization": "bearer x"}
				return pkgToFS(t, p)
			},
			wantSub: "reserved header",
		},
		{
			name: "reserved header parameter rejected",
			build: func() fstest.MapFS {
				p := validStructPackage()
				p.Actions[0].Binding.Parameters = []Parameter{{From: "/id", In: "header", Name: "Cookie"}}
				return pkgToFS(t, p)
			},
			wantSub: "reserved header",
		},
		{
			name: "path placeholder unbound rejected",
			build: func() fstest.MapFS {
				p := validStructPackage()
				p.Actions[0].Binding.Path = "/things/{id}"
				return pkgToFS(t, p)
			},
			wantSub: "placeholder",
		},
		{
			name: "path param without placeholder rejected",
			build: func() fstest.MapFS {
				p := validStructPackage()
				p.Actions[0].Binding.Parameters = []Parameter{{From: "/id", In: "path", Name: "x"}}
				return pkgToFS(t, p)
			},
			wantSub: "placeholder",
		},
		{
			name: "duplicate binding target rejected",
			build: func() fstest.MapFS {
				p := validStructPackage()
				p.Actions[0].Catalog.InputSchema["properties"] = map[string]any{
					"id": map[string]any{"type": "string"},
					"a":  map[string]any{"type": "string"},
					"b":  map[string]any{"type": "string"},
				}
				p.Actions[0].Binding.Parameters = []Parameter{
					{From: "/a", In: "query", Name: "q"},
					{From: "/b", In: "query", Name: "q"},
				}
				return pkgToFS(t, p)
			},
			wantSub: "conflict",
		},
		{
			name: "body pointer overlap rejected",
			build: func() fstest.MapFS {
				p := validStructPackage()
				p.Actions[0].Catalog.InputSchema["properties"] = map[string]any{
					"id": map[string]any{"type": "string"},
					"a":  map[string]any{"type": "string"},
					"b":  map[string]any{"type": "string"},
				}
				p.Actions[0].Binding.Body.Mappings = []BodyMapping{
					{From: "/a", To: "/x"},
					{From: "/b", To: "/x/y"},
				}
				return pkgToFS(t, p)
			},
			wantSub: "overlap",
		},
		{
			name: "wrong schema dialect rejected",
			build: func() fstest.MapFS {
				p := validStructPackage()
				p.Actions[0].Catalog.InputSchema["$schema"] = "http://json-schema.org/draft-07/schema#"
				return pkgToFS(t, p)
			},
			wantSub: "2020-12",
		},
		{
			name: "invalid example rejected",
			build: func() fstest.MapFS {
				p := validStructPackage()
				p.Actions[0].Catalog.Example = map[string]any{"notid": "oops"}
				return pkgToFS(t, p)
			},
			wantSub: "example",
		},
		{
			name: "non-https endpoint rejected",
			build: func() fstest.MapFS {
				p := validStructPackage()
				p.Metadata.Endpoint.BaseURL = "http://api.demo.example.com"
				return pkgToFS(t, p)
			},
			wantSub: "https",
		},
		{
			name: "endpoint with query rejected",
			build: func() fstest.MapFS {
				p := validStructPackage()
				p.Metadata.Endpoint.BaseURL = "https://api.demo.example.com/x?z=1"
				return pkgToFS(t, p)
			},
			wantSub: "query",
		},
		{
			name: "unknown meta field rejected",
			build: func() fstest.MapFS {
				m := pkgToFS(t, validStructPackage())
				m["pkg/meta.yaml"] = &fstest.MapFile{Data: append(m["pkg/meta.yaml"].Data, []byte("owner: someone\n")...)}
				return m
			},
			wantSub: "unknown",
		},
		{
			name: "unknown action field rejected",
			build: func() fstest.MapFS {
				m := pkgToFS(t, validStructPackage())
				m["pkg/actions/do-thing.yaml"] = &fstest.MapFile{Data: append([]byte("template: evil\n"), m["pkg/actions/do-thing.yaml"].Data...)}
				return m
			},
			wantSub: "unknown",
		},
	}

	for _, tc := range cases {
		tc := tc
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()
			_, err := LoadDefinition(tc.build(), "pkg")
			require.Error(t, err, "expected rejection (%s)", tc.wantSub)
			assert.Contains(t, err.Error(), tc.wantSub)
		})
	}
}

func TestLoadDefinition_SymlinkRejected(t *testing.T) {
	dir := t.TempDir()
	pkgDir := filepath.Join(dir, "pkg")
	require.NoError(t, os.MkdirAll(filepath.Join(pkgDir, "actions"), 0o755))
	// Write action content somewhere outside the package, then symlink it in.
	target := filepath.Join(dir, "real.yaml")
	require.NoError(t, os.WriteFile(target, []byte("name: do-thing\nauth: {profile: default}\ncatalog: {summary: x, safety: {class: mutation}, input_schema: {$schema: https://json-schema.org/draft/2020-12/schema, type: object}}\nbinding: {method: POST, path: /things, response: {mode: passthrough}}\n"), 0o644))
	require.NoError(t, os.Symlink(target, filepath.Join(pkgDir, "actions", "do-thing.yaml")))
	require.NoError(t, os.WriteFile(filepath.Join(pkgDir, "meta.yaml"), []byte(`schema_version: 1
domain: demo
version: 1
name: Demo
endpoint: {base_url: https://api.demo.example.com}
auth: {default: default, profiles: [{id: default, type: oauth2_bearer}]}
actions:
  - name: do-thing
    file: actions/do-thing.yaml
`), 0o644))

	_, err := LoadDefinition(os.DirFS(dir), "pkg")
	require.Error(t, err)
	assert.Contains(t, err.Error(), "symlink")
}

func TestLoadDefinitions_MultipleSorted(t *testing.T) {
	m := fstest.MapFS{}
	for _, dom := range []string{"zebra", "alpha"} {
		m[dom+"/meta.yaml"] = &fstest.MapFile{Data: []byte(fmt.Sprintf(`schema_version: 1
domain: %s
version: 1
name: %s
endpoint: {base_url: https://%s.example.com}
actions: []
`, dom, dom, dom))}
	}
	pkgs, err := LoadDefinitions(m, ".")
	require.NoError(t, err)
	require.Len(t, pkgs, 2)
	assert.Equal(t, "alpha", pkgs[0].Metadata.Domain)
	assert.Equal(t, "zebra", pkgs[1].Metadata.Domain)
}

func TestEncodeDefinition_RoundTrip(t *testing.T) {
	orig, err := LoadDefinition(validEmbed, "testdata/packages/valid")
	require.NoError(t, err)

	files, err := EncodeDefinition(orig)
	require.NoError(t, err)
	assert.Contains(t, files, "meta.yaml")
	assert.Contains(t, files, "actions/create-issue.yaml")

	m := fstest.MapFS{}
	roundtripDir := "rt"
	for name, data := range files {
		m[roundtripDir+"/"+name] = &fstest.MapFile{Data: data}
	}
	again, err := LoadDefinition(m, roundtripDir)
	require.NoError(t, err)
	assert.Equal(t, orig.Metadata.Domain, again.Metadata.Domain)
	assert.Len(t, again.Actions, len(orig.Actions))
	assert.Equal(t, orig.Actions[0].Name, again.Actions[0].Name)

	origDigest, err := DefinitionDigest(orig)
	require.NoError(t, err)
	againDigest, err := DefinitionDigest(again)
	require.NoError(t, err)
	assert.Equal(t, origDigest, againDigest)
}
