package iceberg

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"path"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/seaweedfs/seaweedfs/weed/filer"
	"github.com/seaweedfs/seaweedfs/weed/glog"
	"github.com/seaweedfs/seaweedfs/weed/operation"
	"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
	"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
	"github.com/seaweedfs/seaweedfs/weed/security"
	"github.com/seaweedfs/seaweedfs/weed/util"
	util_http "github.com/seaweedfs/seaweedfs/weed/util/http"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
)

// filerFileEntry holds a non-directory entry with its full directory path.
type filerFileEntry struct {
	Dir   string
	Entry *filer_pb.Entry
}

var initGlobalHTTPClientOnce sync.Once

type singleFilerClient struct {
	client filer_pb.SeaweedFilerClient
}

func (c singleFilerClient) WithFilerClient(_ bool, fn func(filer_pb.SeaweedFilerClient) error) error {
	return fn(c.client)
}

func (c singleFilerClient) AdjustedUrl(location *filer_pb.Location) string {
	if location == nil {
		return ""
	}
	if location.PublicUrl != "" {
		return location.PublicUrl
	}
	return location.Url
}

func (c singleFilerClient) GetDataCenter() string {
	return ""
}

// listFilerEntries lists all entries in a directory.
func listFilerEntries(ctx context.Context, client filer_pb.SeaweedFilerClient, dir, prefix string) ([]*filer_pb.Entry, error) {
	var entries []*filer_pb.Entry
	var lastFileName string
	limit := uint32(10000)

	for {
		resp, err := client.ListEntries(ctx, &filer_pb.ListEntriesRequest{
			Directory:          dir,
			Prefix:             prefix,
			StartFromFileName:  lastFileName,
			InclusiveStartFrom: lastFileName == "",
			Limit:              limit,
		})
		if err != nil {
			// Treat not-found as empty directory; propagate other errors.
			if status.Code(err) == codes.NotFound {
				return entries, nil
			}
			return entries, fmt.Errorf("list entries in %s: %w", dir, err)
		}

		count := 0
		for {
			entry, recvErr := resp.Recv()
			if recvErr != nil {
				if recvErr == io.EOF {
					break
				}
				return entries, fmt.Errorf("recv entry in %s: %w", dir, recvErr)
			}
			if entry.Entry != nil {
				entries = append(entries, entry.Entry)
				lastFileName = entry.Entry.Name
				count++
			}
		}

		if count < int(limit) {
			break
		}
	}

	return entries, nil
}

// walkFilerEntries recursively lists all non-directory entries under dir.
func walkFilerEntries(ctx context.Context, client filer_pb.SeaweedFilerClient, dir string) ([]filerFileEntry, error) {
	entries, err := listFilerEntries(ctx, client, dir, "")
	if err != nil {
		return nil, err
	}

	var result []filerFileEntry
	for _, entry := range entries {
		if entry.IsDirectory {
			subDir := path.Join(dir, entry.Name)
			subEntries, err := walkFilerEntries(ctx, client, subDir)
			if err != nil {
				glog.V(2).Infof("iceberg maintenance: cannot walk %s: %v", subDir, err)
				continue
			}
			result = append(result, subEntries...)
		} else {
			result = append(result, filerFileEntry{Dir: dir, Entry: entry})
		}
	}
	return result, nil
}

// loadCurrentMetadata loads and parses the current Iceberg metadata from the table entry's xattr.
func loadCurrentMetadata(ctx context.Context, client filer_pb.SeaweedFilerClient, bucketName, tablePath string) (*tableState, error) {
	dir := path.Join(s3tables.TablesPath, bucketName, path.Dir(tablePath))
	name := path.Base(tablePath)

	resp, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
		Directory: dir,
		Name:      name,
	})
	if err != nil {
		return nil, fmt.Errorf("lookup table entry %s/%s: %w", dir, name, err)
	}
	if resp == nil || resp.Entry == nil {
		return nil, fmt.Errorf("table entry not found: %s/%s", dir, name)
	}

	metadataBytes, ok := resp.Entry.Extended[s3tables.ExtendedKeyMetadata]
	if !ok || len(metadataBytes) == 0 {
		return nil, fmt.Errorf("no metadata xattr on table entry %s/%s", dir, name)
	}

	state, err := parseTableMetadataEnvelope(metadataBytes, bucketName, tablePath)
	if err != nil {
		return nil, err
	}
	// Refuse to rewrite an entry that only looks like an Iceberg table. Every
	// operation here rewrites or deletes files under the table location, and a
	// foreign format's files are unreferenced by Iceberg metadata by definition.
	if !isIcebergTableEntry(resp.Entry.Extended, state.Metadata) {
		return nil, fmt.Errorf("entry %s/%s is not an iceberg table", dir, name)
	}
	return state, nil
}

