package download

import (
	"context"
	"errors"
	"log/slog"
	"os"
	"path/filepath"
	"sync"
)

// The fake provider exists so the pipeline can be tested end to end
// without a network, a daemon, or a binary on PATH.  It is registered
// like any real adapter and excluded from Descriptors(), so it can
// never be offered in the UI.

// FakeProvider is an in-memory Provider used by tests.  It fills all
// three roles; which ones are active is controlled by its Caps.
type FakeProvider struct {
	info ProviderInfo

	mu sync.Mutex

	// Candidates is what Search returns.
	Candidates []Candidate

	// SearchErr, GrabErr and CheckErr are returned when set.
	SearchErr error
	GrabErr   error
	CheckErr  error

	// Written maps a relative file name to the bytes Grab creates in
	// the staging directory, simulating a completed transfer.
	Written map[string][]byte

	// DelegateStatuses is returned by Poll in order, the last repeating.
	DelegateStatuses []DelegateStatus

	// GrabGate, when set, blocks Grab until it is closed or the
	// context ends.  It lets a test hold transfers open long enough to
	// observe how many run at once.
	GrabGate chan struct{}

	// concurrent tracks how many Grabs are in flight, and maxParallel
	// the high-water mark, which is what a concurrency cap is asserted
	// against.
	concurrent  int
	maxParallel int

	// Calls records what happened, for assertions.
	SearchCalls   int
	GrabCalls     int
	DelegateCalls int
	pollIndex     int
}

// NewFakeProvider returns a fake with the given capabilities.
func NewFakeProvider(id int64, name string, caps Caps) *FakeProvider {
	return &FakeProvider{
		info: ProviderInfo{
			ID:       id,
			Kind:     KindFake,
			Name:     name,
			Enabled:  true,
			Priority: 50,
			Caps:     caps,
		},
		Written: map[string][]byte{},
	}
}

// Info returns the fake's identity.
func (f *FakeProvider) Info() ProviderInfo {
	return f.info
}

// SetPriority adjusts the fake's priority.
func (f *FakeProvider) SetPriority(p int) {
	f.info.Priority = p
}

// Check reports configured health.
func (f *FakeProvider) Check(_ context.Context) error {
	return f.CheckErr
}

// Close is a no-op.
func (f *FakeProvider) Close() error {
	return nil
}

// Search returns the configured candidates.
func (f *FakeProvider) Search(
	ctx context.Context,
	_ Download,
) ([]Candidate, error) {
	f.mu.Lock()
	f.SearchCalls++
	f.mu.Unlock()

	if err := ctx.Err(); err != nil {
		return nil, err //nolint:wrapcheck // context error, already meaningful
	}

	if f.SearchErr != nil {
		return nil, f.SearchErr
	}

	out := make([]Candidate, len(f.Candidates))
	copy(out, f.Candidates)

	for i := range out {
		out[i].ProviderID = f.info.ID
	}

	return out, nil
}

// Grab writes the configured files into dst.
func (f *FakeProvider) Grab(
	ctx context.Context,
	_ Candidate,
	dst string,
	onProgress ProgressFunc,
) (Result, error) {
	f.mu.Lock()
	f.GrabCalls++
	f.concurrent++

	if f.concurrent > f.maxParallel {
		f.maxParallel = f.concurrent
	}

	gate := f.GrabGate
	f.mu.Unlock()

	defer func() {
		f.mu.Lock()
		f.concurrent--
		f.mu.Unlock()
	}()

	if gate != nil {
		select {
		case <-gate:
		case <-ctx.Done():
			return Result{}, ctx.Err() //nolint:wrapcheck // context error
		}
	}

	if err := ctx.Err(); err != nil {
		return Result{}, err //nolint:wrapcheck // context error
	}

	if f.GrabErr != nil {
		return Result{}, f.GrabErr
	}

	files := make([]string, 0, len(f.Written))

	var total int64

	for name, data := range f.Written {
		path := filepath.Join(dst, name)

		if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
			return Result{}, err //nolint:wrapcheck // test helper
		}

		if err := os.WriteFile(path, data, 0o640); err != nil {
			return Result{}, err //nolint:wrapcheck // test helper
		}

		files = append(files, path)
		total += int64(len(data))

		if onProgress != nil {
			onProgress(Progress{Current: total, Total: total})
		}
	}

	return Result{Dir: dst, Files: files, BytesTransferred: total}, nil
}

// GrabCallCount reports how many transfers have been started, safe to
// read while transfers are in flight.
func (f *FakeProvider) GrabCallCount() int {
	f.mu.Lock()
	defer f.mu.Unlock()

	return f.GrabCalls
}

// MaxParallelGrabs reports the most simultaneous transfers this
// provider ever saw, which is what a per-provider cap is asserted
// against.
func (f *FakeProvider) MaxParallelGrabs() int {
	f.mu.Lock()
	defer f.mu.Unlock()

	return f.maxParallel
}

// errNoDelegateStatus is returned when a fake delegator runs out of
// scripted statuses.
var errNoDelegateStatus = errors.New("fake: no delegate status configured")

// Delegate records the call and returns a fixed external ID.
func (f *FakeProvider) Delegate(
	_ context.Context,
	_ Download,
) (string, error) {
	f.mu.Lock()
	defer f.mu.Unlock()

	f.DelegateCalls++

	return "fake-external-1", nil
}

// Poll returns the next scripted status, repeating the last.
func (f *FakeProvider) Poll(
	_ context.Context,
	_ string,
) (DelegateStatus, error) {
	f.mu.Lock()
	defer f.mu.Unlock()

	if len(f.DelegateStatuses) == 0 {
		return DelegateStatus{}, errNoDelegateStatus
	}

	i := f.pollIndex
	if i >= len(f.DelegateStatuses) {
		i = len(f.DelegateStatuses) - 1
	} else {
		f.pollIndex++
	}

	return f.DelegateStatuses[i], nil
}

// Withdraw is a no-op.
func (f *FakeProvider) Withdraw(_ context.Context, _ string) error {
	return nil
}

// fakeRegistry lets tests install providers directly, bypassing the
// database and the constructor registry.
func (m *Manager) installProvider(cfg Config, p Provider) {
	m.provMu.Lock()
	defer m.provMu.Unlock()

	m.providers[cfg.ID] = p
	m.configs[cfg.ID] = cfg
}

func init() {
	Register(
		Descriptor{
			Kind:    KindFake,
			Name:    "Fake (testing)",
			Summary: "In-memory provider used by the test suite.",
			Caps: Caps{
				CanSearch:    true,
				CanTransport: true,
			},
		},
		func(cfg Config, _ SecretLookup, _ *slog.Logger) (Provider, error) {
			return NewFakeProvider(cfg.ID, cfg.Name, Caps{
				CanSearch:    true,
				CanTransport: true,
			}), nil
		},
	)
}

// slogDiscard returns a logger that writes nowhere, for tests.
func slogDiscard() *slog.Logger {
	return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
		Level: slog.LevelError + 1,
	}))
}
