package handlers

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log"
	"net/http"
	"net/url"
	"sync"
	"time"

	"mgit.msbls.de/m/paliad/internal/branding"
	"mgit.msbls.de/m/paliad/internal/services"
)

const (
	giteaBaseURL  = "https://mgit.msbls.de"
	checkInterval = 5 * time.Minute
)

type fileEntry struct {
	RawURL       string
	DownloadName string
	ContentType  string
	RepoOwner    string
	RepoName     string
	FilePath     string
}

// fileRegistry maps the public download slug to the upstream Gitea object.
//
// RawURL / FilePath reference the actual file in mWorkRepo and must match the
// blob's name there exactly; renaming would 404 the proxy. DownloadName is
// what the browser saves the file as — that's a branding surface, so it
// renders branding.Name instead of the upstream filename.
//
// The URL slug ("hl-patents-style.dotm") is preserved as a stable public
// identifier so existing bookmarks keep working post-rebrand.
//
// Per-submission templates (slug `submission/<code>.docx`) are server-only:
// only the submission-draft editor reaches them via fetchSubmissionTemplateBytes.
// handleFileDownload serves any slug that lands here, but the public URL
// surface for submission templates is the export endpoint, not /files.
var fileRegistry = map[string]fileEntry{
	"hl-patents-style.dotm": {
		RawURL:       "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/HL%20Patents%20Style.dotm",
		DownloadName: branding.Name + " Patents Style.dotm",
		ContentType:  "application/vnd.ms-word.template.macroEnabled.12",
		RepoOwner:    "m",
		RepoName:     "mWorkRepo",
		FilePath:     "6 - material/Templates/Word/HL Patents Style.dotm",
	},
	// Per-submission demo template (t-paliad-241). Exercises every
	// placeholder SubmissionVarsService resolves so the
	// /projects/{id}/submissions/{code}/draft editor has variables to
	// substitute. One file per submission_code; future codes register
	// the same way — slug shape "submission/<code>.docx" so the
	// namespace stays separate from the universal style template.
	"submission/de.inf.lg.erwidg.docx": {
		RawURL:       "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/" + branding.Name + "/de.inf.lg.erwidg.docx",
		DownloadName: "Klageerwiderung — " + branding.Name + ".docx",
		ContentType:  "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
		RepoOwner:    "m",
		RepoName:     "mWorkRepo",
		FilePath:     "6 - material/Templates/Word/Paliad/" + branding.Name + "/de.inf.lg.erwidg.docx",
	},
	// Universal skeleton (t-paliad-259). Code-agnostic Schriftsatz starter
	// that carries every placeholder SubmissionVarsService resolves but no
	// submission_code-specific body structure. Slot between the per-firm
	// per-code template and the bare HL Patents Style .dotm fallback: every
	// submission_code without a dedicated template still renders with
	// variables substituted instead of the macro-only letterhead.
	skeletonSubmissionSlug: {
		RawURL:       "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.docx",
		DownloadName: branding.Name + " — Schriftsatz-Skelett.docx",
		ContentType:  "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
		RepoOwner:    "m",
		RepoName:     "mWorkRepo",
		FilePath:     "6 - material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.docx",
	},
	// Firm-formatted skeleton (t-paliad-275). Carries the same 48-key
	// placeholder bag as the universal _skeleton.docx, but additionally
	// preserves every HL paragraph + character style from the HL Patents
	// Style .dotm (HLpat-Heading-H1..H5, HLpat-Body-B0, HLpat-Header-Section,
	// HLpat-Table-Recitals-*, HLpat-Signature, …) and the firm letterhead
	// (header logo + firm-address footer). Slotted ahead of the universal
	// skeleton in the fallback chain so any submission_code without a
	// dedicated per-code template still renders as a real firm-branded
	// Schriftsatz with variables substituted, rather than a plain skeleton.
	// Generated via scripts/gen-hl-skeleton-template against the .dotm.
	firmSkeletonSubmissionSlug: {
		RawURL:       "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/" + branding.Name + "/_firm-skeleton.docx",
		DownloadName: branding.Name + " — Firm Schriftsatz-Skelett.docx",
		ContentType:  "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
		RepoOwner:    "m",
		RepoName:     "mWorkRepo",
		FilePath:     "6 - material/Templates/Word/Paliad/" + branding.Name + "/_firm-skeleton.docx",
	},
	// English skeleton variant (t-paliad-276). Sibling of
	// `_skeleton.docx`; used when a draft's language='en' and no
	// per-code EN template exists. If the file isn't authored yet in
	// mWorkRepo, the Gitea fetch fails and resolveSubmissionTemplate
	// falls through to the DE skeleton — visible to the user as the
	// "Fallback: universelles Skelett" notice on the draft editor.
	skeletonSubmissionENSlug: {
		RawURL:       "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.en.docx",
		DownloadName: branding.Name + " — Submission skeleton.docx",
		ContentType:  "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
		RepoOwner:    "m",
		RepoName:     "mWorkRepo",
		FilePath:     "6 - material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.en.docx",
	},
	// t-paliad-317 Composer Slice E — specialist firm-agnostic bases.
	// Both live under Composer/ (not under HLC/) so a future non-HLC
	// deployment serves the same cross-firm files. Body = anchor-only
	// per Slice B; styles.xml carries the preset's typography.
	composerBaseLGDuesseldorfSlug: {
		RawURL:       "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/Composer/lg-duesseldorf.docx",
		DownloadName: "LG-Düsseldorf Stil.docx",
		ContentType:  "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
		RepoOwner:    "m",
		RepoName:     "mWorkRepo",
		FilePath:     "6 - material/Templates/Word/Paliad/Composer/lg-duesseldorf.docx",
	},
	composerBaseUPCFormalSlug: {
		RawURL:       "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/Composer/upc-formal.docx",
		DownloadName: "UPC formal.docx",
		ContentType:  "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
		RepoOwner:    "m",
		RepoName:     "mWorkRepo",
		FilePath:     "6 - material/Templates/Word/Paliad/Composer/upc-formal.docx",
	},
}

