package cli

import (
	"archive/tar"
	"bytes"
	"compress/gzip"
	"context"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"sync"
	"testing"

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

// captureLogs redirects the global logger to fresh buffers for the duration of
// a test and restores the defaults (and the dryRun global) afterward. Returns
// the stdout and stderr buffers.
func captureLogs(t *testing.T) (*bytes.Buffer, *bytes.Buffer) {
	t.Helper()
	var out, errb bytes.Buffer
	log.Init(&out, &errb)
	t.Cleanup(func() {
		log.Init(os.Stdout, os.Stderr)
		log.SetVerbose(false)
		log.SetQuiet(false)
		dryRun = false
	})
	return &out, &errb
}

// fakeAPI is an in-memory recording storageAPI: the reusable test seam for all
// command tests. It records every attempted Upload/Delete and serves scripted
// List results. It satisfies storageAPI, so it can be substituted for the real
// *bunny.API anywhere (including wrapped by dryRunStorageAPI).
type fakeAPI struct {
	mu       sync.Mutex
	puts     map[string][]byte
	order    []string
	deletes  []string
	lists    map[string][]bunny.Object
	listErr  error
	failOn   string
	failAll  bool
	failWhen func(path string) bool
	failErr  error
}

func newFakeAPI() *fakeAPI {
	return &fakeAPI{puts: map[string][]byte{}, lists: map[string][]bunny.Object{}}
}

func (f *fakeAPI) Upload(ctx context.Context, path string, body []byte) error {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.order = append(f.order, path)
	if f.failAll || path == f.failOn || (f.failWhen != nil && f.failWhen(path)) {
		if f.failErr != nil {
			return f.failErr
		}
		return errors.New("forced failure")
	}
	b := make([]byte, len(body))
	copy(b, body)
	f.puts[path] = b
	return nil
}

func (f *fakeAPI) Delete(ctx context.Context, path string) error {
	return f.recordDelete(path)
}

// DeleteDir records the logical directory path (no trailing slash) so tests can
// assert on it uniformly with file deletes; the real trailing-slash quirk lives
// in the bunny package and is exercised by bunny's own tests.
func (f *fakeAPI) DeleteDir(ctx context.Context, path string) error {
	return f.recordDelete(path)
}

func (f *fakeAPI) recordDelete(path string) error {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.deletes = append(f.deletes, path)
	if f.failAll || path == f.failOn || (f.failWhen != nil && f.failWhen(path)) {
		if f.failErr != nil {
			return f.failErr
		}
		return errors.New("forced failure")
	}
	return nil
}

func (f *fakeAPI) List(ctx context.Context, dir string) *bunny.Iterator {
	return bunny.NewIterator(ctx, dir, f.listDir)
}

func (f *fakeAPI) listDir(ctx context.Context, dir string) ([]bunny.Object, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	if f.listErr != nil {
		return nil, f.listErr
	}
	return f.lists[dir], nil
}

func (f *fakeAPI) paths() []string {
	f.mu.Lock()
	defer f.mu.Unlock()
	var ps []string
	for p := range f.puts {
		ps = append(ps, p)
	}
	sort.Strings(ps)
	return ps
}

// ctxCancelAPI simulates the real fail-fast scenario: the upload of triggerPath
// fails immediately with a genuine (non-context) error, while every other
// upload blocks until the pool cancels its context and then returns an error
// that WRAPS context.Canceled (mirroring a real *bunny.apiError built from an
// aborted in-flight request). It lets a test assert that cancellation collateral
// is filtered out of the failure report while the genuine trigger survives.
type ctxCancelAPI struct {
	*fakeAPI
	triggerPath string
	triggerErr  error
}

func (c *ctxCancelAPI) Upload(ctx context.Context, path string, body []byte) error {
	c.fakeAPI.mu.Lock()
	c.fakeAPI.order = append(c.fakeAPI.order, path)
	c.fakeAPI.mu.Unlock()

	if path == c.triggerPath {
		return c.triggerErr
	}
	// Collateral: wait for the fail-fast cancellation, then report it as a
	// context-wrapped error, exactly like a real aborted upload.
	<-ctx.Done()
	return fmt.Errorf("upload %s aborted: %w", path, ctx.Err())
}

// gatingAPI wraps fakeAPI to make each Upload block on a release channel while
// recording the peak number of concurrent in-flight uploads, so tests can
// assert the pool never exceeds --concurrency.
type gatingAPI struct {
	*fakeAPI
	release chan struct{}

	gmu     sync.Mutex
	cur     int
	maxSeen int
}

func (g *gatingAPI) Upload(ctx context.Context, path string, body []byte) error {
	g.gmu.Lock()
	g.cur++
	if g.cur > g.maxSeen {
		g.maxSeen = g.cur
	}
	g.gmu.Unlock()

	<-g.release

	g.gmu.Lock()
	g.cur--
	g.gmu.Unlock()
	return g.fakeAPI.Upload(ctx, path, body)
}

func (g *gatingAPI) peak() int {
	g.gmu.Lock()
	defer g.gmu.Unlock()
	return g.maxSeen
}

// tarEntry describes a single file/header to write into a test archive.
type tarEntry struct {
	name     string
	body     string
	typeflag byte
	linkname string
}

func makeArchive(t *testing.T, entries []tarEntry) string {
	t.Helper()
	dir := t.TempDir()
	p := filepath.Join(dir, "a.tar.gz")
	f, err := os.Create(p)
	if err != nil {
		t.Fatal(err)
	}
	defer f.Close()
	gz := gzip.NewWriter(f)
	tw := tar.NewWriter(gz)
	for _, e := range entries {
		tf := e.typeflag
		if tf == 0 {
			tf = tar.TypeReg
		}
		hdr := &tar.Header{
			Name:     e.name,
			Mode:     0o644,
			Size:     int64(len(e.body)),
			Typeflag: tf,
			Linkname: e.linkname,
		}
		if tf != tar.TypeReg {
			hdr.Size = 0
		}
		if err := tw.WriteHeader(hdr); err != nil {
			t.Fatal(err)
		}
		if tf == tar.TypeReg {
			if _, err := tw.Write([]byte(e.body)); err != nil {
				t.Fatal(err)
			}
		}
	}
	if err := tw.Close(); err != nil {
		t.Fatal(err)
	}
	if err := gz.Close(); err != nil {
		t.Fatal(err)
	}
	return p
}
