package write_test

import (
	"bytes"
	"encoding/base64"
	"encoding/json"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"github.com/jmrGrav/mcp-hugo-server-go/internal/config"
	"github.com/jmrGrav/mcp-hugo-server-go/internal/contentmodel"
	"github.com/jmrGrav/mcp-hugo-server-go/internal/tools/admin"
)

// minimalPNG is enough leading bytes for http.DetectContentType to sniff
// image/png; it need not be a structurally complete PNG.
var minimalPNG = []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0, 0, 0, 0, 0}

func b64(data []byte) string { return base64.StdEncoding.EncodeToString(data) }

func writeBundle(t *testing.T, contentRoot, slug string) {
	t.Helper()
	dir := filepath.Join(contentRoot, slug)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(dir, "index.md"), []byte("---\ntitle: Article\n---\nBody.\n"), 0o644); err != nil {
		t.Fatal(err)
	}
}

func TestUploadPageAssetSuccess(t *testing.T) {
	contentRoot := t.TempDir()
	writeBundle(t, contentRoot, "posts/article")
	session, _, done := newTestServer(t, contentRoot)
	defer done()

	res := callTool(t, session, "upload_page_asset", map[string]any{
		"slug":           "posts/article",
		"filename":       "cover.png",
		"content_base64": b64(minimalPNG),
	})
	if res.IsError {
		raw, _ := json.Marshal(res.Content)
		t.Fatalf("upload_page_asset returned error: %s", raw)
	}
	out := decodeWriteContent(t, res)
	dataEnvelope := decodeWriteData(t, res)
	assertRootOnlyField(t, out, dataEnvelope, "rate_limit_remaining")
	if got := dataEnvelope["status"]; got != "created" {
		t.Fatalf("upload_page_asset data.status = %v, want created — upload_page_asset only ever creates (an existing filename fails with already_exists)", got)
	}
	if dataEnvelope["source_key"] != "posts/article" {
		t.Fatalf("upload_page_asset data.source_key = %v, want posts/article", dataEnvelope["source_key"])
	}
	if dataEnvelope["content_type"] != "image/png" {
		t.Fatalf("upload_page_asset data.content_type = %v, want image/png", dataEnvelope["content_type"])
	}
	if size, _ := dataEnvelope["size_bytes"].(float64); size != float64(len(minimalPNG)) {
		t.Fatalf("upload_page_asset data.size_bytes = %v, want %d", dataEnvelope["size_bytes"], len(minimalPNG))
	}
	data, err := os.ReadFile(filepath.Join(contentRoot, "posts", "article", "cover.png"))
	if err != nil {
		t.Fatalf("uploaded file not found: %v", err)
	}
	if string(data) != string(minimalPNG) {
		t.Fatal("uploaded file content mismatch")
	}
}

// TestUploadPageAssetAcceptsCleanSVG is a regression test for #571: a
// well-formed SVG using only allowlisted elements/attributes is now
// accepted, unlike the outright rejection SVG got before this issue.
func TestUploadPageAssetAcceptsCleanSVG(t *testing.T) {
	contentRoot := t.TempDir()
	writeBundle(t, contentRoot, "posts/article")
	session, _, done := newTestServer(t, contentRoot)
	defer done()

	svg := `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path d="M0 0h24v24H0z" fill="#333"/></svg>`
	res := callTool(t, session, "upload_page_asset", map[string]any{
		"slug":           "posts/article",
		"filename":       "icon.svg",
		"content_base64": b64([]byte(svg)),
	})
	if res.IsError {
		raw, _ := json.Marshal(res.Content)
		t.Fatalf("upload_page_asset clean svg failed: %s", raw)
	}
	data, err := os.ReadFile(filepath.Join(contentRoot, "posts", "article", "icon.svg"))
	if err != nil {
		t.Fatalf("uploaded file not found: %v", err)
	}
	if string(data) != svg {
		t.Fatal("uploaded SVG content mismatch")
	}
}

// TestUploadPageAssetRejectsSVGWithScript, ...WithEventHandler, and
// ...WithExternalReference are regression tests for #571: an SVG carrying
// any of the confirmed injection vectors is rejected outright with
// invalid_params, never silently stripped and written as a modified file.
func TestUploadPageAssetRejectsSVGWithScript(t *testing.T) {
	contentRoot := t.TempDir()
	writeBundle(t, contentRoot, "posts/article")
	session, _, done := newTestServer(t, contentRoot)
	defer done()

	res := callTool(t, session, "upload_page_asset", map[string]any{
		"slug":           "posts/article",
		"filename":       "evil.svg",
		"content_base64": b64([]byte(`<svg xmlns="http://www.w3.org/2000/svg"><script>alert(document.domain)</script></svg>`)),
	})
	if !res.IsError {
		t.Fatal("upload_page_asset: want error for SVG with <script>, got success")
	}
	raw, _ := json.Marshal(res.Content)
	if !strings.Contains(string(raw), "invalid_params") {
		t.Fatalf("upload_page_asset svg-script error = %s, want invalid_params", raw)
	}
}

func TestUploadPageAssetRejectsSVGWithEventHandler(t *testing.T) {
	contentRoot := t.TempDir()
	writeBundle(t, contentRoot, "posts/article")
	session, _, done := newTestServer(t, contentRoot)
	defer done()

	res := callTool(t, session, "upload_page_asset", map[string]any{
		"slug":           "posts/article",
		"filename":       "evil.svg",
		"content_base64": b64([]byte(`<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"></svg>`)),
	})
	if !res.IsError {
		t.Fatal("upload_page_asset: want error for SVG with onload=, got success")
	}
	raw, _ := json.Marshal(res.Content)
	if !strings.Contains(string(raw), "invalid_params") {
		t.Fatalf("upload_page_asset svg-onload error = %s, want invalid_params", raw)
	}
}

