package idxfile

import (
	"bytes"
	"encoding/binary"
	"errors"
	"fmt"
	"io"
	"time"

	"github.com/go-git/go-git/v6/internal/sharedfile"
	"github.com/go-git/go-git/v6/plumbing"
	gsync "github.com/go-git/go-git/v6/utils/sync"
	"github.com/go-git/go-git/v6/x/fdpool"
)

const defaultCloseGracePeriod = time.Second

const (
	idxHeaderSize = 8 // 4 magic + 4 version
	idxFanoutSize = 256 * 4
	off32Size     = 4
	off64Size     = 8
	revHeaderSize = 12 // 4 magic + 4 version + 4 hash function

	is64bitsMask = uint64(1) << 31
)

// ReadAtCloser is the interface required for files used by LazyIndex.
// It is an alias for [sharedfile.ReadAtCloser]; both names refer
// to the same type at compile time.
type ReadAtCloser = sharedfile.ReadAtCloser

// LazyIndex implements the Index interface by reading directly from
// .idx and .rev files via ReadAt, without loading all data into memory.
//
// File descriptors are managed automatically via reference-counted
// shared handles: opened lazily on first use, shared across concurrent
// readers, and closed when no readers remain. This avoids holding
// descriptors open indefinitely while still sharing a single FD across
// concurrent operations.
type LazyIndex struct {
	hashSize int
	count    int
	count64  int

	// Section byte offsets within the idx file.
	fanoutStart int
	namesStart  int
	crcStart    int
	off32Start  int
	off64Start  int

	idx *sharedfile.SharedFile
	rev *sharedfile.SharedFile

	fanout [256]uint32 // cached from idx; small enough to keep in memory
}

var _ Index = (*LazyIndex)(nil)

// NewLazyIndex creates a LazyIndex from opener functions for .idx and
// .rev files.
//
// The openers are called to obtain file handles on demand. Each call
// must return a fresh, independently closeable handle. File descriptors
// are shared across concurrent readers and released automatically when
// idle.
func NewLazyIndex(openIdx, openRev func() (ReadAtCloser, error), packHash plumbing.Hash) (*LazyIndex, error) {
	return NewLazyIndexWithPool(openIdx, openRev, packHash, nil)
}

// NewLazyIndexWithPool is like [NewLazyIndex] but registers the
// idx and rev [sharedfile.SharedFile]s with the given
// [*fdpool.Pool]. The pool governs LRU eviction across many
// LazyIndexes so a storage-wide FD budget covers the .idx and
// .rev descriptors. Pass nil to disable pooling (equivalent to
// [NewLazyIndex]).
//
// When pool is non-nil the [defaultCloseGracePeriod] timer is
// inert: each FD stays open and registered with the pool until
// the LRU evicts it (or [LazyIndex.Close] tears it down). When
// pool is nil the grace timer governs FD lifetime as in
// [NewLazyIndex].
//
// Neither this constructor nor the [Index] methods accept a
// [context.Context]. Index lookups are pure ReadAt I/O without
// cancellation hooks, matching the context-free convention of
// the storage, plumbing/format, and plumbing/storer layers;
// callers requiring cancellation enforce it at the call-site
// in the layer above.
func NewLazyIndexWithPool(openIdx, openRev func() (ReadAtCloser, error), packHash plumbing.Hash, pool *fdpool.Pool) (*LazyIndex, error) {
	if openIdx == nil {
		return nil, errors.New("idx opener is nil")
	}
	if openRev == nil {
		return nil, errors.New("rev opener is nil")
	}

	s := &LazyIndex{
		idx: sharedfile.NewWithPool(openIdx, defaultCloseGracePeriod, pool),
		rev: sharedfile.NewWithPool(openRev, defaultCloseGracePeriod, pool),
	}

	if err := s.init(packHash); err != nil {
		_ = s.Close()
		return nil, err
	}
	return s, nil
}