// loadFileByIcebergPath loads a file from the filer given an Iceberg-style path.
// dataPath is the bucket-relative directory the table's files live in.
func loadFileByIcebergPath(ctx context.Context, client filer_pb.SeaweedFilerClient, bucketName, dataPath, icebergPath string) ([]byte, error) {
	fullPath, err := icebergFilerPath(bucketName, dataPath, icebergPath)
	if err != nil {
		return nil, err
	}
	dir, fileName := path.Dir(fullPath), path.Base(fullPath)

	resp, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
		Directory: dir,
		Name:      fileName,
	})
	if err != nil {
		return nil, fmt.Errorf("lookup %s/%s: %w", dir, fileName, err)
	}
	if resp == nil || resp.Entry == nil {
		return nil, fmt.Errorf("file not found: %s/%s", dir, fileName)
	}

	if len(resp.Entry.Content) > 0 || len(resp.Entry.Chunks) == 0 {
		return resp.Entry.Content, nil
	}

	initGlobalHTTPClientOnce.Do(util_http.InitGlobalHttpClient)
	reader := filer.NewFileReader(singleFilerClient{client: client}, resp.Entry)
	if closer, ok := reader.(io.Closer); ok {
		defer closer.Close()
	}
	data, err := io.ReadAll(reader)
	if err != nil {
		return nil, fmt.Errorf("read chunked file %s/%s: %w", dir, fileName, err)
	}
	return data, nil
}

// normalizeIcebergPath converts an Iceberg path (an S3 URL, an absolute filer
// path, or a path relative to the table root) into a path relative to the
// bucket root, e.g. "ns/table/metadata/snap-1.avro". Absolute references are
// resolved from the bucket root rather than from dataPath: an Iceberg table
// records where its files actually are, and a client writing through the REST
// catalog may place them outside the catalog path. The bucket-relative form is
// the canonical key for comparing references that mix both spellings.
func normalizeIcebergPath(icebergPath, bucketName, dataPath string) (string, error) {
	p := icebergPath
	absolute := false
	if idx := strings.Index(p, "://"); idx >= 0 {
		p = p[idx+3:]
		absolute = true
	} else if strings.HasPrefix(p, "/") {
		absolute = true
	}
	p = strings.TrimPrefix(p, "/")

	if absolute {
		rest, ok := cutPathPrefix(p, bucketName)
		if !ok {
			// Filer-style references carry the /buckets prefix ahead of the bucket.
			if withoutTablesPath, hasTablesPath := cutPathPrefix(p, strings.TrimPrefix(s3tables.TablesPath, "/")); hasTablesPath {
				rest, ok = cutPathPrefix(withoutTablesPath, bucketName)
			}
		}
		if !ok {
			return "", fmt.Errorf("iceberg path %q is outside bucket %q", icebergPath, bucketName)
		}
		p = rest
	} else {
		if clean := path.Clean(p); clean == ".." || strings.HasPrefix(clean, "../") {
			return "", fmt.Errorf("invalid iceberg path %q", icebergPath)
		}
		p = path.Join(dataPath, p)
	}

	p = path.Clean(p)
	if p == "." || p == ".." || strings.HasPrefix(p, "../") {
		return "", fmt.Errorf("invalid iceberg path %q", icebergPath)
	}
	return p, nil
}

// cutPathPrefix removes a leading path prefix at a path boundary.
func cutPathPrefix(p, segment string) (string, bool) {
	if segment == "" {
		return p, false
	}
	rest, ok := strings.CutPrefix(p, segment+"/")
	return rest, ok
}

// icebergFilerPath resolves an Iceberg path to the filer path holding the file.
func icebergFilerPath(bucketName, dataPath, icebergPath string) (string, error) {
	relPath, err := normalizeIcebergPath(icebergPath, bucketName, dataPath)
	if err != nil {
		return "", err
	}
	return path.Join(s3tables.TablesPath, bucketName, relPath), nil
}

// absoluteIcebergPath is the inverse of normalizeIcebergPath: it builds the
// absolute s3:// URI for a bucket-relative path. The Iceberg spec requires
// absolute locations in metadata — strict readers (Spark/Trino via S3FileIO)
// reject paths with no scheme.
func absoluteIcebergPath(bucketName string, elem ...string) string {
	return "s3://" + path.Join(append([]string{bucketName}, elem...)...)
}