func TestUploadPageAssetRejectsSVGWithExternalReference(t *testing.T) {
	contentRoot := t.TempDir()
	writeBundle(t, contentRoot, "posts/article")
	session, _, done := newTestServer(t, contentRoot)
	defer done()

	svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><use xlink:href="http://evil.example/x.svg#a"/></svg>`
	res := callTool(t, session, "upload_page_asset", map[string]any{
		"slug":           "posts/article",
		"filename":       "evil.svg",
		"content_base64": b64([]byte(svg)),
	})
	if !res.IsError {
		t.Fatal("upload_page_asset: want error for SVG with external xlink:href, got success")
	}
	raw, _ := json.Marshal(res.Content)
	if !strings.Contains(string(raw), "invalid_params") {
		t.Fatalf("upload_page_asset svg-external-href error = %s, want invalid_params", raw)
	}
}

func TestUploadPageAssetRejectsMimeMismatch(t *testing.T) {
	contentRoot := t.TempDir()
	writeBundle(t, contentRoot, "posts/article")
	session, _, done := newTestServer(t, contentRoot)
	defer done()

	res := callTool(t, session, "upload_page_asset", map[string]any{
		"slug":           "posts/article",
		"filename":       "cover.png",
		"content_base64": b64([]byte("this is plain text, not a png")),
	})
	if !res.IsError {
		t.Fatal("upload_page_asset: want error for mime mismatch, got success")
	}
	raw, _ := json.Marshal(res.Content)
	if !strings.Contains(string(raw), "invalid_params") {
		t.Fatalf("upload_page_asset mime-mismatch error = %s, want invalid_params", raw)
	}
	if _, err := os.Stat(filepath.Join(contentRoot, "posts", "article", "cover.png")); !os.IsNotExist(err) {
		t.Fatal("upload_page_asset must not write a file when sniffing rejects the content")
	}
}

func TestUploadPageAssetRejectsJPEGMimeMismatch(t *testing.T) {
	contentRoot := t.TempDir()
	writeBundle(t, contentRoot, "posts/article")
	session, _, done := newTestServer(t, contentRoot)
	defer done()

	res := callTool(t, session, "upload_page_asset", map[string]any{
		"slug":           "posts/article",
		"filename":       "cover.jpg",
		"content_base64": b64([]byte("this is plain text, not a jpeg")),
	})
	if !res.IsError {
		t.Fatal("upload_page_asset: want error for jpeg mime mismatch, got success")
	}
	assertSingleStructuredWriteErrorField(t, res, "invalid_params", "filename/content_base64")
	errData := decodeWriteErrorData(t, res)
	if got := errData["slug"]; got != "posts/article" {
		t.Fatalf("data.slug = %v, want posts/article", got)
	}
	if got := errData["filename"]; got != "cover.jpg" {
		t.Fatalf("data.filename = %v, want cover.jpg", got)
	}
	if _, err := os.Stat(filepath.Join(contentRoot, "posts", "article", "cover.jpg")); !os.IsNotExist(err) {
		t.Fatal("upload_page_asset must not write a file when jpeg sniffing rejects the content")
	}
}

func TestUploadPageAssetRejectsHostileFilenameCorpus(t *testing.T) {
	cases := []struct {
		name     string
		filename string
	}{
		{name: "raw traversal", filename: "../cover.png"},
		{name: "encoded traversal", filename: "%2e%2e.png"},
		{name: "double encoded traversal", filename: "%252e%252e.png"},
		{name: "backslash traversal", filename: `..\\cover.png`},
		{name: "absolute path", filename: "/tmp/cover.png"},
		{name: "unicode confusable slash", filename: "cover∕evil.png"},
		{name: "control character", filename: "co\x07ver.png"},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			contentRoot := t.TempDir()
			writeBundle(t, contentRoot, "posts/article")
			session, _, done := newTestServer(t, contentRoot)
			defer done()

			res := callTool(t, session, "upload_page_asset", map[string]any{
				"slug":           "posts/article",
				"filename":       tc.filename,
				"content_base64": b64(minimalPNG),
			})
			if !res.IsError {
				t.Fatalf("upload_page_asset(%q): want invalid_params, got success", tc.filename)
			}
			assertSingleStructuredWriteErrorField(t, res, "invalid_params", "filename")

			if _, err := os.Stat(filepath.Join(contentRoot, "posts", "article", "cover.png")); !os.IsNotExist(err) {
				t.Fatalf("upload_page_asset(%q) must not create cover.png on rejection", tc.filename)
			}
		})
	}
}

func TestUploadPageAssetRejectsFakeJPEG(t *testing.T) {
	contentRoot := t.TempDir()
	writeBundle(t, contentRoot, "posts/article")
	session, _, done := newTestServer(t, contentRoot)
	defer done()

	res := callTool(t, session, "upload_page_asset", map[string]any{
		"slug":           "posts/article",
		"filename":       "cover.jpg",
		"content_base64": b64([]byte("this is plain text, not a jpeg")),
	})
	if !res.IsError {
		t.Fatal("upload_page_asset: want error for fake jpeg, got success")
	}
	raw := marshalContent(t, res)
	if !strings.Contains(raw, "invalid_params") || !strings.Contains(raw, ".jpg") {
		t.Fatalf("upload_page_asset fake-jpeg error = %s, want invalid_params mentioning .jpg", raw)
	}
	if _, err := os.Stat(filepath.Join(contentRoot, "posts", "article", "cover.jpg")); !os.IsNotExist(err) {
		t.Fatal("upload_page_asset must not write a file when sniffing rejects fake jpeg content")
	}
}

func TestUploadPageAssetAcceptsUppercaseExtension(t *testing.T) {
	contentRoot := t.TempDir()
	writeBundle(t, contentRoot, "posts/article")
	session, _, done := newTestServer(t, contentRoot)
	defer done()

	res := callTool(t, session, "upload_page_asset", map[string]any{
		"slug":           "posts/article",
		"filename":       "COVER.PNG",
		"content_base64": b64(minimalPNG),
	})
	if res.IsError {
		t.Fatalf("upload_page_asset uppercase extension failed: %s", marshalContent(t, res))
	}
	dataEnvelope := decodeWriteData(t, res)
	if got := dataEnvelope["filename"]; got != "COVER.PNG" {
		t.Fatalf("upload_page_asset data.filename = %v, want COVER.PNG", got)
	}
	if got := dataEnvelope["content_type"]; got != "image/png" {
		t.Fatalf("upload_page_asset data.content_type = %v, want image/png", got)
	}
	if _, err := os.Stat(filepath.Join(contentRoot, "posts", "article", "COVER.PNG")); err != nil {
		t.Fatalf("expected uppercase filename to be written unchanged: %v", err)
	}
}

func TestUploadPageAssetRejectsOversizedDecodedContent(t *testing.T) {
	contentRoot := t.TempDir()
	writeBundle(t, contentRoot, "posts/article")
	session, _, done := newTestServer(t, contentRoot)
	defer done()

	tooLarge := bytes.Repeat([]byte("a"), 10<<20+1)
	res := callTool(t, session, "upload_page_asset", map[string]any{
		"slug":           "posts/article",
		"filename":       "too-large.png",
		"content_base64": b64(tooLarge),
	})
	if !res.IsError {
		t.Fatal("upload_page_asset: want error for decoded payload > 10 MiB, got success")
	}
	raw := marshalContent(t, res)
	if !strings.Contains(raw, "invalid_params") || !strings.Contains(raw, "decoded asset content exceeds") {
		t.Fatalf("upload_page_asset oversize error = %s, want decoded-size invalid_params", raw)
	}
	if _, err := os.Stat(filepath.Join(contentRoot, "posts", "article", "too-large.png")); !os.IsNotExist(err) {
		t.Fatal("upload_page_asset must not leave a partial file after rejecting oversized content")
	}
}

func TestUploadPageAssetRejectsExistingFilename(t *testing.T) {
	contentRoot := t.TempDir()
	writeBundle(t, contentRoot, "posts/article")
	session, _, done := newTestServer(t, contentRoot)
	defer done()

	args := map[string]any{
		"slug":           "posts/article",
		"filename":       "cover.png",
		"content_base64": b64(minimalPNG),
	}
	if res := callTool(t, session, "upload_page_asset", args); res.IsError {
		raw, _ := json.Marshal(res.Content)
		t.Fatalf("first upload_page_asset returned error: %s", raw)
	}

	res := callTool(t, session, "upload_page_asset", args)
	if !res.IsError {
		t.Fatal("upload_page_asset: want already_exists on second write to same filename, got success")
	}
	raw, _ := json.Marshal(res.Content)
	if !strings.Contains(string(raw), "already_exists") {
		t.Fatalf("upload_page_asset duplicate-filename error = %s, want already_exists", raw)
	}
}

func TestUploadPageAssetNotABundle(t *testing.T) {
	contentRoot := t.TempDir()
	if err := os.MkdirAll(filepath.Join(contentRoot, "pages"), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(contentRoot, "pages", "about.md"), []byte("---\ntitle: About\n---\nBody.\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	session, _, done := newTestServer(t, contentRoot)
	defer done()

	res := callTool(t, session, "upload_page_asset", map[string]any{
		"slug":           "pages/about",
		"filename":       "cover.png",
		"content_base64": b64(minimalPNG),
	})
	if !res.IsError {
		t.Fatal("upload_page_asset: want not_a_bundle for single-file page, got success")
	}
	raw, _ := json.Marshal(res.Content)
	if !strings.Contains(string(raw), "not_a_bundle") {
		t.Fatalf("upload_page_asset error = %s, want not_a_bundle", raw)
	}
}

func TestUploadPageAssetDryRunDoesNotWrite(t *testing.T) {
	contentRoot := t.TempDir()
	writeBundle(t, contentRoot, "posts/article")
	session, _, done := newTestServer(t, contentRoot)
	defer done()

	res := callTool(t, session, "upload_page_asset", map[string]any{
		"slug":           "posts/article",
		"filename":       "cover.png",
		"content_base64": b64(minimalPNG),
		"dry_run":        true,
	})
	if res.IsError {
		raw, _ := json.Marshal(res.Content)
		t.Fatalf("upload_page_asset dry_run returned error: %s", raw)
	}
	dataEnvelope := decodeWriteData(t, res)
	if got := dataEnvelope["status"]; got != "would_create" {
		t.Fatalf("upload_page_asset dry_run data.status = %v, want would_create", got)
	}
	if dryRun, _ := dataEnvelope["dry_run"].(bool); !dryRun {
		t.Fatalf("upload_page_asset dry_run response data.dry_run = %v, want true", dataEnvelope["dry_run"])
	}
	if _, err := os.Stat(filepath.Join(contentRoot, "posts", "article", "cover.png")); !os.IsNotExist(err) {
		t.Fatal("upload_page_asset dry_run must not write a file")
	}
}

// TestUploadPageAssetDryRunDoesNotConsumeQuota is #588's upload_page_asset
// counterpart to TestDeletePageAssetDryRunDoesNotConsumeDestructiveQuota — an
// audit found upload_page_asset called limiter.Allow() before checking
// in.DryRun (the shared create/update quota, not the destructive one).
func TestUploadPageAssetDryRunDoesNotConsumeQuota(t *testing.T) {
	contentRoot := t.TempDir()
	writeBundle(t, contentRoot, "posts/article")
	rl := config.Default().RateLimit
	rl.CreateUpdatePerMin = 2
	session, _, done := newTestServer(t, contentRoot, testServerOpts{RateLimit: &rl})
	defer done()

	var remaining []float64
	for i := 0; i < 5; i++ {
		res := callTool(t, session, "upload_page_asset", map[string]any{
			"slug":           "posts/article",
			"filename":       "quota-cover.png",
			"content_base64": b64(minimalPNG),
			"dry_run":        true,
		})
		if res.IsError {
			raw, _ := json.Marshal(res.Content)
			t.Fatalf("upload_page_asset dry_run %d returned error: %s", i, raw)
		}
		root := decodeWriteContent(t, res)
		rem, ok := root["rate_limit_remaining"].(float64)
		if !ok {
			t.Fatalf("upload_page_asset dry_run %d: rate_limit_remaining missing", i)
		}
		remaining = append(remaining, rem)
	}
	for i := 1; i < len(remaining); i++ {
		if remaining[i] != remaining[0] {
			t.Fatalf("upload_page_asset dry_run consumed quota: rate_limit_remaining sequence = %v, want constant at %v (dry_run must never call limiter.Allow())", remaining, remaining[0])
		}
	}
	if remaining[0] != float64(rl.CreateUpdatePerMin) {
		t.Fatalf("upload_page_asset dry_run rate_limit_remaining = %v, want full fresh budget %d", remaining[0], rl.CreateUpdatePerMin)
	}
}

// TestUploadPageAssetNonDryRunAlreadyExistsStillConsumesQuota is a
// discriminating regression test for #588's review: the dry-run fix must be
// a surgical guard (skip Allow() only when in.DryRun), not a wholesale move
// of Allow() past the whole dry-run block — a move would have also skipped
// Allow() for non-dry-run failures like already_exists, silently changing
// existing, intentional quota-consumption behavior with no other test
// catching it.
func TestUploadPageAssetNonDryRunAlreadyExistsStillConsumesQuota(t *testing.T) {
	contentRoot := t.TempDir()
	writeBundle(t, contentRoot, "posts/article")
	rl := config.Default().RateLimit
	rl.CreateUpdatePerMin = 5
	session, _, done := newTestServer(t, contentRoot, testServerOpts{RateLimit: &rl})
	defer done()

	if res := callTool(t, session, "upload_page_asset", map[string]any{
		"slug":           "posts/article",
		"filename":       "existing.png",
		"content_base64": b64(minimalPNG),
	}); res.IsError {
		raw, _ := json.Marshal(res.Content)
		t.Fatalf("first upload_page_asset returned error: %s", raw)
	}

	// Second (non-dry-run) call to the same filename fails already_exists —
	// this must still consume 1 token, exactly as it did before #588.
	res := callTool(t, session, "upload_page_asset", map[string]any{
		"slug":           "posts/article",
		"filename":       "existing.png",
		"content_base64": b64(minimalPNG),
	})
	if !res.IsError {
		t.Fatal("second upload_page_asset to the same filename should fail already_exists")
	}
	raw := marshalContent(t, res)
	if !strings.Contains(raw, "already_exists") {
		t.Fatalf("upload_page_asset second call error = %s, want already_exists", raw)
	}
	data := decodeWriteErrorData(t, res)
	wantRemaining := float64(rl.CreateUpdatePerMin - 2) // first (successful) upload + second (already_exists) each consume one token
	if got := data["rate_limit_remaining"]; got != wantRemaining {
		t.Fatalf("upload_page_asset already_exists data.rate_limit_remaining = %v, want %v", got, wantRemaining)
	}
}

func TestUploadPageAssetDuplicateContentIsAdvisoryOnly(t *testing.T) {
	contentRoot := t.TempDir()
	writeBundle(t, contentRoot, "posts/article")
	session, _, done := newTestServer(t, contentRoot)
	defer done()

	if res := callTool(t, session, "upload_page_asset", map[string]any{
		"slug":           "posts/article",
		"filename":       "cover.png",
		"content_base64": b64(minimalPNG),
	}); res.IsError {
		raw, _ := json.Marshal(res.Content)
		t.Fatalf("first upload_page_asset returned error: %s", raw)
	}

	res := callTool(t, session, "upload_page_asset", map[string]any{
		"slug":           "posts/article",
		"filename":       "cover-copy.png",
		"content_base64": b64(minimalPNG),
	})
	if res.IsError {
		raw, _ := json.Marshal(res.Content)
		t.Fatalf("second upload_page_asset (identical content, new filename) returned error: %s", raw)
	}
	dataEnvelope := decodeWriteData(t, res)
	if dataEnvelope["duplicate_of"] != "cover.png" {
		t.Fatalf("upload_page_asset data.duplicate_of = %v, want cover.png", dataEnvelope["duplicate_of"])
	}
	// The write must still happen under the requested filename — duplicate
	// detection is advisory, not a substitute for the caller's explicit intent.
	if _, err := os.Stat(filepath.Join(contentRoot, "posts", "article", "cover-copy.png")); err != nil {
		t.Fatalf("upload_page_asset must still write cover-copy.png despite duplicate content: %v", err)
	}
}

func TestUploadPageAssetTrimsFilenameConsistently(t *testing.T) {
	// Regression: validateAssetFilename validated a trimmed copy of the
	// filename but the handler used to write/echo the raw, untrimmed input,
	// so "cover.png\n" passed the safe-charset regex on its trimmed form
	// while still writing a file literally named "cover.png\n" on disk.
	contentRoot := t.TempDir()
	writeBundle(t, contentRoot, "posts/article")
	session, _, done := newTestServer(t, contentRoot)
	defer done()

	res := callTool(t, session, "upload_page_asset", map[string]any{
		"slug":           "posts/article",
		"filename":       "cover.png\n",
		"content_base64": b64(minimalPNG),
	})
	if res.IsError {
		raw, _ := json.Marshal(res.Content)
		t.Fatalf("upload_page_asset with whitespace-padded filename returned error: %s", raw)
	}
	dataEnvelope := decodeWriteData(t, res)
	if dataEnvelope["filename"] != "cover.png" {
		t.Fatalf("upload_page_asset data.filename = %v, want trimmed \"cover.png\"", dataEnvelope["filename"])
	}
	if _, err := os.Stat(filepath.Join(contentRoot, "posts", "article", "cover.png")); err != nil {
		t.Fatalf("expected file written under trimmed name %q: %v", "cover.png", err)
	}
	entries, err := os.ReadDir(filepath.Join(contentRoot, "posts", "article"))
	if err != nil {
		t.Fatal(err)
	}
	for _, e := range entries {
		if e.Name() != "index.md" && e.Name() != "cover.png" {
			t.Fatalf("unexpected file written with untrimmed name: %q", e.Name())
		}
	}
}

func TestUploadPageAssetRejectsPathTraversalFilename(t *testing.T) {
	contentRoot := t.TempDir()
	writeBundle(t, contentRoot, "posts/article")
	session, _, done := newTestServer(t, contentRoot)
	defer done()

	for _, filename := range []string{"../evil.png", "sub/dir.png", ".hidden.png"} {
		res := callTool(t, session, "upload_page_asset", map[string]any{
			"slug":           "posts/article",
			"filename":       filename,
			"content_base64": b64(minimalPNG),
		})
		if !res.IsError {
			t.Fatalf("upload_page_asset: want error for unsafe filename %q, got success", filename)
		}
		raw, _ := json.Marshal(res.Content)
		if !strings.Contains(string(raw), "invalid_params") {
			t.Fatalf("upload_page_asset filename %q error = %s, want invalid_params", filename, raw)
		}
	}
}

func TestDeletePageAssetSuccess(t *testing.T) {
	contentRoot := t.TempDir()
	writeBundle(t, contentRoot, "posts/article")
	session, _, done := newTestServer(t, contentRoot)
	defer done()

	upload := callTool(t, session, "upload_page_asset", map[string]any{
		"slug":           "posts/article",
		"filename":       "cover.png",
		"content_base64": b64(minimalPNG),
	})
	if upload.IsError {
		raw, _ := json.Marshal(upload.Content)
		t.Fatalf("upload_page_asset returned error: %s", raw)
	}
	sha256 := decodeWriteData(t, upload)["sha256"].(string)

	res := callTool(t, session, "delete_page_asset", map[string]any{
		"slug":            "posts/article",
		"filename":        "cover.png",
		"expected_sha256": sha256,
	})
	if res.IsError {
		raw, _ := json.Marshal(res.Content)
		t.Fatalf("delete_page_asset returned error: %s", raw)
	}
	dataEnvelope := decodeWriteData(t, res)
	if dataEnvelope["sha256"] != sha256 {
		t.Fatalf("delete_page_asset data.sha256 = %v, want %v", dataEnvelope["sha256"], sha256)
	}
	if dataEnvelope["source_key"] != "posts/article" {
		t.Fatalf("delete_page_asset data.source_key = %v, want posts/article", dataEnvelope["source_key"])
	}
	if _, err := os.Stat(filepath.Join(contentRoot, "posts", "article", "cover.png")); !os.IsNotExist(err) {
		t.Fatal("delete_page_asset must remove the file")
	}
}

func TestDeletePageAssetHashMismatchFailsWithRevisionConflict(t *testing.T) {
	contentRoot := t.TempDir()
	writeBundle(t, contentRoot, "posts/article")
	session, _, done := newTestServer(t, contentRoot)
	defer done()

	if res := callTool(t, session, "upload_page_asset", map[string]any{
		"slug":           "posts/article",
		"filename":       "cover.png",
		"content_base64": b64(minimalPNG),
	}); res.IsError {
		raw, _ := json.Marshal(res.Content)
		t.Fatalf("upload_page_asset returned error: %s", raw)
	}

	res := callTool(t, session, "delete_page_asset", map[string]any{
		"slug":            "posts/article",
		"filename":        "cover.png",
		"expected_sha256": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
	})
	if !res.IsError {
		t.Fatal("delete_page_asset: want error for hash mismatch, got success")
	}
	raw, _ := json.Marshal(res.Content)
	if !strings.Contains(string(raw), "revision_conflict") {
		t.Fatalf("delete_page_asset hash-mismatch error = %s, want revision_conflict", raw)
	}
	env := decodeWriteContent(t, res)
	if code := firstErrorCode(t, env); code != "revision_conflict" {
		t.Fatalf("delete_page_asset hash-mismatch code = %q, want revision_conflict", code)
	}
	errors, ok := env["errors"].([]any)
	if !ok || len(errors) == 0 {
		t.Fatalf("delete_page_asset hash-mismatch errors = %#v, want non-empty []any", env["errors"])
	}
	first, ok := errors[0].(map[string]any)
	if !ok {
		t.Fatalf("delete_page_asset hash-mismatch errors[0] type = %T, want map[string]any", errors[0])
	}
	if field, _ := first["field"].(string); field != "expected_sha256" {
		t.Fatalf("delete_page_asset hash-mismatch field = %q, want expected_sha256", field)
	}
	if _, err := os.Stat(filepath.Join(contentRoot, "posts", "article", "cover.png")); err != nil {
		t.Fatalf("delete_page_asset must not delete the file on a hash mismatch: %v", err)
	}
}

func TestDeletePageAssetRequiresExpectedHashOrRevision(t *testing.T) {
	contentRoot := t.TempDir()
	writeBundle(t, contentRoot, "posts/article")
	session, _, done := newTestServer(t, contentRoot)
	defer done()

	if res := callTool(t, session, "upload_page_asset", map[string]any{
		"slug":           "posts/article",
		"filename":       "cover.png",
		"content_base64": b64(minimalPNG),
	}); res.IsError {
		raw, _ := json.Marshal(res.Content)
		t.Fatalf("upload_page_asset returned error: %s", raw)
	}

	res := callTool(t, session, "delete_page_asset", map[string]any{
		"slug":     "posts/article",
		"filename": "cover.png",
	})
	if !res.IsError {
		t.Fatal("delete_page_asset: want error when neither expected_sha256 nor expected_revision is given, got success")
	}
	raw, _ := json.Marshal(res.Content)
	if !strings.Contains(string(raw), "invalid_params") {
		t.Fatalf("delete_page_asset missing-guard error = %s, want invalid_params", raw)
	}
}

func TestDeletePageAssetReferencedGuardBlocksUnlessForced(t *testing.T) {
	contentRoot := t.TempDir()
	dir := filepath.Join(contentRoot, "posts", "article")
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatal(err)
	}
	// Body references cover.png directly — the guard must detect this.
	if err := os.WriteFile(filepath.Join(dir, "index.md"), []byte("---\ntitle: Article\n---\n![cover](cover.png)\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	session, _, done := newTestServer(t, contentRoot)
	defer done()

	upload := callTool(t, session, "upload_page_asset", map[string]any{
		"slug":           "posts/article",
		"filename":       "cover.png",
		"content_base64": b64(minimalPNG),
	})
	if upload.IsError {
		raw, _ := json.Marshal(upload.Content)
		t.Fatalf("upload_page_asset returned error: %s", raw)
	}
	sha256 := decodeWriteData(t, upload)["sha256"].(string)

	blocked := callTool(t, session, "delete_page_asset", map[string]any{
		"slug":            "posts/article",
		"filename":        "cover.png",
		"expected_sha256": sha256,
	})
	if !blocked.IsError {
		t.Fatal("delete_page_asset: want asset_referenced error, got success")
	}
	raw, _ := json.Marshal(blocked.Content)
	if !strings.Contains(string(raw), "asset_referenced") {
		t.Fatalf("delete_page_asset referenced-guard error = %s, want asset_referenced", raw)
	}
	if _, err := os.Stat(filepath.Join(dir, "cover.png")); err != nil {
		t.Fatalf("delete_page_asset must not delete a referenced asset without force=true: %v", err)
	}

	forced := callTool(t, session, "delete_page_asset", map[string]any{
		"slug":            "posts/article",
		"filename":        "cover.png",
		"expected_sha256": sha256,
		"force":           true,
	})
	if forced.IsError {
		raw, _ := json.Marshal(forced.Content)
		t.Fatalf("delete_page_asset with force=true returned error: %s", raw)
	}
	dataEnvelope := decodeWriteData(t, forced)
	if dataEnvelope["referenced"] != true {
		t.Fatalf("delete_page_asset data.referenced = %v, want true (force overrides the guard, doesn't hide the fact)", dataEnvelope["referenced"])
	}
	if _, err := os.Stat(filepath.Join(dir, "cover.png")); !os.IsNotExist(err) {
		t.Fatal("delete_page_asset with force=true must delete the file")
	}
}

func TestDeletePageAssetDryRunPreviewsWithoutDeleting(t *testing.T) {
	contentRoot := t.TempDir()
	writeBundle(t, contentRoot, "posts/article")
	session, _, done := newTestServer(t, contentRoot)
	defer done()

	if res := callTool(t, session, "upload_page_asset", map[string]any{
		"slug":           "posts/article",
		"filename":       "cover.png",
		"content_base64": b64(minimalPNG),
	}); res.IsError {
		raw, _ := json.Marshal(res.Content)
		t.Fatalf("upload_page_asset returned error: %s", raw)
	}

	res := callTool(t, session, "delete_page_asset", map[string]any{
		"slug":     "posts/article",
		"filename": "cover.png",
		"dry_run":  true,
	})
	if res.IsError {
		raw, _ := json.Marshal(res.Content)
		t.Fatalf("delete_page_asset dry_run returned error: %s", raw)
	}
	dataEnvelope := decodeWriteData(t, res)
	if dataEnvelope["dry_run"] != true {
		t.Fatalf("delete_page_asset dry_run response data.dry_run = %v, want true", dataEnvelope["dry_run"])
	}
	if sha, _ := dataEnvelope["sha256"].(string); sha == "" {
		t.Fatal("delete_page_asset dry_run must report the asset's sha256")
	}
	if dataEnvelope["referenced"] == true {
		t.Fatalf("delete_page_asset dry_run data.referenced = %v, want false (fixture body doesn't mention cover.png)", dataEnvelope["referenced"])
	}
	if _, err := os.Stat(filepath.Join(contentRoot, "posts", "article", "cover.png")); err != nil {
		t.Fatalf("delete_page_asset dry_run must not delete the file: %v", err)
	}
}

func TestDeletePageAssetDeletesGeneratedHeroImageWhenScopeIsGenerated(t *testing.T) {
	contentRoot := t.TempDir()
	hugoRoot := t.TempDir()
	writeBundle(t, contentRoot, "posts/article")
	session, _, done := newTestServer(t, contentRoot, testServerOpts{HugoRoot: hugoRoot})
	defer done()

	heroPath := filepath.Join(hugoRoot, "static", "images", "posts", "article-featured.jpg")
	if err := os.MkdirAll(filepath.Dir(heroPath), 0o755); err != nil {
		t.Fatal(err)
	}
	heroBytes := []byte("hero-bytes")
	if err := os.WriteFile(heroPath, heroBytes, 0o644); err != nil {
		t.Fatal(err)
	}

	res := callTool(t, session, "delete_page_asset", map[string]any{
		"slug":            "posts/article",
		"filename":        "article-featured.jpg",
		"scope":           "generated",
		"expected_sha256": contentmodel.SourceRevisionBytes(heroBytes),
	})
	if res.IsError {
		raw, _ := json.Marshal(res.Content)
		t.Fatalf("delete_page_asset generated hero failed: %s", raw)
	}
	dataEnvelope := decodeWriteData(t, res)
	if got := dataEnvelope["status"]; got != "deleted" {
		t.Fatalf("delete_page_asset data.status = %v, want deleted", got)
	}
	if got := dataEnvelope["scope"]; got != "generated" {
		t.Fatalf("delete_page_asset data.scope = %v, want generated", got)
	}
	if got := dataEnvelope["path"]; got != "static/images/posts/article-featured.jpg" {
		t.Fatalf("delete_page_asset data.path = %v, want static/images/posts/article-featured.jpg", got)
	}
	if _, err := os.Stat(heroPath); !os.IsNotExist(err) {
		t.Fatal("delete_page_asset must remove the generated hero image")
	}
}

func TestDeletePageAssetDryRunPreviewsGeneratedHeroImage(t *testing.T) {
	contentRoot := t.TempDir()
	hugoRoot := t.TempDir()
	writeBundle(t, contentRoot, "posts/article")
	session, _, done := newTestServer(t, contentRoot, testServerOpts{HugoRoot: hugoRoot})
	defer done()

	heroPath := filepath.Join(hugoRoot, "static", "images", "posts", "article-featured.jpg")
	if err := os.MkdirAll(filepath.Dir(heroPath), 0o755); err != nil {
		t.Fatal(err)
	}
	heroBytes := []byte("hero-bytes")
	if err := os.WriteFile(heroPath, heroBytes, 0o644); err != nil {
		t.Fatal(err)
	}

	res := callTool(t, session, "delete_page_asset", map[string]any{
		"slug":     "posts/article",
		"filename": "article-featured.jpg",
		"scope":    "generated",
		"dry_run":  true,
	})
	if res.IsError {
		raw, _ := json.Marshal(res.Content)
		t.Fatalf("delete_page_asset generated dry_run failed: %s", raw)
	}
	dataEnvelope := decodeWriteData(t, res)
	if got := dataEnvelope["status"]; got != "ok" {
		t.Fatalf("delete_page_asset dry_run data.status = %v, want ok", got)
	}
	if got := dataEnvelope["scope"]; got != "generated" {
		t.Fatalf("delete_page_asset dry_run data.scope = %v, want generated", got)
	}
	if got := dataEnvelope["sha256"]; got != contentmodel.SourceRevisionBytes(heroBytes) {
		t.Fatalf("delete_page_asset dry_run data.sha256 = %v, want current hero hash", got)
	}
	if _, err := os.Stat(heroPath); err != nil {
		t.Fatalf("delete_page_asset dry_run must not delete the generated hero image: %v", err)
	}
}

func TestDeletePageAssetDeletesGeneratedHeroImageWithoutExistingBundle(t *testing.T) {
	contentRoot := t.TempDir()
	hugoRoot := t.TempDir()
	session, _, done := newTestServer(t, contentRoot, testServerOpts{HugoRoot: hugoRoot})
	defer done()

	heroPath := filepath.Join(hugoRoot, "static", "images", "posts", "article-featured.jpg")
	if err := os.MkdirAll(filepath.Dir(heroPath), 0o755); err != nil {
		t.Fatal(err)
	}
	heroBytes := []byte("hero-bytes")
	if err := os.WriteFile(heroPath, heroBytes, 0o644); err != nil {
		t.Fatal(err)
	}

	res := callTool(t, session, "delete_page_asset", map[string]any{
		"slug":            "posts/article",
		"filename":        "article-featured.jpg",
		"scope":           "generated",
		"expected_sha256": contentmodel.SourceRevisionBytes(heroBytes),
	})
	if res.IsError {
		raw, _ := json.Marshal(res.Content)
		t.Fatalf("delete_page_asset generated hero without bundle failed: %s", raw)
	}
	if _, err := os.Stat(heroPath); !os.IsNotExist(err) {
		t.Fatal("delete_page_asset must remove the generated hero image even when the page bundle no longer exists")
	}
}

func TestDeletePageAssetDeletesGeneratedHeroWithoutBundle(t *testing.T) {
	hugoRoot := t.TempDir()
	contentRoot := t.TempDir()
	session, _, done := newTestServer(t, contentRoot, testServerOpts{HugoRoot: hugoRoot})
	defer done()

	heroPath := filepath.Join(hugoRoot, "static", "images", "posts", "orphan-featured.jpg")
	if err := os.MkdirAll(filepath.Dir(heroPath), 0o755); err != nil {
		t.Fatal(err)
	}
	heroBytes := []byte("hero-bytes")
	if err := os.WriteFile(heroPath, heroBytes, 0o644); err != nil {
		t.Fatal(err)
	}

	res := callTool(t, session, "delete_page_asset", map[string]any{
		"slug":            "posts/orphan",
		"filename":        "orphan-featured.jpg",
		"scope":           "generated",
		"expected_sha256": contentmodel.SourceRevisionBytes(heroBytes),
	})
	if res.IsError {
		raw, _ := json.Marshal(res.Content)
		t.Fatalf("delete_page_asset generated hero without bundle failed: %s", raw)
	}
	data := decodeWriteData(t, res)
	if got := data["scope"]; got != "generated" {
		t.Fatalf("data.scope = %v, want generated", got)
	}
	if got := data["path"]; got != "static/images/posts/orphan-featured.jpg" {
		t.Fatalf("data.path = %v, want static/images/posts/orphan-featured.jpg", got)
	}
	if _, err := os.Stat(heroPath); !os.IsNotExist(err) {
		t.Fatal("delete_page_asset must remove orphaned generated hero image even when no bundle exists")
	}
}

func TestDeletePageAssetDryRunPreviewsGeneratedHeroImageWithoutExistingBundle(t *testing.T) {
	contentRoot := t.TempDir()
	hugoRoot := t.TempDir()
	session, _, done := newTestServer(t, contentRoot, testServerOpts{HugoRoot: hugoRoot})
	defer done()

	heroPath := filepath.Join(hugoRoot, "static", "images", "posts", "article-featured.jpg")
	if err := os.MkdirAll(filepath.Dir(heroPath), 0o755); err != nil {
		t.Fatal(err)
	}
	heroBytes := []byte("hero-bytes")
	if err := os.WriteFile(heroPath, heroBytes, 0o644); err != nil {
		t.Fatal(err)
	}

	res := callTool(t, session, "delete_page_asset", map[string]any{
		"slug":     "posts/article",
		"filename": "article-featured.jpg",
		"scope":    "generated",
		"dry_run":  true,
	})
	if res.IsError {
		raw, _ := json.Marshal(res.Content)
		t.Fatalf("delete_page_asset generated dry_run without bundle failed: %s", raw)
	}
	dataEnvelope := decodeWriteData(t, res)
	if got := dataEnvelope["sha256"]; got != contentmodel.SourceRevisionBytes(heroBytes) {
		t.Fatalf("delete_page_asset dry_run without bundle data.sha256 = %v, want current hero hash", got)
	}
	if _, err := os.Stat(heroPath); err != nil {
		t.Fatalf("delete_page_asset dry_run without bundle must not delete the generated hero image: %v", err)
	}
}

func TestDeletePageAssetGeneratedScopeNormalizesLanguagePrefixedPublicSlug(t *testing.T) {
	hugoRoot := t.TempDir()
	contentRoot := t.TempDir()
	session, _, done := newTestServer(t, contentRoot, testServerOpts{HugoRoot: hugoRoot})
	defer done()

	heroPath := filepath.Join(hugoRoot, "static", "images", "posts", "demo-featured.jpg")
	if err := os.MkdirAll(filepath.Dir(heroPath), 0o755); err != nil {
		t.Fatal(err)
	}
	heroBytes := []byte("hero-bytes")
	if err := os.WriteFile(heroPath, heroBytes, 0o644); err != nil {
		t.Fatal(err)
	}

	res := callTool(t, session, "delete_page_asset", map[string]any{
		"slug":            "/en/posts/demo/",
		"filename":        "demo-featured.jpg",
		"scope":           "generated",
		"expected_sha256": contentmodel.SourceRevisionBytes(heroBytes),
	})
	if res.IsError {
		raw, _ := json.Marshal(res.Content)
		t.Fatalf("delete_page_asset generated hero with language-prefixed public slug failed: %s", raw)
	}
	data := decodeWriteData(t, res)
	if got := data["source_key"]; got != "posts/demo" {
		t.Fatalf("data.source_key = %v, want posts/demo", got)
	}
	if got := data["path"]; got != "static/images/posts/demo-featured.jpg" {
		t.Fatalf("data.path = %v, want static/images/posts/demo-featured.jpg", got)
	}
	if _, err := os.Stat(heroPath); !os.IsNotExist(err) {
		t.Fatal("delete_page_asset must normalize /en/posts/.../ to the same generated hero path as generate_hero_image")
	}
}

// TestDeletePageAssetDryRunDoesNotConsumeDestructiveQuota is a regression
// test for #575: a live Claude.ai audit observed rate_limit_remaining drop
// (5→4) on delete_page_asset before the real (non-dry-run) call, suggesting
// dry_run itself consumed the destructive quota. The handler's own code
// comment says fetching the limiter doesn't consume budget and dry_run
// returns before calling limiter.Allow() — this test proves that empirically
// with a small budget so any unexpected decrement is visible immediately,
// ruling out (or catching) a token-bucket-refill artifact masking a real bug.
func TestDeletePageAssetDryRunDoesNotConsumeDestructiveQuota(t *testing.T) {
	contentRoot := t.TempDir()
	writeBundle(t, contentRoot, "posts/article")
	rl := config.Default().RateLimit
	rl.DestructivePerMin = 2
	session, _, done := newTestServer(t, contentRoot, testServerOpts{RateLimit: &rl})
	defer done()

	if res := callTool(t, session, "upload_page_asset", map[string]any{
		"slug":           "posts/article",
		"filename":       "cover.png",
		"content_base64": b64(minimalPNG),
	}); res.IsError {
		raw, _ := json.Marshal(res.Content)
		t.Fatalf("upload_page_asset returned error: %s", raw)
	}

	var remaining []float64
	for i := 0; i < 5; i++ {
		res := callTool(t, session, "delete_page_asset", map[string]any{
			"slug":     "posts/article",
			"filename": "cover.png",
			"dry_run":  true,
		})
		if res.IsError {
			raw, _ := json.Marshal(res.Content)
			t.Fatalf("delete_page_asset dry_run %d returned error: %s", i, raw)
		}
		root := decodeWriteContent(t, res)
		rem, ok := root["rate_limit_remaining"].(float64)
		if !ok {
			t.Fatalf("delete_page_asset dry_run %d: rate_limit_remaining missing", i)
		}
		remaining = append(remaining, rem)
	}
	for i := 1; i < len(remaining); i++ {
		if remaining[i] != remaining[0] {
			t.Fatalf("delete_page_asset dry_run consumed destructive quota: rate_limit_remaining sequence = %v, want constant at %v (dry_run must never call limiter.Allow())", remaining, remaining[0])
		}
	}
	if remaining[0] != float64(rl.DestructivePerMin) {
		t.Fatalf("delete_page_asset dry_run rate_limit_remaining = %v, want full fresh budget %d", remaining[0], rl.DestructivePerMin)
	}
}

// TestDeletePageAssetMatchingOwnerStillConsumesQuota confirms `owner` is
// advisory metadata only on asset deletes too: a matching test_content_owner
// must not bypass the destructive quota.
func TestDeletePageAssetMatchingOwnerStillConsumesQuota(t *testing.T) {
	contentRoot := t.TempDir()
	rl := config.Default().RateLimit
	rl.DestructivePerMin = 1
	session, _, done := newTestServer(t, contentRoot, testServerOpts{RateLimit: &rl})
	defer done()

	const owner = "agent-895"
	if res := callTool(t, session, "create_page", map[string]any{
		"slug": "posts/owned-asset-test", "title": "T", "body": "B",
		"tags": []any{}, "categories": []any{},
		"test_content": map[string]any{"owner": owner},
	}); res.IsError {
		raw, _ := json.Marshal(res.Content)
		t.Fatalf("create_page expected success, got error: %s", raw)
	}
	for _, name := range []string{"cover.png", "cover2.png"} {
		if res := callTool(t, session, "upload_page_asset", map[string]any{
			"slug": "posts/owned-asset-test", "filename": name, "content_base64": b64(minimalPNG),
		}); res.IsError {
			raw, _ := json.Marshal(res.Content)
			t.Fatalf("upload_page_asset %s expected success, got error: %s", name, raw)
		}
	}

	if res := callTool(t, session, "delete_page_asset", map[string]any{
		"slug": "posts/owned-asset-test", "filename": "cover.png",
		"expected_revision": currentRevision(t, filepath.Join(contentRoot, "posts", "owned-asset-test", "index.md")),
		"owner":             owner,
	}); res.IsError {
		raw, _ := json.Marshal(res.Content)
		t.Fatalf("first delete_page_asset expected success, got error: %s", raw)
	}

	res := callTool(t, session, "delete_page_asset", map[string]any{
		"slug": "posts/owned-asset-test", "filename": "cover2.png",
		"expected_revision": currentRevision(t, filepath.Join(contentRoot, "posts", "owned-asset-test", "index.md")),
		"owner":             owner,
	})
	if !res.IsError {
		t.Fatal("expected rate_limit_exceeded on 2nd delete_page_asset with matching owner, got success")
	}
	raw, _ := json.Marshal(res.Content)
	if !strings.Contains(string(raw), "rate_limit_exceeded") {
		t.Errorf("expected rate_limit_exceeded error, got: %s", raw)
	}
}

// TestDeletePageAssetMismatchedOwnerStillConsumesQuota confirms the same
// outcome for a mismatched owner string.
func TestDeletePageAssetMismatchedOwnerStillConsumesQuota(t *testing.T) {
	contentRoot := t.TempDir()
	rl := config.Default().RateLimit
	rl.DestructivePerMin = 1
	session, _, done := newTestServer(t, contentRoot, testServerOpts{RateLimit: &rl})
	defer done()

	if res := callTool(t, session, "create_page", map[string]any{
		"slug": "posts/other-owner-asset-test", "title": "T", "body": "B",
		"tags": []any{}, "categories": []any{},
		"test_content": map[string]any{"owner": "someone-else"},
	}); res.IsError {
		raw, _ := json.Marshal(res.Content)
		t.Fatalf("create_page expected success, got error: %s", raw)
	}
	for _, name := range []string{"cover.png", "cover2.png"} {
		if res := callTool(t, session, "upload_page_asset", map[string]any{
			"slug": "posts/other-owner-asset-test", "filename": name, "content_base64": b64(minimalPNG),
		}); res.IsError {
			raw, _ := json.Marshal(res.Content)
			t.Fatalf("upload_page_asset %s expected success, got error: %s", name, raw)
		}
	}

	// First delete with a non-matching owner consumes the (size-1) quota.
	if res := callTool(t, session, "delete_page_asset", map[string]any{
		"slug": "posts/other-owner-asset-test", "filename": "cover.png",
		"expected_revision": currentRevision(t, filepath.Join(contentRoot, "posts", "other-owner-asset-test", "index.md")),
		"owner":             "not-the-recorded-owner",
	}); res.IsError {
		raw, _ := json.Marshal(res.Content)
		t.Fatalf("first delete_page_asset expected success, got error: %s", raw)
	}

	// Second delete (also mismatched owner) must be blocked.
	res := callTool(t, session, "delete_page_asset", map[string]any{
		"slug": "posts/other-owner-asset-test", "filename": "cover2.png",
		"expected_revision": currentRevision(t, filepath.Join(contentRoot, "posts", "other-owner-asset-test", "index.md")),
		"owner":             "not-the-recorded-owner",
	})
	if !res.IsError {
		t.Fatal("expected rate_limit_exceeded on 2nd delete_page_asset with mismatched owner, got success")
	}
	raw, _ := json.Marshal(res.Content)
	if !strings.Contains(string(raw), "rate_limit_exceeded") {
		t.Errorf("expected rate_limit_exceeded error, got: %s", raw)
	}
}

// TestDeletePageAssetGeneratedScopeWithoutBundleIgnoresOwnerStillConsumesQuota
// confirms `owner` has no effect on generated-scope deletes without an owning
// bundle either.
func TestDeletePageAssetGeneratedScopeWithoutBundleIgnoresOwnerStillConsumesQuota(t *testing.T) {
	contentRoot := t.TempDir()
	hugoRoot := t.TempDir()
	rl := config.Default().RateLimit
	rl.DestructivePerMin = 1
	session, _, done := newTestServer(t, contentRoot, testServerOpts{RateLimit: &rl, HugoRoot: hugoRoot})
	defer done()

	slug := "posts/orphan-hero-895"
	loc, err := admin.ResolveHeroImageLocation(hugoRoot, slug)
	if err != nil {
		t.Fatalf("ResolveHeroImageLocation: %v", err)
	}
	if err := os.MkdirAll(filepath.Dir(loc.AbsPath), 0o755); err != nil {
		t.Fatalf("MkdirAll: %v", err)
	}
	if err := os.WriteFile(loc.AbsPath, minimalPNG, 0o644); err != nil {
		t.Fatalf("WriteFile: %v", err)
	}

	if res := callTool(t, session, "delete_page_asset", map[string]any{
		"slug": slug, "filename": loc.Name, "scope": "generated",
		"expected_sha256": contentmodel.SourceRevisionBytes(minimalPNG),
		"owner":           "agent-895",
	}); res.IsError {
		raw, _ := json.Marshal(res.Content)
		t.Fatalf("first delete_page_asset (orphan hero) expected success, got error: %s", raw)
	}

	// Recreate the orphaned asset and delete again — must be blocked, since
	// the owner param has no bundle frontmatter to match against.
	if err := os.WriteFile(loc.AbsPath, minimalPNG, 0o644); err != nil {
		t.Fatalf("WriteFile (recreate): %v", err)
	}
	res := callTool(t, session, "delete_page_asset", map[string]any{
		"slug": slug, "filename": loc.Name, "scope": "generated",
		"expected_sha256": contentmodel.SourceRevisionBytes(minimalPNG),
		"owner":           "agent-895",
	})
	if !res.IsError {
		t.Fatal("expected rate_limit_exceeded on 2nd orphan-hero delete (owner must not exempt a bundle-less asset), got success")
	}
	raw, _ := json.Marshal(res.Content)
	if !strings.Contains(string(raw), "rate_limit_exceeded") {
		t.Errorf("expected rate_limit_exceeded error, got: %s", raw)
	}
}

func TestDeletePageAssetIdempotencyKeyReturnsOriginalResultWithoutDeletingTwice(t *testing.T) {
	contentRoot := t.TempDir()
	writeBundle(t, contentRoot, "posts/article")
	session, _, done := newTestServer(t, contentRoot)
	defer done()

	upload := callTool(t, session, "upload_page_asset", map[string]any{
		"slug":           "posts/article",
		"filename":       "cover.png",
		"content_base64": b64(minimalPNG),
	})
	if upload.IsError {
		raw, _ := json.Marshal(upload.Content)
		t.Fatalf("upload_page_asset returned error: %s", raw)
	}
	sha256 := decodeWriteData(t, upload)["sha256"].(string)

	args := map[string]any{
		"slug":            "posts/article",
		"filename":        "cover.png",
		"expected_sha256": sha256,
		"idempotency_key": "delete-cover-once",
	}
	first := callTool(t, session, "delete_page_asset", args)
	if first.IsError {
		raw, _ := json.Marshal(first.Content)
		t.Fatalf("first delete_page_asset returned error: %s", raw)
	}

	// This is the actual uncertain-delivery scenario the idempotency_key
	// promise exists for: the file is genuinely gone (deleted by the first
	// call) and the caller never gets to see the first response, so it
	// retries with nothing recreated at the path. The idempotency replay
	// lookup must be reachable without the file existing — if it depended
	// on a not_found gate the way a fresh delete attempt does, this replay
	// would incorrectly fail with not_found instead of returning the cached
	// original result.
	replay := callTool(t, session, "delete_page_asset", args)
	if replay.IsError {
		raw, _ := json.Marshal(replay.Content)
		t.Fatalf("replayed delete_page_asset (file genuinely gone, nothing recreated) returned error: %s", raw)
	}
	firstOut := decodeWriteContent(t, first)
	replayOut := decodeWriteContent(t, replay)
	if firstOut["sha256"] != replayOut["sha256"] {
		t.Fatalf("replay sha256 = %v, want %v (identical to original result)", replayOut["sha256"], firstOut["sha256"])
	}
}

func TestDeletePageAssetNotFound(t *testing.T) {
	contentRoot := t.TempDir()
	writeBundle(t, contentRoot, "posts/article")
	session, _, done := newTestServer(t, contentRoot)
	defer done()

	res := callTool(t, session, "delete_page_asset", map[string]any{
		"slug":            "posts/article",
		"filename":        "does-not-exist.png",
		"expected_sha256": "irrelevant",
	})
	if !res.IsError {
		t.Fatal("delete_page_asset: want not_found for a missing asset, got success")
	}
	raw, _ := json.Marshal(res.Content)
	if !strings.Contains(string(raw), "not_found") {
		t.Fatalf("delete_page_asset missing-asset error = %s, want not_found", raw)
	}
}

func TestDeletePageAssetRejectsIndexFile(t *testing.T) {
	contentRoot := t.TempDir()
	writeBundle(t, contentRoot, "posts/article")
	session, _, done := newTestServer(t, contentRoot)
	defer done()

	res := callTool(t, session, "delete_page_asset", map[string]any{
		"slug":            "posts/article",
		"filename":        "index.md",
		"expected_sha256": "irrelevant",
	})
	if !res.IsError {
		t.Fatal("delete_page_asset: want error when filename names the page's own content file, got success")
	}
	raw, _ := json.Marshal(res.Content)
	if !strings.Contains(string(raw), "invalid_params") {
		t.Fatalf("delete_page_asset index.md error = %s, want invalid_params", raw)
	}
	if _, err := os.Stat(filepath.Join(contentRoot, "posts", "article", "index.md")); err != nil {
		t.Fatalf("delete_page_asset must not delete index.md: %v", err)
	}
}