// init reads and validates headers, caches the fanout table and
// computes section offsets. It acquires file handles through the
// sharedFile so the grace period keeps them warm for the first real
// operation.
func (s *LazyIndex) init(packHash plumbing.Hash) error {
	idxRA, err := s.idx.Acquire()
	if err != nil {
		return fmt.Errorf("cannot open idx: %w", err)
	}
	defer s.idx.Release()

	revRA, err := s.rev.Acquire()
	if err != nil {
		return fmt.Errorf("cannot open rev: %w", err)
	}
	defer s.rev.Release()

	var hdr [idxHeaderSize]byte
	if _, err := idxRA.ReadAt(hdr[:], 0); err != nil {
		return fmt.Errorf("cannot read idx header: %w", err)
	}
	if !bytes.Equal(hdr[:4], idxHeader) {
		return fmt.Errorf("%w: %s", ErrMalformedIdxFile, "header mismatch")
	}

	v := binary.BigEndian.Uint32(hdr[4:])
	if v != VersionSupported {
		return ErrUnsupportedVersion
	}

	var revHdr [revHeaderSize]byte
	if _, err := revRA.ReadAt(revHdr[:], 0); err != nil {
		return fmt.Errorf("cannot read rev header: %w", err)
	}
	if !bytes.Equal(revHdr[:4], []byte{'R', 'I', 'D', 'X'}) {
		return fmt.Errorf("%w: rev file magic mismatch", ErrMalformedIdxFile)
	}
	if v := binary.BigEndian.Uint32(revHdr[4:]); v != 1 {
		return fmt.Errorf("%w: unsupported rev file version %d", ErrMalformedIdxFile, v)
	}

	s.fanoutStart = idxHeaderSize
	var fanoutBuf [idxFanoutSize]byte
	if _, err := idxRA.ReadAt(fanoutBuf[:], int64(s.fanoutStart)); err != nil {
		return fmt.Errorf("cannot read idx fanout: %w", err)
	}

	for i := range 256 {
		s.fanout[i] = binary.BigEndian.Uint32(fanoutBuf[i*4:])
		if i > 0 && s.fanout[i] < s.fanout[i-1] {
			return fmt.Errorf("%w: fanout table is not monotonically non-decreasing at entry %d",
				ErrMalformedIdxFile, i)
		}
	}

	s.count = int(s.fanout[255])

	s.hashSize = packHash.Size()
	s.namesStart = s.fanoutStart + idxFanoutSize
	s.crcStart = s.namesStart + (s.count * s.hashSize)
	s.off32Start = s.crcStart + (s.count * 4)
	s.off64Start = s.off32Start + (s.count * off32Size)

	// Count 64-bit offset entries by scanning the 32-bit offset table
	// for entries with the MSB set.
	n64, err := s.count64bitOffsets(idxRA)
	if err != nil {
		return err
	}
	s.count64 = n64

	// The pack checksum sits right after the 64-bit offset table.
	packBuf := make([]byte, s.hashSize)
	packHashOff := int64(s.off64Start + n64*off64Size)
	if _, err := idxRA.ReadAt(packBuf, packHashOff); err != nil {
		return fmt.Errorf("cannot read pack checksum: %w", err)
	}

	if packHash.Compare(packBuf) != 0 {
		var got plumbing.Hash
		got.ResetBySize(s.hashSize)
		_, _ = got.Write(packBuf)
		return fmt.Errorf("%w: packfile mismatch: got %q instead of %q",
			ErrMalformedIdxFile, got.String(), packHash.String())
	}

	return nil
}

// Contains reports whether the given hash exists in the index by
// binary-searching the idx names table.
func (s *LazyIndex) Contains(h plumbing.Hash) (bool, error) {
	idx, err := s.idx.Acquire()
	if err != nil {
		return false, err
	}
	defer s.idx.Release()

	_, found, err := s.findHashPos(idx, h)
	return found, err
}

// MayContain implements the Index interface. It reports whether the
// index might contain h, using the cached fanout table loaded at
// construction time. No I/O, no lock. False is authoritative ("h is
// not in this pack"); true means call Contains or FindOffset for a
// definitive answer.
func (s *LazyIndex) MayContain(h plumbing.Hash) bool {
	first := int(h.Bytes()[0])
	var prev uint32
	if first > 0 {
		prev = s.fanout[first-1]
	}
	return s.fanout[first] > prev
}