// t-paliad-317 Composer Slice E — slugs for the new specialist bases.
const (
	composerBaseLGDuesseldorfSlug = "submission/composer/lg-duesseldorf.docx"
	composerBaseUPCFormalSlug     = "submission/composer/upc-formal.docx"
)

// skeletonSubmissionSlug names the universal skeleton template inside
// the shared fileRegistry cache. Exported via a const so handler code
// (resolveSubmissionTemplate, hlPatentsStyleSHA's sibling) refers to
// the same string the registry uses.
const skeletonSubmissionSlug = "submission/_skeleton.docx"

// firmSkeletonSubmissionSlug names the firm-formatted skeleton template
// inside the shared fileRegistry cache (t-paliad-275). Same placeholder
// surface as skeletonSubmissionSlug; carries HL paragraph + character
// styles from the source .dotm on top. Sits between the per-code
// template and the generic universal skeleton in the fallback chain so
// codes without a dedicated template still render with firm branding.
const firmSkeletonSubmissionSlug = "submission/_firm-skeleton.docx"

// skeletonSubmissionENSlug names the English skeleton variant used when
// a draft's language='en' and no per-code EN template exists
// (t-paliad-276). Same role as skeletonSubmissionSlug but in EN.
const skeletonSubmissionENSlug = "submission/_skeleton.en.docx"

// submissionTemplateRegistry maps a deadline-rule submission_code to a
// fileRegistry slug. Lookup order matches the cronus design fallback
// chain §8: per-firm `templates/{FIRM_NAME}/{code}.docx` first, then
// universal HL Patents Style as the global fallback.
//
// Add new entries here as the firm authors per-submission templates;
// the file itself lives in mWorkRepo and is served through the shared
// Gitea proxy cache so refreshes are visible to all consumers in one
// place.
//
// t-paliad-276: codes that ship an EN sibling
// (e.g. `de.inf.lg.erwidg.en.docx`) also register it in
// submissionTemplateENRegistry; the language-aware lookup
// (resolveSubmissionTemplate(ctx, code, lang)) prefers the language-
// suffixed slug and falls back to the unsuffixed one when no per-firm
// EN variant exists.
var submissionTemplateRegistry = map[string]string{
	"de.inf.lg.erwidg": "submission/de.inf.lg.erwidg.docx",
}

