package app

import (
	"context"
	"errors"
	"testing"

	"github.com/dtoro/oikos/internal/core/domain"
	"github.com/dtoro/oikos/internal/core/ports"
	"github.com/dtoro/oikos/internal/core/ports/portstest"
	"github.com/dtoro/oikos/internal/ontology"
)

// fakeOntology is a static OntologyStore over a hand-built tree.
type fakeOntology struct{ tree *ontology.TypeTree }

func (f *fakeOntology) LoadTypeTree(context.Context) (ports.TypeTree, error) {
	return f.tree, nil
}

func testTree() *ontology.TypeTree {
	return &ontology.TypeTree{
		Types: map[string]ontology.TypeInfo{
			"service": {LifecycleID: "infra"},
			"host":    {LifecycleID: "infra"},
			"widget":  {IsAbstract: true},
		},
		Lifecycles: map[string]ontology.LifecycleInfo{
			"infra": {States: map[string]bool{"planned": true, "active": true, "deprecated": true}, DefaultState: "active"},
		},
		RelTypes: map[string]ontology.RelTypeInfo{},
	}
}

func newSvc(t *testing.T) (*EntityService, *portstest.EntityRepo) {
	t.Helper()
	repo := portstest.NewEntityRepo()
	svc := NewEntityService(repo, &fakeOntology{tree: testTree()})
	return svc, repo
}

func TestEntityServiceCreate(t *testing.T) {
	ctx := context.Background()
	svc, repo := newSvc(t)

	e, res, err := svc.Create(ctx, CreateEntityCmd{
		Type: "service", Name: "jellyfin", Attributes: map[string]any{"url": "https://media.example"},
		ActorType: "operator", Actor: "tester", Method: "POST", Path: "/api/v1/entities",
	})
	if err != nil {
		t.Fatalf("create: %v", err)
	}
	if e.Slug != "service:jellyfin" {
		t.Errorf("default slug = %q, want service:jellyfin", e.Slug)
	}
	if e.State != "active" {
		t.Errorf("default state = %q, want active (lifecycle default)", e.State)
	}
	if res.Created != 0 {
		// testTree's service declares no monitoring → nothing derived
		t.Errorf("derived = %d, want 0 (no monitoring spec in fake tree)", res.Created)
	}
	if len(repo.Audits) != 1 || repo.Audits[0].Action != "create" {
		t.Errorf("audit = %+v, want one create entry", repo.Audits)
	}
	if len(repo.Events) != 1 || repo.Events[0].Type != "entity.created" {
		t.Errorf("event = %+v, want entity.created", repo.Events)
	}
}

func TestEntityServiceCreateValidation(t *testing.T) {
	ctx := context.Background()
	svc, _ := newSvc(t)

	if _, _, err := svc.Create(ctx, CreateEntityCmd{Type: "no-such-type", Name: "x"}); !errors.Is(err, domain.ErrNotFound) {
		t.Errorf("unknown type: got %v, want ErrNotFound", err)
	}
	if _, _, err := svc.Create(ctx, CreateEntityCmd{Type: "widget", Name: "x"}); !errors.Is(err, domain.ErrAbstractType) {
		t.Errorf("abstract type: got %v, want ErrAbstractType", err)
	}
	// The stricter (MCP) rule now governs both surfaces: caller-supplied
	// states must be declared in the lifecycle.
	if _, _, err := svc.Create(ctx, CreateEntityCmd{Type: "service", Name: "x", State: "destroyed"}); !errors.Is(err, domain.ErrInvalidTransition) {
		t.Errorf("undeclared state: got %v, want ErrInvalidTransition", err)
	}
}

