package server

import (
	"context"
	"encoding/json"
	"errors"
	"net/http"
	"net/http/httptest"
	"reflect"
	"sort"
	"strings"
	"testing"
)

func TestResolveFSList(t *testing.T) {
	tests := []struct {
		name         string
		scope        *TenantScope
		path         string
		wantMode     fsListMode
		wantHidden   bool
		wantChildren []string
		wantErr      error
	}{
		{
			name:     "owner is full",
			scope:    &TenantScope{},
			path:     "/",
			wantMode: fsListFull,
		},
		{
			name: "legacy root list stays full",
			scope: &TenantScope{IsScoped: true, FSScopes: []FSScope{{
				Prefix: "/",
				Ops:    map[FSOp]bool{FSOpList: true},
			}}},
			path:     "/secret",
			wantMode: fsListFull,
		},
		{
			name: "root projects immediate target segments",
			scope: &TenantScope{
				IsScoped:      true,
				FSPseudoRoots: []string{"/"},
				FSScopes: []FSScope{
					{Prefix: "/a/b", Ops: map[FSOp]bool{FSOpList: true}},
					{Prefix: "/c", Ops: map[FSOp]bool{FSOpList: true}},
				},
			},
			path:         "/",
			wantMode:     fsListProjected,
			wantChildren: []string{"a", "c"},
		},
		{
			name: "ancestor projects next segment",
			scope: &TenantScope{
				IsScoped:      true,
				FSPseudoRoots: []string{"/"},
				FSScopes: []FSScope{{
					Prefix: "/a/b/c",
					Ops:    map[FSOp]bool{FSOpList: true},
				}},
			},
			path:         "/a",
			wantMode:     fsListProjected,
			wantChildren: []string{"b"},
		},
		{
			name: "target and descendant are full",
			scope: &TenantScope{
				IsScoped:      true,
				FSPseudoRoots: []string{"/"},
				FSScopes: []FSScope{{
					Prefix: "/a/b",
					Ops:    map[FSOp]bool{FSOpList: true},
				}},
			},
			path:     "/a/b/child",
			wantMode: fsListFull,
		},
		{
			name: "empty root projection succeeds",
			scope: &TenantScope{
				IsScoped:      true,
				FSPseudoRoots: []string{"/"},
			},
			path:     "/",
			wantMode: fsListProjected,
		},
		{
			name: "unrelated path below root is hidden",
			scope: &TenantScope{
				IsScoped:      true,
				FSPseudoRoots: []string{"/"},
				FSScopes: []FSScope{{
					Prefix: "/a",
					Ops:    map[FSOp]bool{FSOpList: true},
				}},
			},
			path:       "/secret",
			wantMode:   fsListDenied,
			wantHidden: true,
		},
		{
			name: "outside non-root boundary is denied not hidden",
			scope: &TenantScope{
				IsScoped:      true,
				FSPseudoRoots: []string{"/team"},
				FSScopes: []FSScope{{
					Prefix: "/team/a",
					Ops:    map[FSOp]bool{FSOpList: true},
				}},
			},
			path:     "/",
			wantMode: fsListDenied,
		},
		{
			name: "read write delete do not create targets",
			scope: &TenantScope{
				IsScoped:      true,
				FSPseudoRoots: []string{"/"},
				FSScopes: []FSScope{{
					Prefix: "/a",
					Ops: map[FSOp]bool{
						FSOpRead: true, FSOpWrite: true, FSOpDelete: true,
					},
				}},
			},
			path:     "/",
			wantMode: fsListProjected,
		},
		{
			name: "multiple boundaries union targets",
			scope: &TenantScope{
				IsScoped:      true,
				FSPseudoRoots: []string{"/", "/team"},
				FSScopes: []FSScope{
					{Prefix: "/team/a", Ops: map[FSOp]bool{FSOpList: true}},
					{Prefix: "/other", Ops: map[FSOp]bool{FSOpList: true}},
				},
			},
			path:         "/",
			wantMode:     fsListProjected,
			wantChildren: []string{"other", "team"},
		},
		{
			name: "later boundary cannot hide an earlier visible boundary",
			scope: &TenantScope{
				IsScoped:      true,
				FSPseudoRoots: []string{"/a", "/"},
				FSScopes: []FSScope{{
					Prefix: "/b",
					Ops:    map[FSOp]bool{FSOpList: true},
				}},
			},
			path:     "/a",
			wantMode: fsListProjected,
		},
		{
			name: "segment boundary does not confuse p1 and p10",
			scope: &TenantScope{
				IsScoped:      true,
				FSPseudoRoots: []string{"/p1"},
				FSScopes: []FSScope{{
					Prefix: "/p10/target",
					Ops:    map[FSOp]bool{FSOpList: true},
				}},
			},
			path:       "/p1/secret",
			wantMode:   fsListDenied,
			wantHidden: true,
		},
		{
			name:    "nil scope is denied",
			path:    "/",
			wantErr: ErrFSAccessDenied,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			decision, err := tt.scope.resolveFSList(tt.path)
			if !errors.Is(err, tt.wantErr) {
				t.Fatalf("resolveFSList() error = %v, want %v", err, tt.wantErr)
			}
			if err != nil {
				return
			}
			if decision.Mode != tt.wantMode || decision.Hidden != tt.wantHidden {
				t.Fatalf("decision = %+v, want mode=%v hidden=%v", decision, tt.wantMode, tt.wantHidden)
			}
			if got := sortedProjectedChildren(decision); !reflect.DeepEqual(got, tt.wantChildren) {
				t.Fatalf("projected children = %v, want %v", got, tt.wantChildren)
			}
		})
	}
}

