package dash

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"path"
	"slices"
	"strings"
	"time"
	"unicode/utf8"

	"github.com/apache/iceberg-go"
	"github.com/parquet-go/parquet-go"
	"github.com/seaweedfs/seaweedfs/weed/admin/plugin"
	"github.com/seaweedfs/seaweedfs/weed/filer"
	"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
	"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
)

const (
	icebergPreviewDefaultRows  = 50
	icebergPreviewMaxRows      = 200
	icebergPreviewMaxFiles     = 5
	icebergPreviewMaxManifests = 100
	icebergPreviewMaxListed    = 500
	icebergPreviewMaxCellChars = 200
	icebergPreviewMaxMetaBytes = 64 << 20

	// workerPreviewTimeout bounds one round trip to a worker. A page waiting on
	// a worker that has gone quiet should say so rather than hang.
	workerPreviewTimeout = 15 * time.Second
)

type IcebergDataFileInfo struct {
	Path        string `json:"path"`
	Format      string `json:"format"`
	RecordCount int64  `json:"record_count"`
	SizeBytes   int64  `json:"size_bytes"`
}

// IcebergDataPreviewData backs the table data-preview page. PreviewError and
// PreviewNotes report per-snapshot problems without failing the whole page.
type IcebergDataPreviewData struct {
	Username          string                `json:"username"`
	CatalogName       string                `json:"catalog_name"`
	NamespaceName     string                `json:"namespace_name"`
	TableName         string                `json:"table_name"`
	BucketARN         string                `json:"bucket_arn"`
	SnapshotID        int64                 `json:"snapshot_id"`
	SnapshotTime      time.Time             `json:"snapshot_time"`
	CurrentSnapshotID int64                 `json:"current_snapshot_id"`
	Snapshots         []IcebergSnapshotInfo `json:"snapshots"`
	Columns           []string              `json:"columns"`
	Rows              [][]string            `json:"rows"`
	RowLimit          int                   `json:"row_limit"`
	ScannedFiles      int                   `json:"scanned_files"`
	SelectedFile      string                `json:"selected_file"`
	DataFiles         []IcebergDataFileInfo `json:"data_files"`
	TotalDataFiles    int                   `json:"total_data_files"`
	TotalRecords      int64                 `json:"total_records"`
	HasDeletes        bool                  `json:"has_deletes"`
	PreviewNotes      []string              `json:"preview_notes,omitempty"`
	PreviewError      string                `json:"preview_error,omitempty"`
	LastUpdated       time.Time             `json:"last_updated"`
	// Format is what the catalog recorded. Anything but ICEBERG means the rows
	// below, if any, came from a worker rather than from metadata this server read.
	Format      string `json:"format,omitempty"`
	PreviewedBy string `json:"previewed_by,omitempty"`
}