// submissionTemplateENRegistry maps a submission_code to the EN
// variant slug. Empty when no EN template has been authored — the
// lookup falls through to the unsuffixed (DE-baked) template and the
// editor surfaces the "Fallback: universelles Skelett" notice when
// even the skeleton has no EN sibling.
var submissionTemplateENRegistry = map[string]string{}

// fetchSubmissionTemplateBytes returns the per-submission_code template
// bytes (and provenance SHA) when one is registered. The bool result
// distinguishes "no per-code template registered" (callers fall back to
// HL Patents Style) from an upstream fetch error.
//
// Language-suffixed variants (t-paliad-276) are served via
// fetchSubmissionTemplateBytesForLang — this base function returns the
// unsuffixed registry entry only (the legacy DE-baked template).
func fetchSubmissionTemplateBytes(ctx context.Context, submissionCode string) ([]byte, string, bool, error) {
	slug, ok := submissionTemplateRegistry[submissionCode]
	if !ok {
		return nil, "", false, nil
	}
	entry, ok := fileRegistry[slug]
	if !ok {
		return nil, "", false, fmt.Errorf("file proxy: submission template slug %q not registered", slug)
	}
	ce := getCacheEntry(slug)

	ce.mu.RLock()
	hasData := len(ce.data) > 0
	needsCheck := time.Since(ce.lastChecked) >= checkInterval
	ce.mu.RUnlock()

	if !hasData {
		if err := fileFetch(ce, entry); err != nil {
			return nil, "", false, err
		}
	} else if needsCheck {
		go fileCheckAndRefresh(ce, entry)
	}

	ce.mu.RLock()
	defer ce.mu.RUnlock()
	if len(ce.data) == 0 {
		return nil, "", false, fmt.Errorf("file proxy: %s cache empty after fetch", slug)
	}
	out := make([]byte, len(ce.data))
	copy(out, ce.data)
	_ = ctx
	return out, ce.sha, true, nil
}

type cacheEntry struct {
	mu          sync.RWMutex
	data        []byte
	sha         string
	lastChecked time.Time
	checking    bool
}

var (
	giteaToken  string
	fileCache   = make(map[string]*cacheEntry)
	fileCacheMu sync.Mutex
	httpClient  = &http.Client{Timeout: 30 * time.Second}
)

func getCacheEntry(name string) *cacheEntry {
	fileCacheMu.Lock()
	defer fileCacheMu.Unlock()
	ce, ok := fileCache[name]
	if !ok {
		ce = &cacheEntry{}
		fileCache[name] = ce
	}
	return ce
}

func handleFileDownload(w http.ResponseWriter, r *http.Request) {
	filename := r.PathValue("filename")
	entry, ok := fileRegistry[filename]
	if !ok {
		http.NotFound(w, r)
		return
	}

	ce := getCacheEntry(filename)

	ce.mu.RLock()
	hasData := len(ce.data) > 0
	needsCheck := time.Since(ce.lastChecked) >= checkInterval
	ce.mu.RUnlock()

	if !hasData {
		if err := fileFetch(ce, entry); err != nil {
			log.Printf("file proxy: fetch %s failed: %v", filename, err)
			http.Error(w, "Failed to fetch file", http.StatusBadGateway)
			return
		}
	} else if needsCheck {
		go fileCheckAndRefresh(ce, entry)
	}

	ce.mu.RLock()
	defer ce.mu.RUnlock()

	w.Header().Set("Content-Type", entry.ContentType)
	w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, entry.DownloadName))
	w.Header().Set("Content-Length", fmt.Sprintf("%d", len(ce.data)))
	w.Write(ce.data)
}

func handleFileRefresh(w http.ResponseWriter, r *http.Request) {
	fileCacheMu.Lock()
	for name := range fileCache {
		fileCache[name] = &cacheEntry{}
	}
	fileCacheMu.Unlock()

	writeJSON(w, http.StatusOK, map[string]string{"ok": "true", "message": "Cache cleared"})
}