func sortedProjectedChildren(decision fsListDecision) []string {
	if len(decision.projectedChildren) == 0 {
		return nil
	}
	out := make([]string, 0, len(decision.projectedChildren))
	for name := range decision.projectedChildren {
		out = append(out, name)
	}
	sort.Strings(out)
	return out
}

func TestResolveFSListRejectsEscapingPath(t *testing.T) {
	scope := &TenantScope{IsScoped: true, FSPseudoRoots: []string{"/"}}
	if _, err := scope.resolveFSList("/a/../secret"); !errors.Is(err, ErrFSInvalidPath) {
		t.Fatalf("resolveFSList() error = %v, want ErrFSInvalidPath", err)
	}
}

func TestResolveFSStat(t *testing.T) {
	scope := &TenantScope{
		IsScoped:      true,
		FSPseudoRoots: []string{"/team"},
		FSScopes: []FSScope{
			{Prefix: "/team/a/b", Ops: map[FSOp]bool{FSOpList: true}},
			{Prefix: "/team/readable", Ops: map[FSOp]bool{FSOpRead: true}},
		},
	}
	tests := []struct {
		name           string
		scope          *TenantScope
		path           string
		wantMode       fsStatMode
		wantHidden     bool
		wantHideNonDir bool
		wantBoundary   bool
	}{
		{name: "owner is full", scope: &TenantScope{}, path: "/", wantMode: fsStatFull},
		{name: "explicit read is full", scope: scope, path: "/team/readable/file", wantMode: fsStatFull},
		{name: "boundary is projected", scope: scope, path: "/team", wantMode: fsStatProjected, wantHideNonDir: true, wantBoundary: true},
		{name: "ancestor is projected", scope: scope, path: "/team/a", wantMode: fsStatProjected, wantHideNonDir: true},
		{name: "target is projected", scope: scope, path: "/team/a/b", wantMode: fsStatProjected, wantHideNonDir: true},
		{name: "target descendant is projected and visible", scope: scope, path: "/team/a/b/child", wantMode: fsStatProjected},
		{name: "unrelated path is hidden", scope: scope, path: "/team/secret", wantMode: fsStatDenied, wantHidden: true},
		{name: "outside boundary is denied", scope: scope, path: "/other", wantMode: fsStatDenied},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			decision, err := tt.scope.resolveFSStat(tt.path)
			if err != nil {
				t.Fatal(err)
			}
			if decision.Mode != tt.wantMode || decision.Hidden != tt.wantHidden || decision.HideNonDirectory != tt.wantHideNonDir || decision.Boundary != tt.wantBoundary {
				t.Fatalf("decision = %+v, want mode=%v hidden=%v hideNonDir=%v boundary=%v", decision, tt.wantMode, tt.wantHidden, tt.wantHideNonDir, tt.wantBoundary)
			}
		})
	}
}