// GetIcebergTableDataPreview walks the selected snapshot's manifests and reads
// sample rows from its Parquet data files. snapshotID 0 means the current
// snapshot; selectedFile restricts the preview to one manifest-listed file.
func (s *AdminServer) GetIcebergTableDataPreview(ctx context.Context, catalogName, bucketArn, namespace, tableName string, snapshotID int64, selectedFile string, rowLimit int) (IcebergDataPreviewData, error) {
	if rowLimit < 1 {
		rowLimit = icebergPreviewDefaultRows
	}
	if rowLimit > icebergPreviewMaxRows {
		rowLimit = icebergPreviewMaxRows
	}

	data := IcebergDataPreviewData{
		CatalogName:   catalogName,
		NamespaceName: namespace,
		TableName:     tableName,
		BucketARN:     bucketArn,
		RowLimit:      rowLimit,
		SelectedFile:  selectedFile,
		LastUpdated:   time.Now(),
	}

	namespaceParts, err := parseNamespaceInput(namespace)
	if err != nil {
		return data, err
	}
	var resp s3tables.GetTableResponse
	req := &s3tables.GetTableRequest{TableBucketARN: bucketArn, Namespace: namespaceParts, Name: tableName}
	if err := s.executeS3TablesOperation(ctx, "GetTable", req, &resp); err != nil {
		return data, err
	}
	data.Format = resp.Format
	if !strings.EqualFold(resp.Format, s3tables.FormatIceberg) && resp.Format != "" {
		s.applyWorkerPreview(ctx, &data, bucketArn, namespaceParts, tableName, rowLimit)
		return data, nil
	}
	if resp.Metadata == nil || len(resp.Metadata.FullMetadata) == 0 {
		data.PreviewError = "Table has no Iceberg metadata."
		return data, nil
	}
	var full icebergFullMetadata
	if err := json.Unmarshal(resp.Metadata.FullMetadata, &full); err != nil {
		data.PreviewError = fmt.Sprintf("Failed to parse Iceberg metadata: %v", err)
		return data, nil
	}
	data.Snapshots = snapshotsFromFullMetadata(full.Snapshots)
	data.CurrentSnapshotID = full.CurrentSnapshotID

	snap := selectPreviewSnapshot(full, snapshotID)
	if snap == nil {
		if snapshotID != 0 {
			data.PreviewError = fmt.Sprintf("Snapshot %d not found.", snapshotID)
		} else {
			data.PreviewError = "Table has no snapshots yet."
		}
		return data, nil
	}
	data.SnapshotID = snap.SnapshotID
	if snap.TimestampMs > 0 {
		data.SnapshotTime = time.Unix(0, snap.TimestampMs*int64(time.Millisecond))
	}
	if snap.ManifestList == "" {
		data.PreviewError = "Snapshot has no manifest list."
		return data, nil
	}

	dataFiles, hasDeletes, notes, err := s.listIcebergDataFiles(ctx, catalogName, namespace, tableName, snap.ManifestList)
	if err != nil {
		data.PreviewError = fmt.Sprintf("Failed to read snapshot manifests: %v", err)
		return data, nil
	}
	data.HasDeletes = hasDeletes
	data.PreviewNotes = notes
	data.TotalDataFiles = len(dataFiles)
	for _, f := range dataFiles {
		data.TotalRecords += f.RecordCount
	}
	data.DataFiles = dataFiles
	if len(dataFiles) > icebergPreviewMaxListed {
		data.DataFiles = dataFiles[:icebergPreviewMaxListed]
		data.PreviewNotes = append(data.PreviewNotes, fmt.Sprintf("Listing first %d of %d data files.", icebergPreviewMaxListed, len(dataFiles)))
	}

	toScan := dataFiles
	if selectedFile != "" {
		toScan = nil
		for _, f := range dataFiles {
			if f.Path == selectedFile {
				toScan = []IcebergDataFileInfo{f}
				break
			}
		}
		if toScan == nil {
			data.PreviewError = "Requested data file is not part of this snapshot."
			return data, nil
		}
	}
	if len(toScan) == 0 {
		data.PreviewNotes = append(data.PreviewNotes, "Snapshot contains no data files.")
		return data, nil
	}

	for _, f := range toScan {
		if len(data.Rows) >= rowLimit || data.ScannedFiles >= icebergPreviewMaxFiles {
			break
		}
		if !strings.EqualFold(f.Format, string(iceberg.ParquetFile)) {
			data.PreviewNotes = append(data.PreviewNotes, fmt.Sprintf("Skipped %s: %s preview is not supported.", path.Base(f.Path), f.Format))
			continue
		}
		filerPath, err := icebergLocationToFilerPath(f.Path, catalogName, namespace, tableName)
		if err != nil {
			data.PreviewNotes = append(data.PreviewNotes, fmt.Sprintf("Skipped %s: %v", path.Base(f.Path), err))
			continue
		}
		cols, rows, err := s.readParquetRows(ctx, filerPath, rowLimit-len(data.Rows))
		if err != nil {
			data.PreviewNotes = append(data.PreviewNotes, fmt.Sprintf("Failed to read %s: %v", path.Base(f.Path), err))
			data.ScannedFiles++
			continue
		}
		if data.Columns == nil {
			data.Columns = cols
		} else if !slices.Equal(data.Columns, cols) {
			data.PreviewNotes = append(data.PreviewNotes, fmt.Sprintf("Skipped %s: column layout differs from the first file.", path.Base(f.Path)))
			continue
		}
		data.Rows = append(data.Rows, rows...)
		data.ScannedFiles++
	}
	if hasDeletes {
		data.PreviewNotes = append(data.PreviewNotes, "Snapshot has row-level delete files; the preview shows raw data-file rows without applying deletes.")
	}
	return data, nil
}

