// Copyright 2025 Emiliano Spinella (eminwux)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0

package cellblueprint_test

import (
	"errors"
	"regexp"
	"strings"
	"testing"

	"github.com/eminwux/kukeon/internal/cellblueprint"
	"github.com/eminwux/kukeon/internal/errdefs"
	v1beta1 "github.com/eminwux/kukeon/pkg/api/model/v1beta1"
)

func strptr(s string) *string { return &s }

// baseDoc returns a minimal runnable blueprint with a single container and one
// parameter substituted into the image tag.
func baseDoc() v1beta1.CellBlueprintDoc {
	return v1beta1.CellBlueprintDoc{
		APIVersion: v1beta1.APIVersionV1Beta1,
		Kind:       v1beta1.KindCellBlueprint,
		Metadata: v1beta1.CellBlueprintMetadata{
			Name:   "web",
			Realm:  "default",
			Space:  "agents",
			Stack:  "claude",
			Labels: map[string]string{"team": "infra"},
		},
		Spec: v1beta1.CellBlueprintSpec{
			Prefix: "web",
			Parameters: []v1beta1.CellBlueprintParameter{
				{Name: "TAG", Default: strptr("latest")},
			},
			Cell: v1beta1.BlueprintCellSpec{
				Containers: []v1beta1.BlueprintContainer{
					{
						ID:         "main",
						Image:      "registry.example.com/web:${TAG}",
						Attachable: true,
					},
				},
			},
		},
	}
}

func TestResolve_DefaultParameter(t *testing.T) {
	out, err := cellblueprint.Resolve(baseDoc(), nil, nil)
	if err != nil {
		t.Fatalf("Resolve: %v", err)
	}
	if got := out.Spec.Cell.Containers[0].Image; got != "registry.example.com/web:latest" {
		t.Fatalf("image = %q, want default-substituted", got)
	}
}

func TestResolve_CliParamWins(t *testing.T) {
	out, err := cellblueprint.Resolve(baseDoc(), map[string]string{"TAG": "v2"}, nil)
	if err != nil {
		t.Fatalf("Resolve: %v", err)
	}
	if got := out.Spec.Cell.Containers[0].Image; got != "registry.example.com/web:v2" {
		t.Fatalf("image = %q, want cli-param override", got)
	}
}

func TestResolve_EnvFallback(t *testing.T) {
	doc := baseDoc()
	doc.Spec.Parameters = []v1beta1.CellBlueprintParameter{{Name: "TAG"}} // no default
	lookup := func(k string) (string, bool) {
		if k == "TAG" {
			return "from-env", true
		}
		return "", false
	}
	out, err := cellblueprint.Resolve(doc, nil, lookup)
	if err != nil {
		t.Fatalf("Resolve: %v", err)
	}
	if got := out.Spec.Cell.Containers[0].Image; got != "registry.example.com/web:from-env" {
		t.Fatalf("image = %q, want env fallback", got)
	}
}

func TestResolve_UndeclaredParamErrors(t *testing.T) {
	_, err := cellblueprint.Resolve(baseDoc(), map[string]string{"NOPE": "x"}, nil)
	if !errors.Is(err, errdefs.ErrBlueprintInvalid) {
		t.Fatalf("err = %v, want ErrBlueprintInvalid for undeclared param", err)
	}
}

func TestResolve_RequiredUnsetErrors(t *testing.T) {
	doc := baseDoc()
	doc.Spec.Parameters = []v1beta1.CellBlueprintParameter{{Name: "TAG", Required: true}}
	_, err := cellblueprint.Resolve(doc, nil, nil)
	if !errors.Is(err, errdefs.ErrBlueprintInvalid) {
		t.Fatalf("err = %v, want ErrBlueprintInvalid for required-unset param", err)
	}
}

// TestResolveConfig_ToleratesUndeclaredKeys is the #1124 regression: the
// machine-generated CellConfig.Values channel carries operator facts the
// blueprint never declares as parameters; ResolveConfig must not reject them
// the way the interactive --param path (Resolve) does.
func TestResolveConfig_ToleratesUndeclaredKeys(t *testing.T) {
	out, err := cellblueprint.ResolveConfig(baseDoc(), map[string]string{
		"TAG":                 "v3",
		"ROLE":                "dev",
		"GIT_ALLOWED_SIGNERS": "/keys/allowed_signers",
		"HARNESS":             "claude",
		"PROJECT":             "kukeon",
	}, nil)
	if err != nil {
		t.Fatalf("ResolveConfig: %v", err)
	}
	// The declared parameter still resolves from the supplied value.
	if got := out.Spec.Cell.Containers[0].Image; got != "registry.example.com/web:v3" {
		t.Fatalf("image = %q, want declared param substituted", got)
	}
}