const (
	// inlineContentLimit is the most saveFilerFile keeps in Entry.Content.
	// Manifests and metadata JSON are small and stay inline; a compacted data
	// file runs to hundreds of MB, and inlining that stores the parquet bytes
	// verbatim in the filer store and ships them again through the metadata
	// change log as one oversized event.
	inlineContentLimit = 256 * 1024
	// filerFileChunkSize is the upload chunk size for anything over the limit.
	filerFileChunkSize = 8 * 1024 * 1024
)

// saveFilerFile saves a file to the filer, inline when it is small and as
// volume chunks when it is not.
func saveFilerFile(ctx context.Context, client filer_pb.SeaweedFilerClient, dir, fileName string, content []byte) error {
	entry := &filer_pb.Entry{
		Name: fileName,
		Attributes: &filer_pb.FuseAttributes{
			Mtime:    time.Now().Unix(),
			Crtime:   time.Now().Unix(),
			FileMode: uint32(0644),
			FileSize: uint64(len(content)),
		},
	}
	if len(content) <= inlineContentLimit {
		entry.Content = content
	} else {
		chunks, err := uploadFilerChunks(ctx, client, path.Join(dir, fileName), content)
		if err != nil {
			return fmt.Errorf("upload %s/%s: %w", dir, fileName, err)
		}
		entry.Chunks = chunks
	}

	resp, err := client.CreateEntry(ctx, &filer_pb.CreateEntryRequest{
		Directory: dir,
		Entry:     entry,
	})
	if err != nil {
		return fmt.Errorf("create entry %s/%s: %w", dir, fileName, err)
	}
	if resp.Error != "" {
		return fmt.Errorf("create entry %s/%s: %s", dir, fileName, resp.Error)
	}
	return nil
}

// uploadFilerChunks writes content to volume servers, assigning through the
// filer so the entry's storage rules apply.
func uploadFilerChunks(ctx context.Context, client filer_pb.SeaweedFilerClient, fullPath string, content []byte) ([]*filer_pb.FileChunk, error) {
	assignFn := func(ctx context.Context, count int, expectedDataSize uint64) (*operation.VolumeAssignRequest, *operation.AssignResult, error) {
		resp, err := client.AssignVolume(ctx, &filer_pb.AssignVolumeRequest{
			Count:            int32(count),
			Path:             fullPath,
			ExpectedDataSize: expectedDataSize,
		})
		if err != nil {
			return nil, nil, err
		}
		if resp.Error != "" {
			return nil, nil, errors.New(resp.Error)
		}
		if resp.Location == nil || resp.FileId == "" {
			return nil, nil, fmt.Errorf("assign volume returned no location")
		}
		return nil, &operation.AssignResult{
			Fid:       resp.FileId,
			Url:       resp.Location.Url,
			PublicUrl: resp.Location.PublicUrl,
			Count:     uint64(count),
			Auth:      security.EncodedJwt(resp.Auth),
			Fsync:     resp.Fsync,
		}, nil
	}

	initGlobalHTTPClientOnce.Do(util_http.InitGlobalHttpClient)
	result, err := operation.UploadReaderInChunks(ctx, util.NewBytesReader(content), &operation.ChunkedUploadOption{
		ChunkSize:  filerFileChunkSize,
		AssignFunc: assignFn,
	})
	if err != nil {
		// Chunks uploaded before the failure are orphaned: nothing references
		// them, so name them for fsck rather than losing them silently.
		if result != nil {
			for _, chunk := range result.FileChunks {
				glog.Warningf("iceberg: orphan chunk %s from failed upload of %s", chunk.GetFileIdString(), fullPath)
			}
		}
		return nil, err
	}
	return result.FileChunks, nil
}

// deleteFilerFile deletes a file from the filer.
func deleteFilerFile(ctx context.Context, client filer_pb.SeaweedFilerClient, dir, fileName string) error {
	return filer_pb.DoRemove(ctx, client, dir, fileName, true, false, true, false, nil)
}