func selectPreviewSnapshot(full icebergFullMetadata, snapshotID int64) *icebergSnapshot {
	if snapshotID != 0 {
		for i := range full.Snapshots {
			if full.Snapshots[i].SnapshotID == snapshotID {
				return &full.Snapshots[i]
			}
		}
		return nil
	}
	return selectSnapshotForMetrics(full)
}

func (s *AdminServer) listIcebergDataFiles(ctx context.Context, bucketName, namespaceDir, tableName, manifestListLocation string) (files []IcebergDataFileInfo, hasDeletes bool, notes []string, err error) {
	listPath, err := icebergLocationToFilerPath(manifestListLocation, bucketName, namespaceDir, tableName)
	if err != nil {
		return nil, false, nil, err
	}
	listBytes, err := s.readFilerFileContent(ctx, listPath)
	if err != nil {
		return nil, false, nil, fmt.Errorf("read manifest list %s: %w", manifestListLocation, err)
	}
	manifests, err := s3tables.ReadManifestList(listBytes)
	if err != nil {
		return nil, false, nil, fmt.Errorf("parse manifest list: %w", err)
	}

	scanned := 0
	for _, mf := range manifests {
		if mf.ManifestContent() != iceberg.ManifestContentData {
			hasDeletes = true
			continue
		}
		if scanned >= icebergPreviewMaxManifests {
			notes = append(notes, fmt.Sprintf("Scanned first %d of %d manifests.", icebergPreviewMaxManifests, len(manifests)))
			break
		}
		scanned++
		mfPath, err := icebergLocationToFilerPath(mf.FilePath(), bucketName, namespaceDir, tableName)
		if err != nil {
			notes = append(notes, fmt.Sprintf("Skipped manifest %s: %v", path.Base(mf.FilePath()), err))
			continue
		}
		mfBytes, err := s.readFilerFileContent(ctx, mfPath)
		if err != nil {
			notes = append(notes, fmt.Sprintf("Failed to read manifest %s: %v", path.Base(mf.FilePath()), err))
			continue
		}
		entries, err := iceberg.ReadManifest(mf, bytes.NewReader(mfBytes), true)
		if err != nil {
			notes = append(notes, fmt.Sprintf("Failed to parse manifest %s: %v", path.Base(mf.FilePath()), err))
			continue
		}
		for _, entry := range entries {
			df := entry.DataFile()
			files = append(files, IcebergDataFileInfo{
				Path:        df.FilePath(),
				Format:      string(df.FileFormat()),
				RecordCount: df.Count(),
				SizeBytes:   df.FileSizeBytes(),
			})
		}
	}
	return files, hasDeletes, notes, nil
}

// icebergLocationToFilerPath maps an Iceberg file location (s3://bucket/...,
// an absolute filer path, or a table-relative path like data/x.parquet) to a
// filer path, which must stay under the table-buckets root.
func icebergLocationToFilerPath(location, bucketName, namespaceDir, tableName string) (string, error) {
	p := location
	if idx := strings.Index(p, "://"); idx >= 0 {
		p = path.Join(s3tables.TablesPath, p[idx+3:])
	} else if strings.HasPrefix(p, "/") {
		p = path.Clean(p)
	} else {
		tableDir := path.Join(s3tables.TablesPath, bucketName, namespaceDir, tableName)
		p = path.Join(tableDir, p)
		if !strings.HasPrefix(p, tableDir+"/") {
			return "", fmt.Errorf("location %q resolves outside the table directory", location)
		}
	}
	p = path.Clean(p)
	if !strings.HasPrefix(p, s3tables.TablesPath+"/") {
		return "", fmt.Errorf("location %q resolves outside %s", location, s3tables.TablesPath)
	}
	return p, nil
}

