package contentpayload

import (
	"bytes"
	"context"
	"errors"
	"io"
	"os"
	"path/filepath"
	"testing"

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

func TestFilesystemStagesImmutableVersion(t *testing.T) {
	storage, err := NewFilesystemTreeBackend(t.TempDir())
	require.NoError(t, err)
	t.Cleanup(func() { require.NoError(t, storage.Close()) })

	request := StageTreeRequest{
		WorkspaceID: "wsp_1",
		ContentID:   "ctn_1",
		VersionID:   "ctv_1",
		Files: []File{
			{Path: "docs/readme.md", Size: 5, Content: bytes.NewBufferString("hello")},
			{Path: "bin/run", Size: 3, Executable: true, Content: bytes.NewBufferString("run")},
		},
	}
	staged, err := storage.StageTree(context.Background(), request)
	require.NoError(t, err)
	require.Equal(t, ManifestRef("wsp_1/ctn_1/ctv_1"), staged.ManifestRef)
	require.Equal(t, int64(8), staged.SizeBytes)
	require.Equal(t, 2, staged.FileCount)
	require.Len(t, staged.RootDigest, 64)

	manifest, err := storage.OpenManifest(context.Background(), staged.ManifestRef)
	require.NoError(t, err)
	require.Equal(t, []string{"bin/run", "docs/readme.md"}, []string{manifest.Files[0].Path, manifest.Files[1].Path})
	require.Equal(t, staged.RootDigest, manifest.RootDigest)

	body, meta, err := storage.OpenBlob(context.Background(), manifest.Files[1].Digest, &ByteRange{Offset: 1, Length: 3})
	require.NoError(t, err)
	defer body.Close()
	payload, err := io.ReadAll(body)
	require.NoError(t, err)
	require.Equal(t, "ell", string(payload))
	require.Equal(t, int64(5), meta.SizeBytes)

	destination := filepath.Join(t.TempDir(), "checkout")
	require.NoError(t, storage.Materialize(context.Background(), staged.ManifestRef, destination, MaterializeLimits{}))
	readme, err := os.ReadFile(filepath.Join(destination, "docs", "readme.md"))
	require.NoError(t, err)
	require.Equal(t, "hello", string(readme))
	stat, err := os.Stat(filepath.Join(destination, "bin", "run"))
	require.NoError(t, err)
	require.NotZero(t, stat.Mode()&0o100)
}

func TestFilesystemStageIsIdempotentAndVersionIsImmutable(t *testing.T) {
	storage, err := NewFilesystemTreeBackend(t.TempDir())
	require.NoError(t, err)
	t.Cleanup(func() { require.NoError(t, storage.Close()) })
	request := StageTreeRequest{WorkspaceID: "wsp_1", ContentID: "ctn_1", VersionID: "ctv_1", Files: []File{{Path: "a.txt", Size: 3, Content: bytes.NewBufferString("one")}}}

	first, err := storage.StageTree(context.Background(), request)
	require.NoError(t, err)
	request.Files[0].Content = bytes.NewBufferString("one")
	second, err := storage.StageTree(context.Background(), request)
	require.NoError(t, err)
	require.True(t, first.Created)
	require.False(t, second.Created)
	first.Created = false
	require.Equal(t, first, second)

	request.Files[0].Content = bytes.NewBufferString("two")
	_, err = storage.StageTree(context.Background(), request)
	require.ErrorIs(t, err, ErrAlreadyExists)
}

func TestFilesystemRejectsInvalidPathsDuplicatesAndLimits(t *testing.T) {
	storage, err := NewFilesystemTreeBackend(t.TempDir())
	require.NoError(t, err)
	t.Cleanup(func() { require.NoError(t, storage.Close()) })
	base := StageTreeRequest{WorkspaceID: "wsp_1", ContentID: "ctn_1", VersionID: "ctv_1"}

	for _, invalid := range []string{"", "/absolute", "../escape", "dir/../escape", "dir//file"} {
		request := base
		request.Files = []File{{Path: invalid, Size: 1, Content: bytes.NewBufferString("x")}}
		_, err := storage.StageTree(context.Background(), request)
		require.Error(t, err, invalid)
	}

	duplicate := base
	duplicate.Files = []File{{Path: "a", Size: 1, Content: bytes.NewBufferString("x")}, {Path: "a", Size: 1, Content: bytes.NewBufferString("y")}}
	_, err = storage.StageTree(context.Background(), duplicate)
	require.ErrorContains(t, err, "duplicate path")

	oversized := base
	oversized.Files = []File{{Path: "a", Size: 2, Content: bytes.NewBufferString("xx")}}
	oversized.Limits = Limits{MaxFiles: 1, MaxFileBytes: 1, MaxTotalBytes: 1, MaxManifestBytes: 1024}
	_, err = storage.StageTree(context.Background(), oversized)
	require.ErrorContains(t, err, "invalid size")
}

func TestFilesystemRejectsSizeMismatchAndCancellation(t *testing.T) {
	storage, err := NewFilesystemTreeBackend(t.TempDir())
	require.NoError(t, err)
	t.Cleanup(func() { require.NoError(t, storage.Close()) })
	request := StageTreeRequest{WorkspaceID: "wsp_1", ContentID: "ctn_1", VersionID: "ctv_1", Files: []File{{Path: "a", Size: 2, Content: bytes.NewBufferString("x")}}}
	_, err = storage.StageTree(context.Background(), request)
	require.ErrorContains(t, err, "size mismatch")

	ctx, cancel := context.WithCancel(context.Background())
	cancel()
	request.Files[0].Size = 1
	request.Files[0].Content = bytes.NewBufferString("x")
	_, err = storage.StageTree(ctx, request)
	require.ErrorIs(t, err, context.Canceled)
}

func TestFilesystemDeletePayloadIsIdempotentAndDoesNotDeleteSharedBlob(t *testing.T) {
	storage, err := NewFilesystemTreeBackend(t.TempDir())
	require.NoError(t, err)
	t.Cleanup(func() { require.NoError(t, storage.Close()) })
	stage := func(version string) StagedTree {
		result, err := storage.StageTree(context.Background(), StageTreeRequest{
			WorkspaceID: "wsp_1", ContentID: "ctn_1", VersionID: version,
			Files: []File{{Path: "shared", Size: 4, Content: bytes.NewBufferString("same")}},
		})
		require.NoError(t, err)
		return result
	}
	first := stage("ctv_1")
	second := stage("ctv_2")
	manifest, err := storage.OpenManifest(context.Background(), second.ManifestRef)
	require.NoError(t, err)

	require.NoError(t, storage.DeletePayload(context.Background(), first.ManifestRef))
	require.NoError(t, storage.DeletePayload(context.Background(), first.ManifestRef))
	_, err = storage.OpenManifest(context.Background(), first.ManifestRef)
	require.ErrorIs(t, err, ErrNotFound)
	body, _, err := storage.OpenBlob(context.Background(), manifest.Files[0].Digest, nil)
	require.NoError(t, err)
	require.NoError(t, body.Close())
}

func TestFilesystemOpenBlobRejectsInvalidRange(t *testing.T) {
	storage, err := NewFilesystemTreeBackend(t.TempDir())
	require.NoError(t, err)
	t.Cleanup(func() { require.NoError(t, storage.Close()) })
	staged, err := storage.StageTree(context.Background(), StageTreeRequest{
		WorkspaceID: "wsp_1", ContentID: "ctn_1", VersionID: "ctv_1",
		Files: []File{{Path: "a", Size: 3, Content: bytes.NewBufferString("abc")}},
	})
	require.NoError(t, err)
	manifest, err := storage.OpenManifest(context.Background(), staged.ManifestRef)
	require.NoError(t, err)
	_, _, err = storage.OpenBlob(context.Background(), manifest.Files[0].Digest, &ByteRange{Offset: 2, Length: 2})
	require.Error(t, err)
	_, _, err = storage.OpenBlob(context.Background(), Digest("bad"), nil)
	require.Error(t, err)
	require.False(t, errors.Is(err, ErrNotFound))
}

func TestFilesystemDoesNotFollowManifestSymlinkOutsideRoot(t *testing.T) {
	root := t.TempDir()
	storage, err := NewFilesystemTreeBackend(root)
	require.NoError(t, err)
	t.Cleanup(func() { require.NoError(t, storage.Close()) })
	outside := filepath.Join(t.TempDir(), "outside.json")
	require.NoError(t, os.WriteFile(outside, []byte(`{"workspaceId":"stolen"}`), 0o600))
	manifestDir := filepath.Join(root, "manifests", "wsp_1", "ctn_1", "ctv_1")
	require.NoError(t, os.MkdirAll(manifestDir, 0o750))
	require.NoError(t, os.Symlink(outside, filepath.Join(manifestDir, manifestFilename)))

	_, err = storage.OpenManifest(context.Background(), "wsp_1/ctn_1/ctv_1")
	require.Error(t, err)
}

func TestFilesystemRejectsIntermediateSymlinks(t *testing.T) {
	for _, component := range []string{"manifests", "blobs"} {
		t.Run(component, func(t *testing.T) {
			root := t.TempDir()
			outside := t.TempDir()
			require.NoError(t, os.Symlink(outside, filepath.Join(root, component)))
			storage, err := NewFilesystemTreeBackend(root)
			require.NoError(t, err)
			t.Cleanup(func() { require.NoError(t, storage.Close()) })
			_, err = storage.StageTree(context.Background(), StageTreeRequest{
				WorkspaceID: "wsp_1", ContentID: "ctn_1", VersionID: "ctv_1",
				Files: []File{{Path: "a", Size: 1, Content: bytes.NewBufferString("x")}},
			})
			require.Error(t, err)
			entries, err := os.ReadDir(outside)
			require.NoError(t, err)
			require.Empty(t, entries)
		})
	}
}

func TestFilesystemPinsRootAcrossPathReplacement(t *testing.T) {
	parent := t.TempDir()
	root := filepath.Join(parent, "content-storage")
	storage, err := NewFilesystemTreeBackend(root)
	require.NoError(t, err)
	t.Cleanup(func() { require.NoError(t, storage.Close()) })

	pinnedRoot := filepath.Join(parent, "pinned-root")
	require.NoError(t, os.Rename(root, pinnedRoot))
	require.NoError(t, os.Mkdir(root, 0o750))

	staged, err := storage.StageTree(context.Background(), StageTreeRequest{
		WorkspaceID: "wsp_1", ContentID: "ctn_1", VersionID: "ctv_1",
		Files: []File{{Path: "a", Size: 3, Content: bytes.NewBufferString("abc")}},
	})
	require.NoError(t, err)
	_, err = storage.OpenManifest(context.Background(), staged.ManifestRef)
	require.NoError(t, err)
	_, err = os.Stat(filepath.Join(pinnedRoot, "manifests", "wsp_1", "ctn_1", "ctv_1", manifestFilename))
	require.NoError(t, err)
	replacementEntries, err := os.ReadDir(root)
	require.NoError(t, err)
	require.Empty(t, replacementEntries, "path replacement must not become the storage root")
}

func TestFilesystemCloseRejectsNewOperations(t *testing.T) {
	storage, err := NewFilesystemTreeBackend(t.TempDir())
	require.NoError(t, err)
	require.NoError(t, storage.Close())
	require.NoError(t, storage.Close(), "close must be idempotent")

	_, err = storage.StageTree(context.Background(), StageTreeRequest{
		WorkspaceID: "wsp_1", ContentID: "ctn_1", VersionID: "ctv_1",
		Files: []File{{Path: "a", Size: 1, Content: bytes.NewBufferString("x")}},
	})
	require.ErrorIs(t, err, errFilesystemClosed)
}

func TestFilesystemRestageRejectsExistingCorruptBlob(t *testing.T) {
	root := t.TempDir()
	storage, err := NewFilesystemTreeBackend(root)
	require.NoError(t, err)
	t.Cleanup(func() { require.NoError(t, storage.Close()) })

	first, err := storage.StageTree(context.Background(), StageTreeRequest{
		WorkspaceID: "wsp_1", ContentID: "ctn_1", VersionID: "ctv_1",
		Files: []File{{Path: "a", Size: 3, Content: bytes.NewBufferString("abc")}},
	})
	require.NoError(t, err)
	manifest, err := storage.OpenManifest(context.Background(), first.ManifestRef)
	require.NoError(t, err)
	digest := string(manifest.Files[0].Digest)
	require.NoError(t, os.WriteFile(filepath.Join(root, "blobs", digest[:2], digest[2:]), []byte("xyz"), 0o600))

	_, err = storage.StageTree(context.Background(), StageTreeRequest{
		WorkspaceID: "wsp_1", ContentID: "ctn_1", VersionID: "ctv_2",
		Files: []File{{Path: "a", Size: 3, Content: bytes.NewBufferString("abc")}},
	})
	require.ErrorContains(t, err, "existing content blob digest mismatch")
	_, err = storage.OpenManifest(context.Background(), "wsp_1/ctn_1/ctv_2")
	require.ErrorIs(t, err, ErrNotFound)
}

func TestFilesystemDetectsBlobAndManifestCorruption(t *testing.T) {
	root := t.TempDir()
	storage, err := NewFilesystemTreeBackend(root)
	require.NoError(t, err)
	t.Cleanup(func() { require.NoError(t, storage.Close()) })
	staged, err := storage.StageTree(context.Background(), StageTreeRequest{
		WorkspaceID: "wsp_1", ContentID: "ctn_1", VersionID: "ctv_1",
		Files: []File{{Path: "a", Size: 3, Content: bytes.NewBufferString("abc")}},
	})
	require.NoError(t, err)
	manifest, err := storage.OpenManifest(context.Background(), staged.ManifestRef)
	require.NoError(t, err)
	digest := string(manifest.Files[0].Digest)
	require.NoError(t, os.WriteFile(filepath.Join(root, "blobs", digest[:2], digest[2:]), []byte("xyz"), 0o600))
	_, _, err = storage.OpenBlob(context.Background(), manifest.Files[0].Digest, nil)
	require.ErrorContains(t, err, "digest mismatch")
	require.ErrorContains(t, storage.Materialize(context.Background(), staged.ManifestRef, filepath.Join(t.TempDir(), "out"), MaterializeLimits{}), "digest mismatch")

	manifestPath := filepath.Join(root, "manifests", "wsp_1", "ctn_1", "ctv_1", manifestFilename)
	payload, err := os.ReadFile(manifestPath)
	require.NoError(t, err)
	payload = bytes.Replace(payload, []byte(`"sizeBytes":3`), []byte(`"sizeBytes":2`), 1)
	require.NoError(t, os.WriteFile(manifestPath, payload, 0o600))
	_, err = storage.OpenManifest(context.Background(), staged.ManifestRef)
	require.ErrorContains(t, err, "aggregate size mismatch")
}