// updateTableMetadataXattr updates the table entry's metadata xattr with
// the new Iceberg metadata. It verifies the stored metadataVersion before
// writing and passes the previous metadata xattr back to the filer as a
// server-side precondition so concurrent writers fail with a retryable
// metadata version conflict.
// newMetadataLocation is the absolute location of the new metadata file
// (e.g. "s3://bucket/ns/table/metadata/v3.metadata.json"), matching the form
// the S3 Tables catalog stores.
func updateTableMetadataXattr(ctx context.Context, client filer_pb.SeaweedFilerClient, tableDir string, expectedVersion int, newFullMetadata []byte, newMetadataLocation string) error {
	tableName := path.Base(tableDir)
	parentDir := path.Dir(tableDir)

	resp, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
		Directory: parentDir,
		Name:      tableName,
	})
	if err != nil {
		return fmt.Errorf("lookup table entry: %w", err)
	}
	if resp == nil || resp.Entry == nil {
		return fmt.Errorf("table entry not found")
	}

	existingXattr, ok := resp.Entry.Extended[s3tables.ExtendedKeyMetadata]
	if !ok {
		return fmt.Errorf("no metadata xattr on table entry")
	}

	// Parse existing xattr, update fullMetadata
	var internalMeta map[string]json.RawMessage
	if err := json.Unmarshal(existingXattr, &internalMeta); err != nil {
		return fmt.Errorf("unmarshal existing xattr: %w", err)
	}

	// Verify the stored metadataVersion matches what we expect before issuing
	// the conditional UpdateEntry request below.
	versionRaw, ok := internalMeta["metadataVersion"]
	if !ok {
		return fmt.Errorf("%w: metadataVersion field missing from xattr", errMetadataVersionConflict)
	}
	var storedVersion int
	if err := json.Unmarshal(versionRaw, &storedVersion); err != nil {
		return fmt.Errorf("%w: cannot parse metadataVersion: %v", errMetadataVersionConflict, err)
	}
	if storedVersion != expectedVersion {
		return fmt.Errorf("%w: expected version %d, found %d", errMetadataVersionConflict, expectedVersion, storedVersion)
	}

	// Update the metadata.fullMetadata field
	var metadataObj map[string]json.RawMessage
	if raw, ok := internalMeta["metadata"]; ok {
		if err := json.Unmarshal(raw, &metadataObj); err != nil {
			return fmt.Errorf("unmarshal metadata object: %w", err)
		}
	} else {
		metadataObj = make(map[string]json.RawMessage)
	}
	metadataObj["fullMetadata"] = newFullMetadata
	metadataJSON, err := json.Marshal(metadataObj)
	if err != nil {
		return fmt.Errorf("marshal metadata object: %w", err)
	}
	internalMeta["metadata"] = metadataJSON

	// Increment version
	newVersion := expectedVersion + 1
	versionJSON, _ := json.Marshal(newVersion)
	internalMeta["metadataVersion"] = versionJSON

	// Update modifiedAt
	modifiedAt, _ := json.Marshal(time.Now().Format(time.RFC3339Nano))
	internalMeta["modifiedAt"] = modifiedAt

	// Update metadataLocation to point to the new metadata file
	metaLocJSON, _ := json.Marshal(newMetadataLocation)
	internalMeta["metadataLocation"] = metaLocJSON

	// Regenerate versionToken for consistency with the S3 Tables catalog
	tokenJSON, _ := json.Marshal(generateIcebergVersionToken())
	internalMeta["versionToken"] = tokenJSON

	updatedXattr, err := json.Marshal(internalMeta)
	if err != nil {
		return fmt.Errorf("marshal updated xattr: %w", err)
	}

	// Assert the whole attribute set, not just the version: this rewrites the
	// entry from the snapshot above, so a narrower precondition would delete an
	// attribute a concurrent writer created, such as a maintenance configuration.
	expectedExtended := s3tables.SnapshotExtended(resp.Entry.Extended)
	resp.Entry.Extended[s3tables.ExtendedKeyMetadata] = updatedXattr
	resp.Entry.Extended[s3tables.ExtendedKeyMetadataVersion] = metadataVersionXattr(newVersion)
	_, err = client.UpdateEntry(ctx, &filer_pb.UpdateEntryRequest{
		Directory:        parentDir,
		Entry:            resp.Entry,
		ExpectedExtended: expectedExtended,
	})
	if err != nil {
		if status.Code(err) == codes.FailedPrecondition {
			return fmt.Errorf("%w: table metadata changed during update", errMetadataVersionConflict)
		}
		return fmt.Errorf("update table entry: %w", err)
	}
	return nil
}

func metadataVersionXattr(version int) []byte {
	return []byte(strconv.Itoa(version))
}

// generateIcebergVersionToken produces a random hex token, mirroring the
// logic in s3tables.generateVersionToken (which is unexported).
func generateIcebergVersionToken() string {
	b := make([]byte, 16)
	if _, err := rand.Read(b); err != nil {
		return fmt.Sprintf("%x", time.Now().UnixNano())
	}
	return hex.EncodeToString(b)
}