// FindOffset returns the packfile offset for the object with the given hash.
// It returns plumbing.ErrObjectNotFound if the hash is not in the index.
func (s *LazyIndex) FindOffset(h plumbing.Hash) (int64, error) {
	idx, err := s.idx.Acquire()
	if err != nil {
		return 0, err
	}
	defer s.idx.Release()

	pos, found, err := s.findHashPos(idx, h)
	if err != nil {
		return 0, err
	}
	if !found {
		return 0, plumbing.ErrObjectNotFound
	}

	off, err := s.offset(idx, pos)
	if err != nil {
		return 0, err
	}

	return int64(off), nil
}

// FindCRC32 returns the CRC32 checksum of the object with the given hash.
// It returns plumbing.ErrObjectNotFound if the hash is not in the index.
func (s *LazyIndex) FindCRC32(h plumbing.Hash) (uint32, error) {
	idx, err := s.idx.Acquire()
	if err != nil {
		return 0, err
	}
	defer s.idx.Release()

	pos, found, err := s.findHashPos(idx, h)
	if err != nil {
		return 0, err
	}
	if !found {
		return 0, plumbing.ErrObjectNotFound
	}

	return s.crc32(idx, pos)
}

// FindHash returns the object hash stored at the given packfile offset
// by binary-searching the .rev reverse index.
// It returns plumbing.ErrObjectNotFound if no object exists at that offset.
func (s *LazyIndex) FindHash(o int64) (plumbing.Hash, error) {
	idx, err := s.idx.Acquire()
	if err != nil {
		return plumbing.ZeroHash, err
	}
	defer s.idx.Release()

	rev, err := s.rev.Acquire()
	if err != nil {
		return plumbing.ZeroHash, err
	}
	defer s.rev.Release()

	return s.findHashViaRev(idx, rev, o)
}

// Count returns the total number of objects in the index.
func (s *LazyIndex) Count() (int64, error) {
	return int64(s.count), nil
}

// Entries returns an iterator over all index entries in hash order.
// The caller must call Close on the returned iterator to release the
// underlying file reference.
func (s *LazyIndex) Entries() (EntryIter, error) {
	idx, err := s.idx.Acquire()
	if err != nil {
		return nil, err
	}
	return &scannerEntryIter{s: s, idx: idx}, nil
}

// EntriesWithPrefix implements the Index interface. It returns an
// iterator over entries whose hashes start with prefix. When prefix
// is empty the call is equivalent to Entries; otherwise the
// iterator visits only the fanout-bounded names-table slice
// selected by prefix[0] and stops as soon as a name without prefix
// is read (the names table is sorted by hash).
//
// For a multi-byte prefix the matching entries form a contiguous
// run somewhere within the bucket; binary-search positions the
// iterator at the start of that run so the linear walk only spans
// matches. This mirrors upstream Git's for_each_prefixed_object_in_pack
// which calls bsearch_pack to position before walking forward.
//
// The returned iterator holds an acquired reference to the idx
// SharedFile which is released on Close.
func (s *LazyIndex) EntriesWithPrefix(prefix []byte) (EntryIter, error) {
	if len(prefix) == 0 {
		return s.Entries()
	}
	first := int(prefix[0])
	var lo int
	if first > 0 {
		lo = int(s.fanout[first-1])
	}
	hi := int(s.fanout[first])
	if lo >= hi {
		return &lazyPrefixIter{}, nil
	}
	idx, err := s.idx.Acquire()
	if err != nil {
		return nil, err
	}

	// Find the leftmost entry in [lo, hi) whose hash is >= prefix
	// (padded with zeros to hash size). All matching entries, if
	// any, start at this position; the iterator's
	// stop-on-first-mismatch then terminates correctly once the
	// run ends.
	target := make([]byte, s.hashSize)
	copy(target, prefix)
	var arr [32]byte
	buf := arr[:s.hashSize]
	bsLo, bsHi := lo, hi
	for bsLo < bsHi {
		mid := (bsLo + bsHi) >> 1
		nameOff := int64(s.namesStart + mid*s.hashSize)
		if _, err := idx.ReadAt(buf, nameOff); err != nil {
			s.idx.Release()
			return nil, fmt.Errorf("read name at pos %d: %w", mid, err)
		}
		if bytes.Compare(buf, target) < 0 {
			bsLo = mid + 1
		} else {
			bsHi = mid
		}
	}
	if bsLo >= hi {
		s.idx.Release()
		return &lazyPrefixIter{}, nil
	}

	return &lazyPrefixIter{
		s:      s,
		idx:    idx,
		prefix: prefix,
		pos:    bsLo,
		end:    hi,
	}, nil
}

