// Package source provides a sequential, single-pass iterator over release
// entries (regular files to upload). TarGzSource reads a tar.gz; a directory
// source may be added later behind the same interface.
package source

import (
	"archive/tar"
	"compress/gzip"
	"context"
	"errors"
	"fmt"
	"io"
	"os"
	"path"
	"strings"

	"tsne.dev/hopper/internal/log"
)

// Entry is a single regular file emitted by a Source.
type Entry struct {
	Path      string // cleaned, archive-relative key (no prefix applied).
	Size      int
	IsMutable bool // true for mutable entry points (*.html, case-insensitive).

	content io.Reader // unexported; live source stream for THIS entry.
}

// ReadContent reads the current entry's bytes fully into memory.
// On context cancellation it returns the context's cause promptly instead
// of finishing a large read. It is valid ONLY for the current entry and
// must be called before the next Source.Next().
func (e Entry) ReadContent(ctx context.Context) ([]byte, error) {
	const chunkSize = 512 << 10 // 512 KiB

	initialSize := e.Size
	if initialSize == 0 {
		initialSize = 4096
	}

	buf := make([]byte, initialSize)
	off := 0
	for {
		if err := context.Cause(ctx); err != nil {
			return nil, err
		}

		if len(buf)-off < chunkSize {
			newbuf := make([]byte, max(2*len(buf), len(buf)+chunkSize))
			copy(newbuf, buf[:off])
			buf = newbuf
		}

		read, err := e.content.Read(buf[off : off+chunkSize])
		if read > 0 {
			off += read
		}
		if err != nil {
			if errors.Is(err, io.EOF) {
				return buf[:off], nil
			}
			return nil, err
		}
	}
}

// Source is a sequential, single-pass iterator. Returns io.EOF when exhausted.
type Source interface {
	Next() (Entry, error)
	Close() error
}

// TarGzSource streams compress/gzip -> archive/tar entries one at a time.
type TarGzSource struct {
	f    *os.File
	gz   *gzip.Reader
	tr   *tar.Reader
	root string
}

// NewTarGzSource opens the given tar.gz archive. The given root selects
// the subtree to emit and is stripped from each selected entry. If root
// is empty all entries are selected.
//
// Note: `root` must arrive pre-normalized (no leading/trailing slashes,
// cleaned of "."/".."). The cli package owns root normalization.
func NewTarGzSource(archivePath string, root string) (*TarGzSource, error) {
	f, err := os.Open(archivePath)
	if err != nil {
		return nil, fmt.Errorf("open archive: %w", err)
	}
	gz, err := gzip.NewReader(f)
	if err != nil {
		f.Close()
		return nil, fmt.Errorf("gzip: %w", err)
	}

	return &TarGzSource{
		f:    f,
		gz:   gz,
		tr:   tar.NewReader(gz),
		root: root,
	}, nil
}

// Next returns the next regular-file entry, filtering by entry type and
// applying path-safety rules. Returns io.EOF when exhausted.
//
// Entry-type handling: regular files are emitted; directories are silently
// ignored; symlinks/hardlinks/devices/FIFOs/etc. are skipped with a warning
// (never fatal).
func (s *TarGzSource) Next() (Entry, error) {
	for {
		hdr, err := s.tr.Next()
		if err != nil {
			return Entry{}, err // includes io.EOF
		}

		switch hdr.Typeflag {
		case tar.TypeReg:
			// emit below
		case tar.TypeDir:
			continue // silently ignored
		default:
			log.Warn("skipping non-regular source entry %q (type %q)", hdr.Name, string(hdr.Typeflag))
			continue
		}

		cleaned, err := cleanArchivePath(hdr.Name)
		if err != nil {
			return Entry{}, err
		}

		path, ok := s.selectPath(cleaned)
		if !ok {
			continue
		}

		return Entry{
			Path:      path,
			Size:      int(hdr.Size),
			IsMutable: strings.HasSuffix(strings.ToLower(path), ".html"),
			content:   s.tr,
		}, nil
	}
}

// Close releases the underlying resources.
func (s *TarGzSource) Close() error {
	gzErr := s.gz.Close()
	fErr := s.f.Close()
	if gzErr != nil {
		return gzErr
	}
	return fErr
}

func (s *TarGzSource) selectPath(path string) (string, bool) {
	if s.root == "" {
		return path, true
	}
	prefix := s.root + "/"
	if !strings.HasPrefix(path, prefix) {
		return "", false
	}
	return path[len(prefix):], true
}

// cleanArchivePath validates and cleans a raw archive path:
//   - absolute path -> hard error;
//   - cleaned path escaping the root (starts with "..") -> hard error;
//   - stays within root but contained a ".." segment -> emit, with a warning;
//   - pure "."/redundant-slash normalization -> silent.
func cleanArchivePath(raw string) (string, error) {
	if path.IsAbs(raw) {
		return "", fmt.Errorf("absolute path in archive: %q", raw)
	}

	cleaned := path.Clean(raw)

	if cleaned == "." {
		return "", fmt.Errorf("empty or invalid path in archive: %q", raw)
	}

	if cleaned == ".." || strings.HasPrefix(cleaned, "../") {
		return "", fmt.Errorf("path escapes archive root: %q", raw)
	}

	// Stayed within root but contained a ".." segment -> warn (legal, unusual).
	if containsDotDot(raw) {
		log.Warn("path %q contains '..' segment", raw)
	}

	return cleaned, nil
}

// containsDotDot reports whether p has a ".." path segment, scanning by index
// to avoid the allocation of strings.Split.
func containsDotDot(p string) bool {
	for len(p) > 0 {
		i := strings.IndexByte(p, '/')
		var seg string
		if i < 0 {
			seg, p = p, ""
		} else {
			seg, p = p[:i], p[i+1:]
		}
		if seg == ".." {
			return true
		}
	}
	return false
}