// TestResolveConfig_SubstitutesUndeclaredBodyRef proves an undeclared key the
// blueprint *body* references (a `${ROLE}` with no spec.parameters[] entry, as
// the team-rendered dev-claude blueprint carries) is substituted rather than
// left literal — dropping it would re-break the artifact a different way.
func TestResolveConfig_SubstitutesUndeclaredBodyRef(t *testing.T) {
	doc := baseDoc()
	doc.Spec.Cell.Containers[0].WorkingDir = "/work/${ROLE}"
	out, err := cellblueprint.ResolveConfig(doc, map[string]string{"ROLE": "dev"}, nil)
	if err != nil {
		t.Fatalf("ResolveConfig: %v", err)
	}
	if got := out.Spec.Cell.Containers[0].WorkingDir; got != "/work/dev" {
		t.Fatalf("workingDir = %q, want undeclared ${ROLE} substituted", got)
	}
}

// TestResolveConfig_StillEnforcesRequired confirms the lenient channel only
// relaxes undeclared-key rejection — a declared-required parameter the values
// map does not supply is still an error (the #1124 "second wall" stays a real
// failure; its agents-repo template fix is out of scope here).
func TestResolveConfig_StillEnforcesRequired(t *testing.T) {
	doc := baseDoc()
	doc.Spec.Parameters = []v1beta1.CellBlueprintParameter{{Name: "TAG", Required: true}}
	_, err := cellblueprint.ResolveConfig(doc, map[string]string{"ROLE": "dev"}, nil)
	if !errors.Is(err, errdefs.ErrBlueprintInvalid) {
		t.Fatalf("err = %v, want ErrBlueprintInvalid for required-unset param", err)
	}
}

var hexSuffixRE = regexp.MustCompile(`^web-[0-9a-f]{6}$`)

func TestMaterialize_GeneratedNameAndScope(t *testing.T) {
	resolved, err := cellblueprint.Resolve(baseDoc(), nil, nil)
	if err != nil {
		t.Fatalf("Resolve: %v", err)
	}
	cell, err := cellblueprint.Materialize(resolved)
	if err != nil {
		t.Fatalf("Materialize: %v", err)
	}
	if !hexSuffixRE.MatchString(cell.Metadata.Name) {
		t.Fatalf("name = %q, want <prefix>-<6hex>", cell.Metadata.Name)
	}
	if cell.Spec.ID != cell.Metadata.Name {
		t.Fatalf("spec.id = %q, want = metadata.name %q", cell.Spec.ID, cell.Metadata.Name)
	}
	if cell.Spec.RealmID != "default" || cell.Spec.SpaceID != "agents" || cell.Spec.StackID != "claude" {
		t.Fatalf("scope = %q/%q/%q, want default/agents/claude", cell.Spec.RealmID, cell.Spec.SpaceID, cell.Spec.StackID)
	}
	if cell.Metadata.Labels[cellblueprint.LabelBlueprint] != "web" {
		t.Fatalf("missing blueprint back-reference label: %v", cell.Metadata.Labels)
	}
	if cell.Metadata.Labels["team"] != "infra" {
		t.Fatalf("metadata labels not carried through: %v", cell.Metadata.Labels)
	}
}

func TestMaterialize_FreshNameEachCall(t *testing.T) {
	resolved, _ := cellblueprint.Resolve(baseDoc(), nil, nil)
	a, _ := cellblueprint.Materialize(resolved)
	b, _ := cellblueprint.Materialize(resolved)
	if a.Metadata.Name == b.Metadata.Name {
		t.Fatalf("two materializations produced the same name %q", a.Metadata.Name)
	}
}

func TestMaterialize_NameOverride(t *testing.T) {
	resolved, _ := cellblueprint.Resolve(baseDoc(), nil, nil)
	cell, err := cellblueprint.MaterializeWithName(resolved, "pinned", nil)
	if err != nil {
		t.Fatalf("MaterializeWithName: %v", err)
	}
	if cell.Metadata.Name != "pinned" || cell.Spec.ID != "pinned" {
		t.Fatalf("override not honored: name=%q id=%q", cell.Metadata.Name, cell.Spec.ID)
	}
}

// TestMaterialize_StampsBlueprintProvenance pins issue #1021: a Blueprint-
// materialized cell carries a Spec.Provenance block with bindingKind=blueprint,
// the Blueprint's scoped name as the binding ref, and the resolved --param map
// recorded verbatim so a later re-resolution does not depend on the transient
// CLI invocation.
func TestMaterialize_StampsBlueprintProvenance(t *testing.T) {
	resolved, err := cellblueprint.Resolve(baseDoc(), map[string]string{"TAG": "v9"}, nil)
	if err != nil {
		t.Fatalf("Resolve: %v", err)
	}
	cell, err := cellblueprint.MaterializeWithName(resolved, "pinned", map[string]string{"TAG": "v9"})
	if err != nil {
		t.Fatalf("MaterializeWithName: %v", err)
	}
	prov := cell.Spec.Provenance
	if prov == nil {
		t.Fatalf("cell.Spec.Provenance = nil; want a blueprint provenance block")
	}
	if prov.BindingKind != v1beta1.BindingKindBlueprint {
		t.Errorf("bindingKind=%q want %q", prov.BindingKind, v1beta1.BindingKindBlueprint)
	}
	wantRef := v1beta1.CellBindingRef{Name: "web", Realm: "default", Space: "agents", Stack: "claude"}
	if prov.BindingRef != wantRef {
		t.Errorf("bindingRef=%+v want %+v", prov.BindingRef, wantRef)
	}
	if got := prov.Params["TAG"]; got != "v9" {
		t.Errorf("params[TAG]=%q want v9", got)
	}
	// Mutating the source param map must not leak into the stamped provenance.
	src := map[string]string{"TAG": "orig"}
	cell2, err := cellblueprint.MaterializeWithName(resolved, "pinned", src)
	if err != nil {
		t.Fatalf("MaterializeWithName: %v", err)
	}
	src["TAG"] = "mutated"
	if got := cell2.Spec.Provenance.Params["TAG"]; got != "orig" {
		t.Errorf("provenance params aliased source map: got %q want orig", got)
	}
}