// EntriesByOffset returns an iterator over all index entries sorted by
// their packfile offset. It reads positions from the .rev file on each
// call to Next, avoiding any up-front allocation or sorting.
//
// The caller must call Close on the returned iterator to release the
// underlying file references.
func (s *LazyIndex) EntriesByOffset() (EntryIter, error) {
	idx, err := s.idx.Acquire()
	if err != nil {
		return nil, err
	}
	rev, err := s.rev.Acquire()
	if err != nil {
		s.idx.Release()
		return nil, err
	}
	return &revEntryIter{s: s, idx: idx, rev: rev}, nil
}

// Close releases the underlying shared file handles, preventing future
// operations. If there are active readers they will finish normally;
// the file descriptors close when the last reader is done.
func (s *LazyIndex) Close() error {
	return errors.Join(s.idx.Close(), s.rev.Close())
}

// CloseIdleDescriptors releases the idx and rev file descriptors
// without disabling the [LazyIndex]. The FDs close inline when no
// readers are active; otherwise each [sharedfile.SharedFile]
// latches an immediate close on the next refs==0 transition.
// In-flight readers complete normally; subsequent operations
// reopen the FDs on demand and resume normal grace-timer
// behaviour.
//
// Returns the joined error of the inline closes; latched closes
// that fire later are not reported.
func (s *LazyIndex) CloseIdleDescriptors() error {
	return errors.Join(s.idx.ReleaseNow(), s.rev.ReleaseNow())
}

// --- internal helpers; all take an io.ReaderAt so the caller controls
//     the acquire/release lifecycle. ---

// findHashPos binary-searches the names table for h, returning the flat
// position (0..count-1) if found.
func (s *LazyIndex) findHashPos(idx io.ReaderAt, h plumbing.Hash) (int, bool, error) {
	if h.Size() != s.hashSize {
		return 0, false, fmt.Errorf("hash size mismatch: %d %d", h.Size(), s.hashSize)
	}
	first := int(h.Bytes()[0])
	var lo int
	if first > 0 {
		lo = int(s.fanout[first-1])
	}
	hi := int(s.fanout[first])
	if lo >= hi {
		return 0, false, nil
	}

	target := h.Bytes()[:s.hashSize]
	var arr [32]byte
	buf := arr[:s.hashSize]

	for lo < hi {
		mid := (lo + hi) >> 1
		nameOff := int64(s.namesStart + mid*s.hashSize)
		if _, err := idx.ReadAt(buf, nameOff); err != nil {
			return 0, false, fmt.Errorf("read name at pos %d: %w", mid, err)
		}

		cmp := bytes.Compare(target, buf)
		switch {
		case cmp < 0:
			hi = mid
		case cmp > 0:
			lo = mid + 1
		default:
			return mid, true, nil
		}
	}
	return 0, false, nil
}

// offset returns the pack offset for the object at position pos.
func (s *LazyIndex) offset(idx io.ReaderAt, pos int) (uint64, error) {
	var buf [off32Size]byte
	off := int64(s.off32Start + pos*off32Size)
	if _, err := idx.ReadAt(buf[:], off); err != nil {
		return 0, fmt.Errorf("%w: cannot read offset32: %v", ErrMalformedIdxFile, err)
	}

	off32 := binary.BigEndian.Uint32(buf[:])
	if uint64(off32)&is64bitsMask != 0 {
		loIndex := int(uint64(off32) & ^is64bitsMask)
		if loIndex >= s.count64 {
			return 0, fmt.Errorf("%w: offset64 index %d out of range (have %d entries)",
				ErrMalformedIdxFile, loIndex, s.count64)
		}
		var buf64 [off64Size]byte
		off64Pos := int64(s.off64Start + loIndex*off64Size)
		if _, err := idx.ReadAt(buf64[:], off64Pos); err != nil {
			return 0, fmt.Errorf("%w: cannot read offset64: %v", ErrMalformedIdxFile, err)
		}
		return binary.BigEndian.Uint64(buf64[:]), nil
	}

	return uint64(off32), nil
}

