package workspacefs

import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"io/fs"
	"os"
	"path/filepath"
	"strconv"
)

const (
	// maxMachineDirectoryRawEntries caps the browsable raw-entry prefix of a
	// single directory. It bounds per-request and per-directory work even when a
	// caller revisits pages.
	maxMachineDirectoryRawEntries = 1_000

	// maxMachineDirectoryPageBytes bounds the serialized successful response
	// envelope returned by the directory browsing endpoint. The descriptor's
	// transport limit is 512 KiB; 448 KiB leaves headroom for current/parent
	// paths and metadata without relying on a single-entry size ceiling.
	maxMachineDirectoryPageBytes = 448 * 1024

	// maxMachineDirectorySkipBatch bounds each ReadDir call during the cursor
	// skip and page scan phases. It must exceed any reasonable limit so a single
	// page scan does not agentteam needlessly, but stays small enough that
	// allocation pressure stays bounded.
	maxMachineDirectorySkipBatch = MaxListLimit
)

// ErrMachineDirectoryInvalid indicates the requested path or cursor was not a
// canonical absolute directory or a valid raw-entry offset. It is the only
// invalid sentinel; callers must not include the offending path in any user-
// facing message or log.
var ErrMachineDirectoryInvalid = errors.New("machine directory path is invalid")

// ErrMachineDirectoryUnreadable indicates the resolved directory could not be
// opened, stat, or read. The reason is intentionally absent; callers must not
// attach the path or OS error text.
var ErrMachineDirectoryUnreadable = errors.New("machine directory cannot be read")

// MachineDirectory describes a single browsable directory row.
type MachineDirectory struct {
	Name string `json:"name"`
	Path string `json:"path"`
}

// MachineDirectoryPageInfo describes a bounded page of directory rows.
type MachineDirectoryPageInfo struct {
	CurrentPath string `json:"currentPath"`
	ParentPath  string `json:"parentPath"`
	Limit       int    `json:"limit"`
	Cursor      string `json:"cursor"`
	HasMore     bool   `json:"hasMore"`
	NextCursor  string `json:"nextCursor"`
	Truncated   bool   `json:"truncated"`
}

// machineDirectoryListEnvelope mirrors the Remote Endpoint successful payload
// shape so the byte budget can be enforced against the exact wire form.
type machineDirectoryListEnvelope struct {
	CurrentPath string             `json:"currentPath"`
	ParentPath  string             `json:"parentPath"`
	Directories []MachineDirectory `json:"directories"`
	Limit       int                `json:"limit"`
	Cursor      string             `json:"cursor"`
	HasMore     bool               `json:"hasMore"`
	NextCursor  string             `json:"nextCursor"`
	Truncated   bool               `json:"truncated"`
}

// machineDirectoryFS is the filesystem surface used by directory browsing.
// Production code uses osMachineDirectoryFS; tests inject deterministic
// not-exist, permission, generic I/O, stat, and read failures without mutating
// package globals.
type machineDirectoryFS interface {
	UserHomeDir() (string, error)
	EvalSymlinks(string) (string, error)
	Open(string) (machineDirectoryHandle, error)
}

// machineDirectoryHandle is the opened-directory surface used by browsing.
type machineDirectoryHandle interface {
	Stat() (os.FileInfo, error)
	ReadDir(int) ([]os.DirEntry, error)
	Close() error
}

// osMachineDirectoryFS delegates to the os and filepath packages.
type osMachineDirectoryFS struct{}

func (osMachineDirectoryFS) UserHomeDir() (string, error) { return os.UserHomeDir() }

func (osMachineDirectoryFS) EvalSymlinks(path string) (string, error) {
	return filepath.EvalSymlinks(path)
}

func (osMachineDirectoryFS) Open(name string) (machineDirectoryHandle, error) {
	return os.Open(name)
}

// ListMachineDirectoriesPage returns one bounded page of directories located
// inside the directory identified by rawPath. Empty rawPath resolves to the
// daemon user's home directory.
func ListMachineDirectoriesPage(rawPath string, opts ListOptions) ([]MachineDirectory, MachineDirectoryPageInfo, error) {
	return listMachineDirectoriesPage(osMachineDirectoryFS{}, rawPath, opts)
}