func TestMaterialize_FilledRepoCarried(t *testing.T) {
	doc := baseDoc()
	doc.Spec.Cell.Containers[0].Repos = []v1beta1.ContainerRepo{
		{Name: "app", Target: "/src", URL: "https://example.com/app.git"},
	}
	resolved, _ := cellblueprint.Resolve(doc, nil, nil)
	cell, err := cellblueprint.Materialize(resolved)
	if err != nil {
		t.Fatalf("Materialize: %v", err)
	}
	if len(cell.Spec.Containers[0].Repos) != 1 {
		t.Fatalf("filled repo dropped: %+v", cell.Spec.Containers[0].Repos)
	}
}

func TestMaterialize_OptionalEmptyRepoDropped(t *testing.T) {
	doc := baseDoc()
	doc.Spec.Cell.Containers[0].Repos = []v1beta1.ContainerRepo{
		{Name: "app", Target: "/src"}, // no url, not required → structural slot, dropped
	}
	resolved, _ := cellblueprint.Resolve(doc, nil, nil)
	cell, err := cellblueprint.Materialize(resolved)
	if err != nil {
		t.Fatalf("Materialize: %v", err)
	}
	if len(cell.Spec.Containers[0].Repos) != 0 {
		t.Fatalf("optional empty repo slot not dropped: %+v", cell.Spec.Containers[0].Repos)
	}
}

func TestMaterialize_RequiredEmptyRepoBlocks(t *testing.T) {
	doc := baseDoc()
	doc.Spec.Cell.Containers[0].Repos = []v1beta1.ContainerRepo{
		{Name: "app", Target: "/src", Required: true}, // no url, required → blocks inline -b
	}
	resolved, _ := cellblueprint.Resolve(doc, nil, nil)
	_, err := cellblueprint.Materialize(resolved)
	if !errors.Is(err, errdefs.ErrBlueprintStructuralSlots) {
		t.Fatalf("err = %v, want ErrBlueprintStructuralSlots", err)
	}
}

func TestMaterialize_RequiredSecretSlotBlocks(t *testing.T) {
	doc := baseDoc()
	doc.Spec.Cell.Containers[0].Secrets = []v1beta1.BlueprintSecretSlot{
		{Name: "token", Mode: v1beta1.BlueprintSecretModeEnv, EnvName: "TOKEN", Required: true},
	}
	resolved, _ := cellblueprint.Resolve(doc, nil, nil)
	_, err := cellblueprint.Materialize(resolved)
	if !errors.Is(err, errdefs.ErrBlueprintStructuralSlots) {
		t.Fatalf("err = %v, want ErrBlueprintStructuralSlots for required secret slot", err)
	}
	if !strings.Contains(err.Error(), "token") {
		t.Fatalf("error should name the offending slot: %v", err)
	}
}

func TestMaterialize_OptionalSecretSlotDropped(t *testing.T) {
	doc := baseDoc()
	doc.Spec.Cell.Containers[0].Secrets = []v1beta1.BlueprintSecretSlot{
		{Name: "token", Mode: v1beta1.BlueprintSecretModeEnv, EnvName: "TOKEN"}, // optional
	}
	resolved, _ := cellblueprint.Resolve(doc, nil, nil)
	cell, err := cellblueprint.Materialize(resolved)
	if err != nil {
		t.Fatalf("Materialize: %v", err)
	}
	// Secret slots never land on the runtime ContainerSpec (no secret field on
	// it yet — Config-side fill lands in #624); the optional slot simply drops.
	if len(cell.Spec.Containers) != 1 {
		t.Fatalf("container count changed: %d", len(cell.Spec.Containers))
	}
}

func TestMaterialize_PrefixFallsBackToName(t *testing.T) {
	doc := baseDoc()
	doc.Spec.Prefix = ""
	doc.Metadata.Name = "fallback"
	resolved, _ := cellblueprint.Resolve(doc, nil, nil)
	cell, err := cellblueprint.Materialize(resolved)
	if err != nil {
		t.Fatalf("Materialize: %v", err)
	}
	if !strings.HasPrefix(cell.Metadata.Name, "fallback-") {
		t.Fatalf("name = %q, want prefix from metadata.name", cell.Metadata.Name)
	}
}