// fetchSubmissionTemplateBytesForLang returns the per-(code, lang)
// template bytes when a language-suffixed variant is registered. Used
// only for the EN variant today; DE goes through the unsuffixed
// fetchSubmissionTemplateBytes (which is the legacy / authoritative
// DE registry). t-paliad-276.
//
// Returned bool = "variant registered AND fetched OK". A registered
// variant whose file 404s on Gitea returns (nil, "", false, nil) so
// the caller falls through to the unsuffixed template, mirroring the
// behaviour for unregistered codes.
func fetchSubmissionTemplateBytesForLang(ctx context.Context, submissionCode, lang string) ([]byte, string, bool, error) {
	if lang != "en" {
		// Only EN has a separate registry today. DE goes through the
		// unsuffixed path which is the authoritative DE template.
		return nil, "", false, nil
	}
	slug, ok := submissionTemplateENRegistry[submissionCode]
	if !ok {
		return nil, "", false, nil
	}
	entry, ok := fileRegistry[slug]
	if !ok {
		return nil, "", false, fmt.Errorf("file proxy: submission template slug %q not registered", slug)
	}
	ce := getCacheEntry(slug)

	ce.mu.RLock()
	hasData := len(ce.data) > 0
	needsCheck := time.Since(ce.lastChecked) >= checkInterval
	ce.mu.RUnlock()

	if !hasData {
		if err := fileFetch(ce, entry); err != nil {
			// Treat upstream miss as "variant unavailable" so the
			// resolver falls through to the DE template instead of
			// surfacing a 502.
			log.Printf("file proxy: EN variant fetch failed for %s (%s): %v — falling through", submissionCode, slug, err)
			return nil, "", false, nil
		}
	} else if needsCheck {
		go fileCheckAndRefresh(ce, entry)
	}

	ce.mu.RLock()
	defer ce.mu.RUnlock()
	if len(ce.data) == 0 {
		return nil, "", false, nil
	}
	out := make([]byte, len(ce.data))
	copy(out, ce.data)
	_ = ctx
	return out, ce.sha, true, nil
}

// fetchSubmissionSkeletonBytesForLang returns the cached skeleton
// template bytes for the requested language. EN falls back to DE when
// the EN skeleton hasn't been authored yet (t-paliad-276). Returned
// bool flags whether the bytes match the requested language — false
// means the resolver should communicate "fallback" to the UI.
func fetchSubmissionSkeletonBytesForLang(ctx context.Context, lang string) ([]byte, string, bool, error) {
	if lang == "en" {
		entry, ok := fileRegistry[skeletonSubmissionENSlug]
		if ok {
			ce := getCacheEntry(skeletonSubmissionENSlug)
			ce.mu.RLock()
			hasData := len(ce.data) > 0
			needsCheck := time.Since(ce.lastChecked) >= checkInterval
			ce.mu.RUnlock()
			if !hasData {
				if err := fileFetch(ce, entry); err == nil {
					ce.mu.RLock()
					if len(ce.data) > 0 {
						out := make([]byte, len(ce.data))
						copy(out, ce.data)
						sha := ce.sha
						ce.mu.RUnlock()
						return out, sha, true, nil
					}
					ce.mu.RUnlock()
				} else {
					log.Printf("file proxy: EN skeleton fetch failed (%s): %v — falling back to DE", skeletonSubmissionENSlug, err)
				}
			} else {
				if needsCheck {
					go fileCheckAndRefresh(ce, entry)
				}
				ce.mu.RLock()
				if len(ce.data) > 0 {
					out := make([]byte, len(ce.data))
					copy(out, ce.data)
					sha := ce.sha
					ce.mu.RUnlock()
					return out, sha, true, nil
				}
				ce.mu.RUnlock()
			}
		}
	}
	// Fall through to the DE skeleton; bool=false flags that the
	// returned bytes don't carry the requested language.
	bytes, sha, err := fetchSubmissionSkeletonBytes(ctx)
	if err != nil {
		return nil, "", false, err
	}
	return bytes, sha, lang == "de", nil
}

// fetchSubmissionSkeletonBytes returns the cached universal skeleton
// template bytes plus its provenance SHA. Sits between the per-firm
// per-submission_code template (fetchSubmissionTemplateBytes) and the
// bare universal HL Patents Style .dotm (fetchHLPatentsStyleBytes) in
// resolveSubmissionTemplate's fallback chain — used for every
// submission_code that has no dedicated template registered. Same
// stale-while-revalidate semantics as the rest of the file proxy: first
// call warms the cache synchronously from mWorkRepo via Gitea; later
// calls return immediately while a background refresh runs.
func fetchSubmissionSkeletonBytes(ctx context.Context) ([]byte, string, error) {
	return fetchSubmissionTemplateSlug(ctx, skeletonSubmissionSlug)
}

