// Package gitea is a minimal read-only client for the Gitea generic-package API. It lets the hub
// discover which artifact versions currently exist (olders get pruned) and read each file's sha256
// WITHOUT downloading the artifact — so the operator picks a version in the UI and the hub derives and
// vouches the checksum itself, instead of the operator pasting a 64-hex sha by hand.
package gitea

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"sort"
	"strconv"
	"strings"
	"time"
)

// Client talks to the Gitea packages API with basic auth (the registry username + token the hub
// already holds for the controller image pull).
type Client struct {
	baseURL string // e.g. https://gitea.dooplex.hu (no trailing slash)
	owner   string // package owner, e.g. admin
	user    string
	token   string
	http    *http.Client
}

// New builds a client. baseURL/owner/user/token come from the hub's registry config.
//
// ⚠ IT DOES NOT USE http.DefaultTransport, and that is the point (v0.100.2). The Configuration page
// resolves up to 40 package-metadata calls against this one host, now concurrently — and the default
// transport allows **MaxIdleConnsPerHost: 2**. Above that it opens a connection per request and
// throws it away afterwards, so almost every call paid a fresh TCP setup AND a fresh
// authentication, which is the expensive part: an unauthenticated `/api/v1/version` answers in
// ~0.03 s while an authenticated package call takes ~0.24 s against the same Gitea.
//
// Sizing it to the fan-out (16 = 8 in flight × the 2 dropdowns resolved side by side) lets the
// connections be reused for the whole page instead of churned.
func New(baseURL, owner, user, token string) *Client {
	tr := http.DefaultTransport.(*http.Transport).Clone()
	tr.MaxIdleConnsPerHost = 16
	tr.MaxConnsPerHost = 16 // a ceiling too: never stampede Gitea harder than the fan-out needs
	tr.IdleConnTimeout = 90 * time.Second
	return &Client{
		baseURL: strings.TrimRight(baseURL, "/"),
		owner:   owner,
		user:    user,
		token:   token,
		http:    &http.Client{Timeout: 10 * time.Second, Transport: tr},
	}
}

type pkgEntry struct {
	Name    string `json:"name"`
	Version string `json:"version"`
	Type    string `json:"type"`
}

// ListVersions returns the versions of a generic package, newest-semver first. Non-semver versions
// (if any) sort after the semver ones so nothing is silently dropped.
func (c *Client) ListVersions(ctx context.Context, pkgName string) ([]string, error) {
	u := fmt.Sprintf("%s/api/v1/packages/%s?type=generic&q=%s&limit=100",
		c.baseURL, c.owner, pkgName)
	var pkgs []pkgEntry
	if err := c.getJSON(ctx, u, &pkgs); err != nil {
		return nil, err
	}
	var vers []string
	for _, p := range pkgs {
		if p.Name == pkgName && p.Version != "" {
			vers = append(vers, p.Version)
		}
	}
	sort.SliceStable(vers, func(i, j int) bool { return compareSemver(vers[i], vers[j]) > 0 })
	return vers, nil
}

type pkgFile struct {
	Name   string `json:"name"`
	SHA256 string `json:"sha256"`
}

// FileSHA256 returns the sha256 of a file inside a generic package version. When the version holds
// multiple files it prefers an exact match to preferredFile (e.g. "golden.tar.zst"); otherwise it uses
// the first file. This is a cheap metadata call — the artifact bytes are never downloaded.
func (c *Client) FileSHA256(ctx context.Context, pkgName, version, preferredFile string) (string, error) {
	u := fmt.Sprintf("%s/api/v1/packages/%s/generic/%s/%s/files",
		c.baseURL, c.owner, pkgName, version)
	var files []pkgFile
	if err := c.getJSON(ctx, u, &files); err != nil {
		return "", err
	}
	if len(files) == 0 {
		return "", fmt.Errorf("no files listed for %s/%s", pkgName, version)
	}
	if preferredFile != "" {
		for _, f := range files {
			if f.Name == preferredFile && f.SHA256 != "" {
				return f.SHA256, nil
			}
		}
	}
	if files[0].SHA256 == "" {
		return "", fmt.Errorf("no sha256 for %s/%s file %q", pkgName, version, files[0].Name)
	}
	return files[0].SHA256, nil
}