// count64bitOffsets scans the 32-bit offset table and returns the number
// of entries whose MSB is set (i.e. that use the 64-bit overflow table).
func (s *LazyIndex) count64bitOffsets(idx io.ReaderAt) (int, error) {
	bufp := gsync.GetByteSlice()
	defer gsync.PutByteSlice(bufp)

	buf := *bufp
	// Round down to a multiple of off32Size so we always read whole entries.
	buf = buf[:len(buf)&^(off32Size-1)]

	var n int
	remaining := s.count
	pos := int64(s.off32Start)

	for remaining > 0 {
		chunk := min(remaining*off32Size, len(buf))

		if _, err := idx.ReadAt(buf[:chunk], pos); err != nil {
			return 0, fmt.Errorf("%w: cannot read offset32 table: %v", ErrMalformedIdxFile, err)
		}

		for i := 0; i < chunk; i += off32Size {
			if binary.BigEndian.Uint32(buf[i:])&uint32(is64bitsMask) != 0 {
				n++
			}
		}

		pos += int64(chunk)
		remaining -= chunk / off32Size
	}
	return n, nil
}

// crc32 returns the CRC32 for the object at position pos.
func (s *LazyIndex) crc32(idx io.ReaderAt, pos int) (uint32, error) {
	var buf [4]byte
	off := int64(s.crcStart + pos*4)
	if _, err := idx.ReadAt(buf[:], off); err != nil {
		return 0, fmt.Errorf("%w: cannot read CRC32: %v", ErrMalformedIdxFile, err)
	}
	return binary.BigEndian.Uint32(buf[:]), nil
}

// hashAtPos reads the hash at the given flat position.
func (s *LazyIndex) hashAtPos(idx io.ReaderAt, pos int) (plumbing.Hash, error) {
	var arr [32]byte
	buf := arr[:s.hashSize]
	off := int64(s.namesStart + pos*s.hashSize)
	if _, err := idx.ReadAt(buf, off); err != nil {
		return plumbing.ZeroHash, fmt.Errorf("read name at pos %d: %w", pos, err)
	}

	var h plumbing.Hash
	h.ResetBySize(s.hashSize)
	_, _ = h.Write(buf)
	return h, nil
}

func (s *LazyIndex) findHashViaRev(idx, rev io.ReaderAt, want int64) (plumbing.Hash, error) {
	lo, hi := 0, s.count
	var buf [4]byte

	for lo < hi {
		mid := (lo + hi) >> 1
		revOff := int64(revHeaderSize + mid*4)
		if _, err := rev.ReadAt(buf[:], revOff); err != nil {
			return plumbing.ZeroHash, fmt.Errorf("read rev entry: %w", err)
		}

		idxPos := int(binary.BigEndian.Uint32(buf[:]))
		if idxPos < 0 || idxPos >= s.count {
			return plumbing.ZeroHash, fmt.Errorf("%w: rev entry %d out of range (count %d)",
				ErrMalformedIdxFile, idxPos, s.count)
		}
		got, err := s.offset(idx, idxPos)
		if err != nil {
			return plumbing.ZeroHash, err
		}

		switch {
		case int64(got) < want:
			lo = mid + 1
		case int64(got) > want:
			hi = mid
		default:
			return s.hashAtPos(idx, idxPos)
		}
	}
	return plumbing.ZeroHash, plumbing.ErrObjectNotFound
}