func (s *AdminServer) lookupFilerEntry(ctx context.Context, fullPath string) (*filer_pb.Entry, error) {
	dir, name := path.Dir(fullPath), path.Base(fullPath)
	var entry *filer_pb.Entry
	err := s.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
		resp, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
			Directory: dir,
			Name:      name,
		})
		if err != nil {
			return err
		}
		entry = resp.Entry
		return nil
	})
	if err != nil {
		return nil, err
	}
	if entry == nil {
		return nil, fmt.Errorf("not found: %s", fullPath)
	}
	return entry, nil
}

func (s *AdminServer) readFilerFileContent(ctx context.Context, fullPath string) ([]byte, error) {
	entry, err := s.lookupFilerEntry(ctx, fullPath)
	if err != nil {
		return nil, err
	}
	if len(entry.Content) > 0 || len(entry.GetChunks()) == 0 {
		return entry.Content, nil
	}
	size := int64(filer.FileSize(entry))
	if size > icebergPreviewMaxMetaBytes {
		return nil, fmt.Errorf("file too large to load (%d bytes)", size)
	}
	streamFn, err := filer.PrepareStreamContentWithThrottler(ctx, s.GetMasterClient(), VolumeServerReadJwt, entry.GetChunks(), 0, size, 0)
	if err != nil {
		return nil, err
	}
	var buf bytes.Buffer
	if err := streamFn(&buf); err != nil {
		return nil, err
	}
	return buf.Bytes(), nil
}

func (s *AdminServer) readParquetRows(ctx context.Context, fullPath string, want int) ([]string, [][]string, error) {
	entry, err := s.lookupFilerEntry(ctx, fullPath)
	if err != nil {
		return nil, nil, err
	}
	var readerAt io.ReaderAt
	var size int64
	if len(entry.Content) > 0 || len(entry.GetChunks()) == 0 {
		readerAt = bytes.NewReader(entry.Content)
		size = int64(len(entry.Content))
	} else {
		size = int64(filer.FileSize(entry))
		readerAt = &filerChunkReaderAt{ctx: ctx, server: s, chunks: entry.GetChunks(), size: size}
	}

	pf, err := parquet.OpenFile(readerAt, size, parquet.SkipPageIndex(true), parquet.SkipBloomFilters(true))
	if err != nil {
		return nil, nil, fmt.Errorf("open parquet: %w", err)
	}
	leafColumns := pf.Schema().Columns()
	cols := make([]string, len(leafColumns))
	for i, c := range leafColumns {
		cols[i] = strings.Join(c, ".")
	}

	reader := parquet.NewReader(pf)
	defer reader.Close()
	rowBuf := make([]parquet.Row, 32)
	var out [][]string
	for len(out) < want {
		n, readErr := reader.ReadRows(rowBuf)
		for i := 0; i < n && len(out) < want; i++ {
			out = append(out, formatParquetRow(rowBuf[i], len(cols)))
		}
		if readErr != nil {
			if readErr == io.EOF {
				break
			}
			return nil, nil, readErr
		}
		if n == 0 {
			break
		}
	}
	return cols, out, nil
}

func formatParquetRow(row parquet.Row, columnCount int) []string {
	cells := make([]string, columnCount)
	seen := make([]bool, columnCount)
	for _, v := range row {
		ci := v.Column()
		if ci < 0 || ci >= columnCount {
			continue
		}
		cell := formatParquetValue(v)
		if seen[ci] {
			cells[ci] += ", " + cell
		} else {
			cells[ci] = cell
			seen[ci] = true
		}
	}
	return cells
}

func formatParquetValue(v parquet.Value) string {
	if v.IsNull() {
		return ""
	}
	switch v.Kind() {
	case parquet.ByteArray, parquet.FixedLenByteArray:
		b := v.ByteArray()
		if utf8.Valid(b) {
			return truncateCell(string(b))
		}
		return truncateCell(fmt.Sprintf("0x%x", b))
	default:
		return truncateCell(v.String())
	}
}

