package mcp

// Integration tests for the entity-mutation MCP tools (create_entity,
// update_entity_attributes), focused on the capability gap that stranded
// session 23da10db: entities mutated via MCP must derive/regenerate checks the
// same way the HTTP create/patch paths do. Guarded by OIKOS_TEST_DATABASE_URL
// (see internal/db/integration_test.go); run via `make test-db`.

import (
	"time"
	"context"
	"encoding/json"
	"fmt"
	"math/rand"
	"os"
	"strings"
	"testing"

	"github.com/dtoro/oikos/internal/core/app"
	"github.com/dtoro/oikos/internal/adapters/postgres"
	"github.com/google/uuid"
	"github.com/jackc/pgx/v5"
	"github.com/modelcontextprotocol/go-sdk/mcp"
)

// newTestPool mirrors internal/httpapi/api_test.go: a throwaway database,
// migrated and seeded with ontology/inventory/policy so create_entity's type
// validation and checkdefaults derivation have a real type tree to work
// against.
func newTestPool(t *testing.T) *db.Pool {
	t.Helper()
	baseURL := os.Getenv("OIKOS_TEST_DATABASE_URL")
	if baseURL == "" {
		t.Skip("OIKOS_TEST_DATABASE_URL not set — skipping integration test")
	}
	ctx := context.Background()

	admin, err := pgx.Connect(ctx, baseURL)
	if err != nil {
		t.Fatalf("connect admin: %v", err)
	}
	dbName := fmt.Sprintf("oikos_mcp_test_%08x", rand.Int63())
	if _, err := admin.Exec(ctx, "CREATE DATABASE "+dbName); err != nil {
		admin.Close(ctx)
		t.Fatalf("create test db: %v", err)
	}
	admin.Close(ctx)

	qi := strings.Index(baseURL, "?")
	base, params := baseURL, ""
	if qi >= 0 {
		base, params = baseURL[:qi], baseURL[qi:]
	}
	testURL := base[:strings.LastIndex(base, "/")+1] + dbName + params

	pool, err := db.New(ctx, testURL)
	if err != nil {
		t.Fatalf("connect test db: %v", err)
	}
	t.Cleanup(func() {
		pool.Close()
		if admin, e := pgx.Connect(ctx, baseURL); e == nil {
			admin.Exec(ctx, "DROP DATABASE IF EXISTS "+dbName+" WITH (FORCE)")
			admin.Close(ctx)
		}
	})

	if err := pool.Migrate(ctx); err != nil {
		t.Fatalf("migrate: %v", err)
	}
	for _, f := range []string{"ontology.yaml", "inventory.yaml", "policy.yaml"} {
		content, err := os.ReadFile("../../seeds/" + f)
		if err != nil {
			t.Fatalf("read seed %s: %v", f, err)
		}
		name := f
		if err := pool.SeedIngest(ctx, name, content,
			func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
				var err error
				switch name {
				case "ontology.yaml":
					_, err = db.IngestOntologySeed(ctx, tx, data)
				case "inventory.yaml":
					_, err = db.IngestInventorySeed(ctx, tx, data)
				case "policy.yaml":
					_, err = db.IngestPolicySeed(ctx, tx, data)
				}
				return err
			}); err != nil {
			t.Fatalf("ingest %s: %v", f, err)
		}
	}
	return pool
}

// callTool invokes a registered tool's handler in-process and returns its
// concatenated text result.
func callTool(t *testing.T, pool *db.Pool, name string, args map[string]any) string {
	t.Helper()
	var handler toolHandler
	entities := app.NewEntityService(db.NewEntityRepo(pool), db.NewOntologyRepo(pool, time.Minute))
	for _, r := range allTools(pool, uuid.Nil, nil, entities, nil) {
		if r.tool.Name == name {
			handler = r.handler
			break
		}
	}
	if handler == nil {
		t.Fatalf("tool %q not registered", name)
	}
	argsJSON, _ := json.Marshal(args)
	res, err := handler(context.Background(), &mcp.CallToolRequest{Params: &mcp.CallToolParamsRaw{
		Name:      name,
		Arguments: argsJSON,
	}})
	if err != nil {
		t.Fatalf("tool %s returned error: %v", name, err)
	}
	var sb strings.Builder
	for _, c := range res.Content {
		if tc, ok := c.(*mcp.TextContent); ok {
			sb.WriteString(tc.Text)
		}
	}
	return sb.String()
}

// checkCountFor returns the number of derived check_defs targeting slug.
func checkCountFor(t *testing.T, pool *db.Pool, slug string) int {
	t.Helper()
	var n int
	err := pool.QueryRow(context.Background(),
		`SELECT count(*) FROM check_defs cd
		 JOIN entities e ON e.id = cd.target_id
		 WHERE e.slug = $1`, slug).Scan(&n)
	if err != nil {
		t.Fatalf("count check_defs for %s: %v", slug, err)
	}
	return n
}