// entryAt reads a complete entry at the given flat position.
func (s *LazyIndex) entryAt(idx io.ReaderAt, pos int) (*Entry, error) {
	h, err := s.hashAtPos(idx, pos)
	if err != nil {
		return nil, err
	}
	off, err := s.offset(idx, pos)
	if err != nil {
		return nil, err
	}
	crc, err := s.crc32(idx, pos)
	if err != nil {
		return nil, err
	}
	return &Entry{Hash: h, Offset: off, CRC32: crc}, nil
}

// scannerEntryIter iterates over entries in hash order.
// It holds an acquired reference to the idx sharedFile which is
// released when Close is called.
type scannerEntryIter struct {
	s   *LazyIndex
	idx io.ReaderAt // acquired from s.idx
	pos int
}

func (it *scannerEntryIter) Next() (*Entry, error) {
	if it.idx == nil {
		return nil, sharedfile.ErrClosed
	}
	if it.pos >= it.s.count {
		return nil, io.EOF
	}

	e, err := it.s.entryAt(it.idx, it.pos)
	if err != nil {
		return nil, err
	}
	it.pos++
	return e, nil
}

func (it *scannerEntryIter) Close() error {
	it.pos = it.s.count
	if it.idx != nil {
		it.s.idx.Release()
		it.idx = nil
	}
	return nil
}

// revEntryIter iterates over entries in packfile-offset order by
// walking the .rev file sequentially. It holds acquired references to
// both the idx and rev sharedFiles, released on Close.
type revEntryIter struct {
	s   *LazyIndex
	idx io.ReaderAt
	rev io.ReaderAt
	pos int
}

func (it *revEntryIter) Next() (*Entry, error) {
	if it.idx == nil || it.rev == nil {
		return nil, sharedfile.ErrClosed
	}
	if it.pos >= it.s.count {
		return nil, io.EOF
	}

	var buf [4]byte
	revOff := int64(revHeaderSize + it.pos*4)
	if _, err := it.rev.ReadAt(buf[:], revOff); err != nil {
		return nil, fmt.Errorf("read rev entry at %d: %w", it.pos, err)
	}

	idxPos := int(binary.BigEndian.Uint32(buf[:]))
	if idxPos < 0 || idxPos >= it.s.count {
		return nil, fmt.Errorf("%w: rev entry %d out of range (count %d)",
			ErrMalformedIdxFile, idxPos, it.s.count)
	}

	e, err := it.s.entryAt(it.idx, idxPos)
	if err != nil {
		return nil, err
	}

	it.pos++
	return e, nil
}

func (it *revEntryIter) Close() error {
	it.pos = it.s.count
	if it.idx != nil {
		it.s.idx.Release()
		it.idx = nil
	}
	if it.rev != nil {
		it.s.rev.Release()
		it.rev = nil
	}
	return nil
}

// lazyPrefixIter walks the LazyIndex names table from pos to end,
// yielding entries whose hash starts with prefix. It stops the run
// when a hash without the prefix is read (the table is sorted). It
// holds an acquired reference to the idx SharedFile released on
// Close.
//
// Lifetime: Next may release the iterator's SharedFile reference
// eagerly when the first prefix-mismatched entry is observed —
// further matches are impossible in the sorted table, so holding
// the reference would only add pool pressure. Callers should
// defer Close unconditionally; it is idempotent and the eager
// release is purely an optimisation.
type lazyPrefixIter struct {
	s      *LazyIndex
	idx    io.ReaderAt
	prefix []byte
	pos    int
	end    int
}

func (it *lazyPrefixIter) Next() (*Entry, error) {
	if it.idx == nil {
		return nil, io.EOF
	}
	if it.pos >= it.end {
		return nil, io.EOF
	}
	e, err := it.s.entryAt(it.idx, it.pos)
	if err != nil {
		return nil, err
	}
	if !bytes.HasPrefix(e.Hash.Bytes(), it.prefix) {
		// Past the prefix in the sorted names table; close out so the
		// SharedFile reference is released eagerly.
		_ = it.Close()
		return nil, io.EOF
	}
	it.pos++
	return e, nil
}

func (it *lazyPrefixIter) Close() error {
	if it.idx != nil {
		it.s.idx.Release()
		it.idx = nil
	}
	return nil
}