func truncateCell(s string) string {
	if len(s) <= icebergPreviewMaxCellChars {
		return s
	}
	cut := icebergPreviewMaxCellChars
	for cut > 0 && !utf8.RuneStart(s[cut]) {
		cut--
	}
	return s[:cut] + "…"
}

// filerChunkReaderAt serves random-access reads over a chunked filer entry by
// issuing ranged volume-server reads, carrying the read JWT when configured.
type filerChunkReaderAt struct {
	ctx    context.Context
	server *AdminServer
	chunks []*filer_pb.FileChunk
	size   int64
}

func (r *filerChunkReaderAt) ReadAt(p []byte, off int64) (int, error) {
	if off < 0 {
		return 0, fmt.Errorf("negative offset: %d", off)
	}
	if off >= r.size {
		return 0, io.EOF
	}
	want := int64(len(p))
	if off+want > r.size {
		want = r.size - off
	}
	streamFn, err := filer.PrepareStreamContentWithThrottler(r.ctx, r.server.GetMasterClient(), VolumeServerReadJwt, r.chunks, off, want, 0)
	if err != nil {
		return 0, err
	}
	// Write straight into p; a bytes.Buffer wrapping p would silently
	// allocate a fresh backing array if it ever grew, dropping bytes the
	// caller expects in p.
	w := &sliceWriter{dst: p}
	if err := streamFn(w); err != nil {
		return w.n, err
	}
	if w.n < len(p) {
		return w.n, io.EOF
	}
	return w.n, nil
}

// sliceWriter writes into a fixed destination slice, discarding any overflow.
type sliceWriter struct {
	dst []byte
	n   int
}

func (w *sliceWriter) Write(p []byte) (int, error) {
	c := copy(w.dst[w.n:], p)
	w.n += c
	return c, nil
}

// applyWorkerPreview asks the worker that last described this table for sample
// rows. Admin has no reader for a format it does not implement, so this is the
// only way the page shows anything but a location.
//
// The rows are fetched, never cached: they are the table's data rather than a
// description of it, and a stale copy sitting in admin would be worse than
// asking.
func (s *AdminServer) applyWorkerPreview(ctx context.Context, data *IcebergDataPreviewData, bucketArn string, namespaceParts []string, tableName string, rowLimit int) {
	plugin := s.GetPlugin()
	if plugin == nil {
		data.PreviewError = fmt.Sprintf("Reading a %s table needs a plugin worker, and none is configured.", data.Format)
		return
	}
	bucketName, err := s3tables.ParseBucketNameFromARN(bucketArn)
	if err != nil {
		data.PreviewError = err.Error()
		return
	}

	objectID := append(append([]string{bucketName}, namespaceParts...), tableName)
	requestCtx, cancel := context.WithTimeout(ctx, workerPreviewTimeout)
	defer cancel()

	response, err := plugin.RequestObjectPreview(requestCtx, objectID, data.Format, rowLimit)
	if err != nil {
		data.PreviewError = fmt.Sprintf("Could not read this %s table: %v", data.Format, err)
		return
	}

	data.Columns = response.Columns
	data.Rows = make([][]string, 0, len(response.Rows))
	for _, row := range response.Rows {
		cells := make([]string, len(row.Values))
		for i, value := range row.Values {
			cells[i] = truncateCell(value)
		}
		data.Rows = append(data.Rows, cells)
	}
	data.TotalRecords = response.TotalRows
	data.PreviewedBy = pluginWorkerForObject(plugin, objectID, data.Format)
	if int64(len(data.Rows)) < response.TotalRows {
		data.PreviewNotes = append(data.PreviewNotes, fmt.Sprintf("Showing %d of %d rows.", len(data.Rows), response.TotalRows))
	}
}

// pluginWorkerForObject names the worker that answered, for the page to show.
func pluginWorkerForObject(p *plugin.Plugin, objectID []string, format string) string {
	if observed, ok := p.Observations().GetFormat(objectID, format); ok {
		return observed.WorkerID
	}
	return ""
}