func TestHandleListProjectsAuthorizedDirectories(t *testing.T) {
	s := newTestServer(t)
	ctx := context.Background()
	for _, dir := range []string{"/p1", "/p2", "/p3"} {
		if err := s.fallback.MkdirCtx(ctx, dir, 0o755); err != nil {
			t.Fatalf("mkdir %s: %v", dir, err)
		}
	}
	if err := s.fallback.CreateCtx(ctx, "/p1/a.txt"); err != nil {
		t.Fatalf("create p1 file: %v", err)
	}

	scope := &TenantScope{
		IsScoped:      true,
		Backend:       s.fallback,
		FSPseudoRoots: []string{"/"},
		FSScopes: []FSScope{
			{Prefix: "/p1", Ops: map[FSOp]bool{FSOpRead: true, FSOpList: true}},
			{Prefix: "/p2", Ops: map[FSOp]bool{FSOpList: true}},
		},
	}

	root := callPseudoRootList(t, s, scope, "/")
	if root.Code != http.StatusOK {
		t.Fatalf("root list status = %d body=%s, want 200", root.Code, root.Body.String())
	}
	var rootBody struct {
		Entries []struct {
			Name       string `json:"name"`
			IsDir      bool   `json:"isDir"`
			HasMode    bool   `json:"hasMode"`
			ResourceID string `json:"resource_id"`
			Nlink      uint32 `json:"nlink"`
		} `json:"entries"`
	}
	if err := json.Unmarshal(root.Body.Bytes(), &rootBody); err != nil {
		t.Fatal(err)
	}
	if len(rootBody.Entries) != 2 || rootBody.Entries[0].Name != "p1" || rootBody.Entries[1].Name != "p2" {
		t.Fatalf("root entries = %+v, want p1,p2", rootBody.Entries)
	}
	if !rootBody.Entries[0].IsDir || rootBody.Entries[0].ResourceID == "" {
		t.Fatalf("readable p1 entry = %+v, want real directory metadata", rootBody.Entries[0])
	}
	if !rootBody.Entries[1].IsDir || rootBody.Entries[1].ResourceID != "" || rootBody.Entries[1].HasMode || rootBody.Entries[1].Nlink != 2 {
		t.Fatalf("list-only p2 entry = %+v, want synthetic directory metadata", rootBody.Entries[1])
	}

	target := callPseudoRootList(t, s, scope, "/p1")
	if target.Code != http.StatusOK {
		t.Fatalf("p1 list status = %d body=%s, want 200", target.Code, target.Body.String())
	}
	var targetBody struct {
		Entries []struct {
			Name string `json:"name"`
		} `json:"entries"`
	}
	if err := json.Unmarshal(target.Body.Bytes(), &targetBody); err != nil {
		t.Fatal(err)
	}
	if len(targetBody.Entries) != 1 || targetBody.Entries[0].Name != "a.txt" {
		t.Fatalf("p1 entries = %+v, want a.txt", targetBody.Entries)
	}

	hidden := callPseudoRootList(t, s, scope, "/p3")
	if hidden.Code != http.StatusNotFound {
		t.Fatalf("p3 list status = %d body=%s, want 404", hidden.Code, hidden.Body.String())
	}
}

func TestHandleListEmptyPseudoRootDoesNotExposeActualEntries(t *testing.T) {
	s := newTestServer(t)
	if err := s.fallback.MkdirCtx(context.Background(), "/secret", 0o755); err != nil {
		t.Fatal(err)
	}
	scope := &TenantScope{IsScoped: true, Backend: s.fallback, FSPseudoRoots: []string{"/"}}

	response := callPseudoRootList(t, s, scope, "/")
	if response.Code != http.StatusOK || response.Body.String() != "{\"entries\":[]}\n" {
		t.Fatalf("empty pseudoroot response = status %d body %s", response.Code, response.Body.String())
	}
}

func TestHandleListOmitsFileValuedProjectedAncestor(t *testing.T) {
	s := newTestServer(t)
	if err := s.fallback.CreateCtx(context.Background(), "/file"); err != nil {
		t.Fatal(err)
	}
	scope := &TenantScope{
		IsScoped:      true,
		Backend:       s.fallback,
		FSPseudoRoots: []string{"/"},
		FSScopes: []FSScope{{
			Prefix: "/file/target",
			Ops:    map[FSOp]bool{FSOpList: true},
		}},
	}

	response := callPseudoRootList(t, s, scope, "/")
	if response.Code != http.StatusOK || response.Body.String() != "{\"entries\":[]}\n" {
		t.Fatalf("file-valued ancestor response = status %d body %s", response.Code, response.Body.String())
	}
}