func listMachineDirectoriesPage(fsImpl machineDirectoryFS, rawPath string, opts ListOptions) ([]MachineDirectory, MachineDirectoryPageInfo, error) {
	// Normalize limit only; cursor is validated strictly against its raw,
	// untrimmed form so whitespace and non-canonical syntax are rejected.
	if opts.Limit <= 0 {
		opts.Limit = DefaultListLimit
	}
	if opts.Limit > MaxListLimit {
		opts.Limit = MaxListLimit
	}

	resolvedPath, err := resolveMachineDirectoryPath(fsImpl, rawPath)
	if err != nil {
		return nil, MachineDirectoryPageInfo{}, err
	}

	// Echo the raw request cursor byte-exact in the response. Missing or empty
	// cursor still means zero for page logic, but the response stays canonical
	// with the input form instead of rewriting "" -> "0".
	echoCursor := opts.Cursor
	cursor, err := parseMachineDirectoryCursor(opts.Cursor)
	if err != nil {
		return nil, MachineDirectoryPageInfo{}, ErrMachineDirectoryInvalid
	}

	pageCapacity := opts.Limit
	if pageCapacity > maxMachineDirectoryRawEntries-cursor {
		pageCapacity = maxMachineDirectoryRawEntries - cursor
	}

	handle, err := fsImpl.Open(resolvedPath)
	if err != nil {
		return nil, MachineDirectoryPageInfo{}, classifyMachineDirectoryOpenError(err)
	}
	defer handle.Close()

	info, err := handle.Stat()
	if err != nil {
		return nil, MachineDirectoryPageInfo{}, ErrMachineDirectoryUnreadable
	}
	if !info.IsDir() {
		return nil, MachineDirectoryPageInfo{}, ErrMachineDirectoryInvalid
	}

	parentPath := parentMachineDirectoryPath(resolvedPath)
	base := machineDirectoryPageInfo(resolvedPath, parentPath, opts)
	base.Cursor = echoCursor

	directories := make([]MachineDirectory, 0, opts.Limit)
	absoluteRawRead := 0
	sawProbe := false

agentteam:
	for {
		// Bound the next ReadDir to what we still need: the cursor skip prefix
		// plus at most pageCapacity page entries plus one probe entry.
		needed := (cursor + pageCapacity + 1) - absoluteRawRead
		if needed <= 0 {
			break
		}
		batch := needed
		if batch > maxMachineDirectorySkipBatch {
			batch = maxMachineDirectorySkipBatch
		}

		entries, readErr := handle.ReadDir(batch)
		if readErr != nil && !errors.Is(readErr, io.EOF) {
			return nil, MachineDirectoryPageInfo{}, ErrMachineDirectoryUnreadable
		}
		if len(entries) == 0 {
			break
		}

		for _, entry := range entries {
			// Cursor skip phase: discard raw entries before the cursor offset.
			if absoluteRawRead < cursor {
				absoluteRawRead++
				continue
			}

			// Page phase: probe when the page capacity is full.
			pageRelative := absoluteRawRead - cursor
			if pageRelative >= pageCapacity {
				sawProbe = true
				absoluteRawRead++
				break agentteam
			}

			absoluteRawRead++

			// Filter symlinks and non-directories. They consume a raw position
			// but never appear in the result.
			if entry.Type()&os.ModeSymlink != 0 || !entry.IsDir() {
				continue
			}

			candidate := MachineDirectory{
				Name: entry.Name(),
				Path: filepath.Join(resolvedPath, entry.Name()),
			}
			trial := make([]MachineDirectory, len(directories), len(directories)+1)
			copy(trial, directories)
			trial = append(trial, candidate)

			// Marshal the worst-case final metadata the response could emit
			// after accepting this row. Two return paths are possible:
			//   (a) byte-budget stop on a LATER row -> NextCursor = raw offset
			//       of that later rejection, which is > absoluteRawRead-1;
			//   (b) raw-capacity probe fires after the page fills -> NextCursor
			//       = cursor+pageCapacity, which is the maximum value
			//       achievable in the page phase.
			// The probe-path cursor is the longest possible NextCursor string
			// for any continuation involving this row, so it is a strict
			// upper bound on the metadata bytes the transport will emit.
			marshaled, err := json.Marshal(machineDirectoryListEnvelope{
				CurrentPath: resolvedPath,
				ParentPath:  parentPath,
				Directories: trial,
				Limit:       opts.Limit,
				Cursor:      echoCursor,
				HasMore:     true,
				NextCursor:  strconv.Itoa(cursor + pageCapacity),
			})
			if err != nil {
				return nil, MachineDirectoryPageInfo{}, ErrMachineDirectoryUnreadable
			}
			if len(marshaled) > maxMachineDirectoryPageBytes {
				if len(directories) == 0 {
					return nil, MachineDirectoryPageInfo{}, ErrMachineDirectoryUnreadable
				}
				page := base
				page.HasMore = true
				page.NextCursor = strconv.Itoa(absoluteRawRead - 1)
				return directories, page, nil
			}
			directories = trial
		}

		if errors.Is(readErr, io.EOF) {
			break
		}
	}

	if !sawProbe {
		// EOF before filling the page capacity: no more rows.
		return directories, base, nil
	}

	nextOffset := cursor + pageCapacity
	if nextOffset >= maxMachineDirectoryRawEntries {
		page := base
		page.Truncated = true
		return directories, page, nil
	}

	page := base
	page.HasMore = true
	page.NextCursor = strconv.Itoa(nextOffset)
	return directories, page, nil
}