func (c *Client) getJSON(ctx context.Context, url string, out interface{}) error {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return err
	}
	req.SetBasicAuth(c.user, c.token)
	req.Header.Set("Accept", "application/json")
	resp, err := c.http.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return fmt.Errorf("gitea GET %s: HTTP %d", url, resp.StatusCode)
	}
	return json.NewDecoder(resp.Body).Decode(out)
}

// compareSemver returns >0 if a>b, 0 if equal, <0 if a<b. Falls back to a lexical compare on a
// non-"X.Y.Z" input (mirrors web.compareVersions; kept local to avoid an import cycle).
func compareSemver(a, b string) int {
	a = strings.TrimPrefix(a, "v")
	b = strings.TrimPrefix(b, "v")
	ap := strings.SplitN(a, ".", 3)
	bp := strings.SplitN(b, ".", 3)
	if len(ap) != 3 || len(bp) != 3 {
		return strings.Compare(a, b)
	}
	for i := 0; i < 3; i++ {
		ai, e1 := strconv.Atoi(ap[i])
		bi, e2 := strconv.Atoi(bp[i])
		if e1 != nil || e2 != nil {
			return strings.Compare(a, b)
		}
		if ai != bi {
			return ai - bi
		}
	}
	return 0
}

// --- Installability probes (R-273) --------------------------------------------------------------
//
// WHY THESE EXIST. On 2026-08-08 agent v0.128.0 was published as a package and never git-tagged;
// the hub vouched it, and because felhom-host-install.sh fetches an agent's config files from
// `raw/tag/v<version>/configs/`, EVERY fresh install and reinstall died at step 5 of 8, as root, on
// a virgin machine, for most of a day. Nothing checked at the moment of risk. These two probes are
// what the artifact-manifest save now runs before it writes.
//
// They deliberately answer three ways — yes / no / could-not-tell — because "could not verify" and
// "is missing" must not collapse into one another at the save boundary.

// ProbeResult is a three-valued answer. Err non-nil means UNDETERMINED: the caller must refuse the
// save with a "could not verify" message rather than with a "missing" one.
type ProbeResult struct {
	OK  bool
	Err error
}

// TagServesFile reports whether `raw/tag/<tag>/<path>` resolves — i.e. whether the tag exists AND
// its tree carries the file. Both halves matter: a tag that exists but predates the config would
// 404 a box mid-install just as thoroughly as an absent tag.
//
// The caller passes the path the INSTALLER actually fetches. Asserting some other path that merely
// happens to exist is how Friday's failure stayed invisible.
func (c *Client) TagServesFile(ctx context.Context, repo, tag, path string) ProbeResult {
	url := fmt.Sprintf("%s/%s/%s/raw/tag/%s/%s", c.baseURL, c.owner, repo, tag, path)
	return c.probe(ctx, url)
}

// PackageDownloadable reports whether a generic package file can actually be fetched.
func (c *Client) PackageDownloadable(ctx context.Context, pkg, version, file string) ProbeResult {
	url := fmt.Sprintf("%s/api/packages/%s/generic/%s/%s/%s", c.baseURL, c.owner, pkg, version, file)
	return c.probe(ctx, url)
}

// probe does one GET and maps the outcome onto the three-valued answer. A GET rather than a HEAD:
// Gitea's raw and package routes do not answer HEAD consistently, and a probe that is wrong about
// its own method would be worse than no probe. Nothing reads the body.
func (c *Client) probe(ctx context.Context, url string) ProbeResult {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return ProbeResult{Err: err}
	}
	if c.token != "" {
		req.SetBasicAuth(c.user, c.token)
	}
	resp, err := c.http.Do(req)
	if err != nil {
		return ProbeResult{Err: err} // transport failure -> UNDETERMINED, never "missing"
	}
	defer resp.Body.Close()
	_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<12))
	switch {
	case resp.StatusCode == http.StatusOK:
		return ProbeResult{OK: true}
	case resp.StatusCode == http.StatusNotFound:
		return ProbeResult{OK: false} // a real, determined "it is not there"
	default:
		// 5xx, 401, 403, a proxy error: the registry did not tell us the thing is absent, it told us
		// it could not answer. Undetermined.
		return ProbeResult{Err: fmt.Errorf("registry returned HTTP %d for %s", resp.StatusCode, url)}
	}
}