func callPseudoRootList(t *testing.T, s *Server, scope *TenantScope, path string) *httptest.ResponseRecorder {
	t.Helper()
	r := httptest.NewRequest(http.MethodGet, "/v1/fs"+path+"?list", nil)
	r = r.WithContext(withScope(r.Context(), scope))
	w := httptest.NewRecorder()
	s.handleList(w, r, path)
	return w
}

func TestPseudoRootStatSurfacesUseSyntheticMetadata(t *testing.T) {
	s := newTestServer(t)
	ctx := context.Background()
	for _, dir := range []string{"/a", "/a/b", "/secret"} {
		if err := s.fallback.MkdirCtx(ctx, dir, 0o755); err != nil {
			t.Fatalf("mkdir %s: %v", dir, err)
		}
	}
	if err := s.fallback.CreateCtx(ctx, "/a/b/file.txt"); err != nil {
		t.Fatal(err)
	}
	scope := &TenantScope{
		IsScoped:      true,
		Backend:       s.fallback,
		FSPseudoRoots: []string{"/"},
		FSScopes: []FSScope{{
			Prefix: "/a/b",
			Ops:    map[FSOp]bool{FSOpList: true},
		}},
	}

	head := callPseudoRootStat(t, s, scope, http.MethodHead, "/a", "")
	if head.Code != http.StatusOK || head.Header().Get("X-Dat9-IsDir") != "true" || head.Header().Get("Content-Length") != "0" || head.Header().Get("X-Dat9-Nlink") != "2" {
		t.Fatalf("projected HEAD = status %d headers=%v", head.Code, head.Header())
	}
	for _, hiddenHeader := range []string{"X-Dat9-Resource-ID", "X-Dat9-Revision", "X-Dat9-Mode", "X-Dat9-Checksum-SHA256", "X-Dat9-Storage-Type"} {
		if got := head.Header().Get(hiddenHeader); got != "" {
			t.Fatalf("projected HEAD %s = %q, want empty", hiddenHeader, got)
		}
	}

	metadata := callPseudoRootStat(t, s, scope, http.MethodGet, "/a", "stat=1")
	if metadata.Code != http.StatusOK {
		t.Fatalf("projected metadata stat status = %d body=%s", metadata.Code, metadata.Body.String())
	}
	var metadataBody map[string]any
	if err := json.Unmarshal(metadata.Body.Bytes(), &metadataBody); err != nil {
		t.Fatal(err)
	}
	if metadataBody["isdir"] != true || metadataBody["size"] != float64(0) || metadataBody["nlink"] != float64(2) || metadataBody["mtime"] != float64(0) {
		t.Fatalf("projected metadata = %+v", metadataBody)
	}
	if metadataBody["resource_id"] != "" || metadataBody["revision"] != float64(0) || metadataBody["content_type"] != "" || metadataBody["semantic_text"] != "" {
		t.Fatalf("projected metadata leaked fields: %+v", metadataBody)
	}

	batch := callPseudoRootBatchStat(t, s, scope, `{"paths":["/a","/a/b","/secret"]}`)
	if batch.Code != http.StatusOK {
		t.Fatalf("batch stat status = %d body=%s", batch.Code, batch.Body.String())
	}
	var batchBody batchStatResponse
	if err := json.Unmarshal(batch.Body.Bytes(), &batchBody); err != nil {
		t.Fatal(err)
	}
	if len(batchBody.Results) != 3 || batchBody.Results[0].Status != http.StatusOK || !batchBody.Results[0].IsDir || batchBody.Results[0].Nlink != 2 || batchBody.Results[1].Status != http.StatusOK || batchBody.Results[2].Status != http.StatusNotFound {
		t.Fatalf("batch results = %+v", batchBody.Results)
	}

	visibleFile := callPseudoRootStat(t, s, scope, http.MethodHead, "/a/b/file.txt", "")
	if visibleFile.Code != http.StatusForbidden {
		t.Fatalf("list-visible file stat status = %d, want 403", visibleFile.Code)
	}
}