// fetchFirmSkeletonBytes returns the cached firm-formatted skeleton
// template bytes (HL paragraph/character styles + 48-key placeholder
// bag) plus its provenance SHA. Sits between the per-code template and
// the generic universal skeleton in resolveSubmissionTemplate's
// fallback chain (t-paliad-275). Same stale-while-revalidate caching
// as the other Gitea-backed template parts.
func fetchFirmSkeletonBytes(ctx context.Context) ([]byte, string, error) {
	return fetchSubmissionTemplateSlug(ctx, firmSkeletonSubmissionSlug)
}

// composerBaseSlugMap routes a Composer base.slug to the existing
// fileRegistry slug whose Gitea object backs it (t-paliad-313 Slice B).
// Slice A seeded two bases that already share .docx files with the v1
// fallback chain — no new Gitea uploads needed for those. Future bases
// (e.g. lg-duesseldorf, upc-formal in Slice E) register their own
// fileRegistry entries via the same shape and add a row here.
var composerBaseSlugMap = map[string]string{
	"hlc-letterhead": firmSkeletonSubmissionSlug,
	"neutral":        skeletonSubmissionSlug,
	"lg-duesseldorf": composerBaseLGDuesseldorfSlug,
	"upc-formal":     composerBaseUPCFormalSlug,
}

// fetchComposerBaseBytes returns the .docx bytes for a Composer base,
// pulled from the shared Gitea proxy cache. ErrComposerBaseNotProxied
// when the slug has no registered fileRegistry entry — a base authored
// without a file-registry mapping (rare; admin oversight) renders as
// "Vorlagenbasis nicht erreichbar" upstream of this call.
var ErrComposerBaseNotProxied = errors.New("composer base: Gitea slug not registered")

func fetchComposerBaseBytes(ctx context.Context, base *services.SubmissionBase) ([]byte, string, error) {
	if base == nil {
		return nil, "", fmt.Errorf("composer base: nil base")
	}
	slug, ok := composerBaseSlugMap[base.Slug]
	if !ok {
		return nil, "", fmt.Errorf("%w: base slug %q", ErrComposerBaseNotProxied, base.Slug)
	}
	return fetchSubmissionTemplateSlug(ctx, slug)
}

// fetchSubmissionTemplateSlug is the shared cache-aware fetcher used by
// the firm-skeleton and universal-skeleton accessors. Factored out so
// the two paths can't drift apart on caching semantics.
func fetchSubmissionTemplateSlug(ctx context.Context, slug string) ([]byte, string, error) {
	entry, ok := fileRegistry[slug]
	if !ok {
		return nil, "", fmt.Errorf("file proxy: %s not registered", slug)
	}
	ce := getCacheEntry(slug)

	ce.mu.RLock()
	hasData := len(ce.data) > 0
	needsCheck := time.Since(ce.lastChecked) >= checkInterval
	ce.mu.RUnlock()

	if !hasData {
		if err := fileFetch(ce, entry); err != nil {
			return nil, "", err
		}
	} else if needsCheck {
		go fileCheckAndRefresh(ce, entry)
	}

	ce.mu.RLock()
	defer ce.mu.RUnlock()
	if len(ce.data) == 0 {
		return nil, "", fmt.Errorf("file proxy: %s cache empty after fetch", slug)
	}
	out := make([]byte, len(ce.data))
	copy(out, ce.data)
	_ = ctx
	return out, ce.sha, nil
}