func resolveMachineDirectoryPath(fsImpl machineDirectoryFS, rawPath string) (string, error) {
	// Only a literal empty path resolves to the daemon user's home directory.
	// A whitespace-only path is a raw filesystem value and must be classified
	// as invalid (not absolute) without being silently rewritten to home.
	if rawPath == "" {
		home, err := fsImpl.UserHomeDir()
		if err != nil || home == "" {
			return "", ErrMachineDirectoryUnreadable
		}
		rawPath = home
	}
	if !filepath.IsAbs(rawPath) {
		return "", ErrMachineDirectoryInvalid
	}
	canonical, err := fsImpl.EvalSymlinks(filepath.Clean(rawPath))
	if err != nil {
		if errors.Is(err, fs.ErrInvalid) || errors.Is(err, fs.ErrNotExist) {
			return "", ErrMachineDirectoryInvalid
		}
		return "", ErrMachineDirectoryUnreadable
	}
	if canonical == "" {
		return "", ErrMachineDirectoryInvalid
	}
	return canonical, nil
}

func classifyMachineDirectoryOpenError(err error) error {
	if err == nil {
		return nil
	}
	if errors.Is(err, fs.ErrInvalid) || errors.Is(err, fs.ErrNotExist) {
		return ErrMachineDirectoryInvalid
	}
	return ErrMachineDirectoryUnreadable
}

func parseMachineDirectoryCursor(raw string) (int, error) {
	if raw == "" || raw == "0" {
		return 0, nil
	}
	if raw[0] < '1' || raw[0] > '9' {
		return 0, fmt.Errorf("invalid cursor %q", raw)
	}
	for i := 1; i < len(raw); i++ {
		if raw[i] < '0' || raw[i] > '9' {
			return 0, fmt.Errorf("invalid cursor %q", raw)
		}
	}
	n, err := strconv.Atoi(raw)
	if err != nil || n > maxMachineDirectoryRawEntries {
		return 0, fmt.Errorf("invalid cursor %q", raw)
	}
	return n, nil
}

func parentMachineDirectoryPath(current string) string {
	if current == "" {
		return ""
	}
	parent := filepath.Dir(current)
	if parent == current {
		return ""
	}
	return parent
}

func machineDirectoryPageInfo(currentPath, parentPath string, opts ListOptions) MachineDirectoryPageInfo {
	return MachineDirectoryPageInfo{
		CurrentPath: currentPath,
		ParentPath:  parentPath,
		Limit:       opts.Limit,
		Cursor:      opts.Cursor,
	}
}