func TestEntityServiceCreateIdempotency(t *testing.T) {
	ctx := context.Background()
	svc, repo := newSvc(t)

	idem := &ports.Idempotency{
		Actor: "tester", Key: "k1", RequestHash: "abc",
		RenderBody: func(e domain.Entity) []byte { return []byte(`{"slug":"` + e.Slug + `"}`) },
	}
	if _, _, err := svc.Create(ctx, CreateEntityCmd{Type: "host", Name: "h1", Idempotency: idem}); err != nil {
		t.Fatalf("create: %v", err)
	}

	cached, err := repo.GetIdempotent(ctx, "tester", "k1")
	if err != nil {
		t.Fatalf("get idempotent: %v", err)
	}
	if cached.RequestHash != "abc" || cached.ResponseCode != 201 {
		t.Errorf("cached = %+v, want hash abc / code 201", cached)
	}
	if string(cached.ResponseBody) != `{"slug":"host:h1"}` {
		t.Errorf("cached body = %s, want the rendered wire shape", cached.ResponseBody)
	}

	if _, err := repo.GetIdempotent(ctx, "tester", "other"); !errors.Is(err, domain.ErrNotFound) {
		t.Errorf("missing key: got %v, want ErrNotFound", err)
	}
}

func TestEntityServiceUpdate(t *testing.T) {
	ctx := context.Background()
	svc, repo := newSvc(t)

	created, _, err := svc.Create(ctx, CreateEntityCmd{Type: "service", Name: "svc", Attributes: map[string]any{"a": 1}})
	if err != nil {
		t.Fatalf("create: %v", err)
	}

	// Merge semantics: existing keys survive.
	updated, _, err := svc.Update(ctx, UpdateEntityCmd{
		SlugOrID: "service:svc", ExpectedVer: created.Version,
		Attributes: map[string]any{"b": 2}, RederiveChecks: true,
		ActorType: "agent", Actor: "mcp", Method: "TOOL", Path: "update_entity_attributes",
	})
	if err != nil {
		t.Fatalf("update: %v", err)
	}
	if updated.Attributes["a"] != 1 || updated.Attributes["b"] != 2 {
		t.Errorf("merged attrs = %v, want a+b", updated.Attributes)
	}
	if len(repo.Rederived) != 1 || repo.Rederived[0] != created.ID {
		t.Errorf("redrive = %v, want one pass for the entity", repo.Rederived)
	}

	// Optimistic concurrency: stale version refuses.
	if _, _, err := svc.Update(ctx, UpdateEntityCmd{SlugOrID: "service:svc", ExpectedVer: created.Version}); !errors.Is(err, domain.ErrConflict) {
		t.Errorf("stale version: got %v, want ErrConflict", err)
	}

	// Replace semantics: wholesale swap.
	replaced, _, err := svc.Update(ctx, UpdateEntityCmd{
		SlugOrID: "service:svc", Attributes: map[string]any{"only": true}, AttrsReplace: true,
	})
	if err != nil {
		t.Fatalf("replace update: %v", err)
	}
	if len(replaced.Attributes) != 1 || replaced.Attributes["only"] != true {
		t.Errorf("replaced attrs = %v, want only", replaced.Attributes)
	}
}

func TestEntityServiceSetState(t *testing.T) {
	ctx := context.Background()
	svc, repo := newSvc(t)

	if _, _, err := svc.Create(ctx, CreateEntityCmd{Type: "host", Name: "old"}); err != nil {
		t.Fatalf("create: %v", err)
	}

	after, err := svc.SetState(ctx, "host:old", "active", "deprecated", "operator", "t", "PATCH", "/x")
	if err != nil {
		t.Fatalf("setState: %v", err)
	}
	if after.State != "deprecated" {
		t.Errorf("state = %s, want deprecated", after.State)
	}
	if len(repo.Audits) != 2 || repo.Audits[1].Action != "state" {
		t.Errorf("audits = %+v, want a state entry after create", repo.Audits)
	}

	// Stale From refuses (check-then-act).
	if _, err := svc.SetState(ctx, "host:old", "active", "deprecated", "operator", "t", "PATCH", "/x"); !errors.Is(err, domain.ErrConflict) {
		t.Errorf("stale from-state: got %v, want ErrConflict", err)
	}
}
