package updater

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"os"
	"path"
	"path/filepath"

	"banger/internal/download"
)

// DownloadRelease fetches the SHA256SUMS file for `release`, looks up
// the tarball's basename in it, then fetches the tarball with on-the-
// fly hash verification. The tarball lands at dstPath; the function
// errors on any verification failure and removes the partial file
// before returning.
//
// SHA256SUMS bytes are returned alongside so the caller can
// cosign-verify them against an embedded public key before trusting
// the hashes inside. Without that step this function is only as
// secure as TLS; see verify_signature.go for the cosign tie-in.
func DownloadRelease(ctx context.Context, client *http.Client, release Release, dstPath string) (sumsBody []byte, err error) {
	if client == nil {
		client = http.DefaultClient
	}

	sumsBody, err = fetchBounded(ctx, client, release.SHA256SumsURL, MaxSHA256SumsBytes)
	if err != nil {
		return nil, fmt.Errorf("fetch SHA256SUMS: %w", err)
	}
	sums, err := ParseSHA256Sums(sumsBody)
	if err != nil {
		return nil, fmt.Errorf("parse SHA256SUMS: %w", err)
	}

	tarballName := path.Base(release.TarballURL)
	expected, ok := sums[tarballName]
	if !ok {
		return nil, fmt.Errorf("SHA256SUMS does not list %q", tarballName)
	}
	if _, err := download.FetchVerified(ctx, client, release.TarballURL, expected, MaxTarballBytes, dstPath); err != nil {
		return nil, fmt.Errorf("fetch tarball: %w", err)
	}
	return sumsBody, nil
}

// fetchBounded does a small bounded GET — used for the manifest, the
// SHA256SUMS file, and (later) the cosign signature. Anything bigger
// goes through download.FetchVerified, which adds the on-the-fly
// hash check.
func fetchBounded(ctx context.Context, client *http.Client, url string, maxBytes int64) ([]byte, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return nil, err
	}
	resp, err := client.Do(req)
	if err != nil {
		return nil, fmt.Errorf("fetch %s: %w", url, err)
	}
	defer resp.Body.Close()
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return nil, fmt.Errorf("fetch %s: HTTP %s", url, resp.Status)
	}
	if resp.ContentLength > maxBytes {
		return nil, fmt.Errorf("fetch %s: %d bytes exceeds %d-byte cap", url, resp.ContentLength, maxBytes)
	}
	body, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
	if err != nil {
		return nil, fmt.Errorf("read %s: %w", url, err)
	}
	if int64(len(body)) > maxBytes {
		return nil, fmt.Errorf("%s exceeded %d-byte cap mid-stream", url, maxBytes)
	}
	return body, nil
}

// EnsureStagingDir creates the staging directory with restrictive
// permissions (0700, owned by the caller — typically root in system
// mode). Any pre-existing contents are NOT cleared; that's
// PrepareCleanStaging's job.
func EnsureStagingDir(stagingDir string) error {
	return os.MkdirAll(stagingDir, 0o700)
}

// PrepareCleanStaging wipes anything left in the staging dir from a
// prior aborted update, then re-creates the directory. Distinct from
// EnsureStagingDir because we don't want to nuke the dir unless
// we're ABOUT to use it — having a leftover staged tree from a
// prior failed run is sometimes useful for diagnostics.
func PrepareCleanStaging(stagingDir string) error {
	if err := os.RemoveAll(stagingDir); err != nil {
		return fmt.Errorf("clear staging %s: %w", stagingDir, err)
	}
	return EnsureStagingDir(stagingDir)
}

// DefaultStagingDir is where the updater stages downloads +
// extracted binaries when no explicit dir is configured. Sits under
// banger's system CacheDir (typically /var/cache/banger/updates) so:
//   - the systemd unit's CacheDirectory=banger keeps the path
//     writable for the helper.
//   - `banger system uninstall --purge` cleans it.
//   - it sits beside the OCI and kernel caches without colliding.
//
// Atomicity caveat: we expect /var/cache and /usr/local to share a
// filesystem (default on essentially every Linux install). On a host
// with /usr split onto a separate volume, the swap step's os.Rename
// would fall through to a copy + delete and lose its atomicity
// guarantee. We document this rather than detect-and-error for
// v0.1.0; the worst-case symptom is a brief window where a binary is
// half-written, which `banger doctor` would catch in step 7.
func DefaultStagingDir(cacheDir string) string {
	return filepath.Join(cacheDir, "updates")
}