// fetchHLPatentsStyleBytes returns the cached HL Patents Style .dotm
// bytes. Shared accessor used by both the /files/{slug} download path
// (Word auto-update channel) and the submission generator
// (handlers/submissions.go) so a refresh through one path is visible to
// the other. First call warms the cache from Gitea synchronously;
// subsequent calls are sub-millisecond. A stale-but-present cache is
// returned immediately while a background refresh runs.
func fetchHLPatentsStyleBytes(ctx context.Context) ([]byte, error) {
	entry, ok := fileRegistry[hlPatentsStyleSlug]
	if !ok {
		return nil, fmt.Errorf("file proxy: %s not registered", hlPatentsStyleSlug)
	}
	ce := getCacheEntry(hlPatentsStyleSlug)

	ce.mu.RLock()
	hasData := len(ce.data) > 0
	needsCheck := time.Since(ce.lastChecked) >= checkInterval
	ce.mu.RUnlock()

	if !hasData {
		if err := fileFetch(ce, entry); err != nil {
			return nil, err
		}
	} else if needsCheck {
		go fileCheckAndRefresh(ce, entry)
	}

	ce.mu.RLock()
	defer ce.mu.RUnlock()
	if len(ce.data) == 0 {
		return nil, fmt.Errorf("file proxy: %s cache empty after fetch", hlPatentsStyleSlug)
	}
	out := make([]byte, len(ce.data))
	copy(out, ce.data)
	_ = ctx // ctx reserved for future timeout pass-through; fileFetch
	// uses the package httpClient timeout today.
	return out, nil
}

// fileFetch downloads the file synchronously (first request).
func fileFetch(ce *cacheEntry, entry fileEntry) error {
	sha, _ := giteaLatestSHA(entry)

	data, err := giteaDownload(entry)
	if err != nil {
		return err
	}

	ce.mu.Lock()
	ce.data = data
	ce.sha = sha
	ce.lastChecked = time.Now()
	ce.mu.Unlock()
	return nil
}

// fileCheckAndRefresh checks the latest commit SHA and re-downloads if changed.
func fileCheckAndRefresh(ce *cacheEntry, entry fileEntry) {
	ce.mu.Lock()
	if ce.checking {
		ce.mu.Unlock()
		return
	}
	ce.checking = true
	ce.mu.Unlock()

	defer func() {
		ce.mu.Lock()
		ce.checking = false
		ce.mu.Unlock()
	}()

	latestSHA, err := giteaLatestSHA(entry)
	if err != nil {
		log.Printf("file proxy: SHA check for %s failed: %v", entry.DownloadName, err)
		ce.mu.Lock()
		ce.lastChecked = time.Now()
		ce.mu.Unlock()
		return
	}

	ce.mu.RLock()
	unchanged := latestSHA == ce.sha && ce.sha != ""
	ce.mu.RUnlock()

	if unchanged {
		ce.mu.Lock()
		ce.lastChecked = time.Now()
		ce.mu.Unlock()
		return
	}

	data, err := giteaDownload(entry)
	if err != nil {
		log.Printf("file proxy: download %s failed: %v", entry.DownloadName, err)
		ce.mu.Lock()
		ce.lastChecked = time.Now()
		ce.mu.Unlock()
		return
	}

	ce.mu.Lock()
	ce.data = data
	ce.sha = latestSHA
	ce.lastChecked = time.Now()
	ce.mu.Unlock()

	log.Printf("file proxy: updated %s (SHA: %.8s)", entry.DownloadName, latestSHA)
}

// giteaLatestSHA returns the SHA of the latest commit that touched the file.
func giteaLatestSHA(entry fileEntry) (string, error) {
	apiURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/commits?path=%s&limit=1&sha=main",
		giteaBaseURL, entry.RepoOwner, entry.RepoName, url.QueryEscape(entry.FilePath))

	req, err := http.NewRequest("GET", apiURL, nil)
	if err != nil {
		return "", err
	}
	if giteaToken != "" {
		req.Header.Set("Authorization", "token "+giteaToken)
	}

	resp, err := httpClient.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return "", fmt.Errorf("gitea API returned %d", resp.StatusCode)
	}

	var commits []struct {
		SHA string `json:"sha"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&commits); err != nil {
		return "", err
	}
	if len(commits) == 0 {
		return "", fmt.Errorf("no commits for path %s", entry.FilePath)
	}

	return commits[0].SHA, nil
}

// giteaDownload fetches the raw file content from Gitea.
func giteaDownload(entry fileEntry) ([]byte, error) {
	req, err := http.NewRequest("GET", entry.RawURL, nil)
	if err != nil {
		return nil, err
	}
	if giteaToken != "" {
		req.Header.Set("Authorization", "token "+giteaToken)
	}

	resp, err := httpClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("gitea raw returned %d", resp.StatusCode)
	}

	return io.ReadAll(resp.Body)
}