// TestCreateEntity_DerivesChecks proves create_entity inserts an entity AND
// derives its default checks in one call (the HTTP create path did this; the
// MCP path previously could not create at all).
func TestCreateEntity_DerivesChecks(t *testing.T) {
	pool := newTestPool(t)
	slug := "service:mcp-create-test"

	out := callTool(t, pool, "create_entity", map[string]any{
		"type": "service",
		"slug": slug,
		"name": "mcp-create-test",
		"attributes": `{"url":"https://mcp-create-test.example"}`,
	})
	if !strings.Contains(out, "Created "+slug) {
		t.Fatalf("create_entity result = %q, want Created %s", out, slug)
	}
	if !strings.Contains(out, "Derived") {
		t.Errorf("create_entity result = %q, want a Derived check summary", out)
	}
	if got := checkCountFor(t, pool, slug); got < 1 {
		t.Errorf("check_defs targeting %s = %d, want >=1 (create did not derive checks)", slug, got)
	}
}

// TestCreateEntity_DuplicateAndInvalid covers the guard rails: a repeat create
// is reported as "already exists" (not an error), and an unknown type is
// rejected with a clear message.
func TestCreateEntity_DuplicateAndInvalid(t *testing.T) {
	pool := newTestPool(t)

	if out := callTool(t, pool, "create_entity", map[string]any{
		"type": "service", "slug": "service:mcp-dup", "name": "mcp-dup",
	}); !strings.Contains(out, "Created service:mcp-dup") {
		t.Fatalf("first create = %q", out)
	}
	if out := callTool(t, pool, "create_entity", map[string]any{
		"type": "service", "slug": "service:mcp-dup", "name": "mcp-dup",
	}); !strings.Contains(out, "already exists") {
		t.Errorf("duplicate create = %q, want 'already exists'", out)
	}
	if out := callTool(t, pool, "create_entity", map[string]any{
		"type": "no-such-type", "slug": "no-such-type:x", "name": "x",
	}); !strings.Contains(out, "not found in ontology") {
		t.Errorf("unknown type = %q, want 'not found in ontology'", out)
	}
}

// TestUpdateEntityAttributes_RegeneratesChecks is the regression guard for the
// haos session: setting an entity's `monitoring` attribute via MCP must
// regenerate checks. Before this fix the MCP update path skipped
// ensureDefaultChecks, so flipping monitoring produced nothing.
func TestUpdateEntityAttributes_RegeneratesChecks(t *testing.T) {
	pool := newTestPool(t)
	slug := "service:mcp-regen-test"

	// Create with monitoring:none — no checks derived.
	if out := callTool(t, pool, "create_entity", map[string]any{
		"type": "service", "slug": slug, "name": "mcp-regen-test",
		"attributes": `{"monitoring":"none","url":"https://mcp-regen.example"}`,
	}); !strings.Contains(out, "Created "+slug) {
		t.Fatalf("create = %q", out)
	}
	if got := checkCountFor(t, pool, slug); got != 0 {
		t.Fatalf("check_defs with monitoring:none = %d, want 0", got)
	}

	// Flip monitoring to [http] via update_entity_attributes — checks must
	// regenerate. This is exactly what failed for service:haos.
	out := callTool(t, pool, "update_entity_attributes", map[string]any{
		"slug":       slug,
		"attributes": `{"monitoring":["http"]}`,
	})
	if !strings.Contains(out, "Updated "+slug) {
		t.Fatalf("update result = %q, want Updated %s", out, slug)
	}
	if !strings.Contains(out, "Derived") {
		t.Errorf("update result = %q, want a Derived check summary (regeneration)", out)
	}
	if got := checkCountFor(t, pool, slug); got < 1 {
		t.Errorf("check_defs after monitoring:[http] = %d, want >=1 (MCP update did not regenerate checks)", got)
	}
}

// TestUpdateEntityAttributes_NotFound keeps the existing error contract.
func TestUpdateEntityAttributes_NotFound(t *testing.T) {
	pool := newTestPool(t)
	out := callTool(t, pool, "update_entity_attributes", map[string]any{
		"slug":       "service:does-not-exist",
		"attributes": `{"x":1}`,
	})
	if !strings.Contains(out, "not found") {
		t.Errorf("update missing entity = %q, want 'not found'", out)
	}
}

// TestFormatCheckResult is a pure unit test for the result-message helper, so
// the formatting contract holds even when the DB is unavailable.
func TestFormatCheckResult(t *testing.T) {
	if got := formatCheckResult(app.DeriveResult{Created: 2}); !strings.Contains(got, "Derived 2 check") {
		t.Errorf("created-only = %q, want Derived 2", got)
	}
	got := formatCheckResult(app.DeriveResult{Created: 1, Skipped: []app.Skip{{Kind: "process", Reason: "no host"}}})
	if !strings.Contains(got, "Derived 1 check") || !strings.Contains(got, "Skipped process") || !strings.Contains(got, "no host") {
		t.Errorf("created+skipped = %q", got)
	}
	if got := formatCheckResult(app.DeriveResult{Undeclared: true}); !strings.Contains(got, "no monitoring") {
		t.Errorf("undeclared = %q, want no-monitoring hint", got)
	}
	if formatCreateResult("a", "b", app.DeriveResult{Created: 0}) != "Created a (b)." {
		t.Error("create result with no checks should have no suffix")
	}
}