func TestPseudoRootStatHidesFileValuedAncestor(t *testing.T) {
	s := newTestServer(t)
	if err := s.fallback.CreateCtx(context.Background(), "/file"); err != nil {
		t.Fatal(err)
	}
	scope := &TenantScope{
		IsScoped:      true,
		Backend:       s.fallback,
		FSPseudoRoots: []string{"/"},
		FSScopes: []FSScope{{
			Prefix: "/file/target",
			Ops:    map[FSOp]bool{FSOpList: true},
		}},
	}
	response := callPseudoRootStat(t, s, scope, http.MethodHead, "/file", "")
	if response.Code != http.StatusNotFound {
		t.Fatalf("file-valued ancestor stat status = %d, want 404", response.Code)
	}
}

func TestPseudoRootListAndStatExistenceConsistency(t *testing.T) {
	s := newTestServer(t)
	missingAncestorScope := &TenantScope{
		IsScoped:      true,
		Backend:       s.fallback,
		FSPseudoRoots: []string{"/"},
		FSScopes: []FSScope{{
			Prefix: "/missing/target",
			Ops:    map[FSOp]bool{FSOpList: true},
		}},
	}
	missingList := callPseudoRootList(t, s, missingAncestorScope, "/missing")
	missingStat := callPseudoRootStat(t, s, missingAncestorScope, http.MethodHead, "/missing", "")
	if missingList.Code != http.StatusNotFound || missingStat.Code != http.StatusNotFound {
		t.Fatalf("missing ancestor LIST=%d STAT=%d, want 404/404", missingList.Code, missingStat.Code)
	}

	missingBoundaryScope := &TenantScope{
		IsScoped:      true,
		Backend:       s.fallback,
		FSPseudoRoots: []string{"/team"},
		FSScopes: []FSScope{{
			Prefix: "/team/a",
			Ops:    map[FSOp]bool{FSOpList: true},
		}},
	}
	boundaryList := callPseudoRootList(t, s, missingBoundaryScope, "/team")
	boundaryStat := callPseudoRootStat(t, s, missingBoundaryScope, http.MethodHead, "/team", "")
	if boundaryList.Code != http.StatusOK || boundaryStat.Code != http.StatusOK {
		t.Fatalf("missing boundary LIST=%d STAT=%d, want 200/200", boundaryList.Code, boundaryStat.Code)
	}
	if boundaryList.Body.String() != "{\"entries\":[]}\n" {
		t.Fatalf("missing boundary body=%s, want empty entries", boundaryList.Body.String())
	}
}

func TestPseudoRootUploadsListHidesUnprojectedPath(t *testing.T) {
	s := newTestServer(t)
	scope := &TenantScope{
		IsScoped:      true,
		Backend:       s.fallback,
		FSPseudoRoots: []string{"/"},
		FSScopes: []FSScope{{
			Prefix: "/p1",
			Ops:    map[FSOp]bool{FSOpList: true},
		}},
	}
	r := httptest.NewRequest(http.MethodGet, "/v1/uploads?path=/secret", nil)
	r = r.WithContext(withScope(r.Context(), scope))
	w := httptest.NewRecorder()
	s.handleUploads(w, r)
	if w.Code != http.StatusNotFound {
		t.Fatalf("hidden uploads list status=%d body=%s, want 404", w.Code, w.Body.String())
	}
}

func callPseudoRootStat(t *testing.T, s *Server, scope *TenantScope, method, path, query string) *httptest.ResponseRecorder {
	t.Helper()
	url := "/v1/fs" + path
	if query != "" {
		url += "?" + query
	}
	r := httptest.NewRequest(method, url, nil)
	r = r.WithContext(withScope(r.Context(), scope))
	w := httptest.NewRecorder()
	if method == http.MethodHead {
		s.handleStat(w, r, path)
	} else {
		s.handleStatMetadata(w, r, path)
	}
	return w
}

func callPseudoRootBatchStat(t *testing.T, s *Server, scope *TenantScope, body string) *httptest.ResponseRecorder {
	t.Helper()
	r := httptest.NewRequest(http.MethodPost, "/v1/fs:batch-stat", strings.NewReader(body))
	r = r.WithContext(withScope(r.Context(), scope))
	w := httptest.NewRecorder()
	s.handleBatchStat(w, r)
	return w
}
