package services

// Submission template registry — Gitea-backed .docx template loader for
// the submission generator (t-paliad-215, design doc
// docs/design-submission-generator-2026-05-19.md §5).
//
// Layout in mWorkRepo:
//
//   templates/{FIRM_NAME}/{submission_code}.docx   firm-specific override
//   templates/_base/{submission_code}.docx         cross-firm baseline
//   templates/_base/{family}.docx                  proceeding-family fallback
//   templates/_base/_skeleton.docx                 ultra-generic fallback
//
// Lookup is first-match-wins down the chain; this is the m-locked Q4
// decision. Templates fetched via Gitea's raw URL endpoint, cached
// in-process with a 5-minute SHA refresh check — identical pattern to
// the HL Patents Style proxy in internal/handlers/files.go (which the
// design doc §1 verified is in production and works).
//
// Slice 1 ships one template at templates/_base/de.inf.lg.erwidg.docx
// (committed to HL/mWorkRepo at SHA 7f97b7f9, the bootstrap demo
// authored by the engine for end-to-end testing — HLC ships the
// polished version per §14 follow-up).

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

const (
	templatesGiteaBaseURL  = "https://mgit.msbls.de"
	templatesGiteaRepoOwn  = "HL"
	templatesGiteaRepoName = "mWorkRepo"
	templatesGiteaBranch   = "main"
	templatesCheckInterval = 5 * time.Minute
	templatesSkeleton      = "_skeleton"
)

// ErrNoTemplate is returned when no template resolves anywhere in the
// fallback chain (firm/code → base/code → base/family → skeleton).
// Caller maps to 503 + a clear UI hint.
var ErrNoTemplate = errors.New("submission template: no template resolved in fallback chain")

// ErrTemplateUpstream wraps Gitea-side failures (network, 5xx).
// Distinct from ErrNoTemplate so the handler can render different UI:
// "no template configured" vs "template repo unreachable".
var ErrTemplateUpstream = errors.New("submission template: upstream Gitea unreachable")

// ResolvedTemplate is the result of a fallback-chain lookup: the
// template bytes plus the metadata the audit row + UI need.
type ResolvedTemplate struct {
	// Path is the Gitea-relative path that resolved (e.g.
	// "templates/HLC/de.inf.lg.erwidg.docx"). Persisted in the
	// system_audit_log row so an admin can trace which template was
	// used for a given generation.
	Path string

	// SHA is the commit SHA the template was fetched at. Pinning this
	// lets audit consumers reproduce the exact bytes that went into
	// the lawyer's download.
	SHA string

	// FirmTier reports which level of the fallback chain fired:
	// "firm", "base_code", "base_family", or "skeleton". Useful for
	// the variable-contract sidebar (Slice 3) and for ops monitoring
	// of how often each firm is actually overriding.
	FirmTier string

	// Bytes is the .docx content; only populated for callers that
	// need to render (i.e. SubmissionRenderer.Render). Resolve()
	// returns it populated; Probe() leaves it nil.
	Bytes []byte
}

// templateCacheEntry mirrors the per-file cache shape used by
// internal/handlers/files.go. Each cached entry tracks its bytes, the
// commit SHA, the last upstream check, and a checking flag so two
// concurrent refresh goroutines don't double-fetch.
type templateCacheEntry struct {
	mu          sync.RWMutex
	data        []byte
	sha         string
	lastChecked time.Time
	checking    bool
	missing     bool // true when Gitea returned 404 — short-circuits subsequent lookups
}

// TemplateRegistry resolves submission templates from Gitea using the
// fallback chain. Process-wide cache; single-replica deployment (per
// docs/design-submission-generator-2026-05-19.md §1) makes in-process
// caching sufficient — a future multi-replica rollout would swap this
// for a shared cache. Same trade-off the HL Patents Style proxy makes.
type TemplateRegistry struct {
	cache      map[string]*templateCacheEntry
	cacheMu    sync.Mutex
	giteaToken string
	httpClient *http.Client
	firmName   string
}

// NewTemplateRegistry constructs the registry. firmName is read once
// at process start from internal/branding.Name so a runtime FIRM_NAME
// rebrand cuts in on the next deploy, not mid-request.
func NewTemplateRegistry(giteaToken, firmName string) *TemplateRegistry {
	return &TemplateRegistry{
		cache:      make(map[string]*templateCacheEntry),
		giteaToken: giteaToken,
		firmName:   firmName,
		httpClient: &http.Client{Timeout: 30 * time.Second},
	}
}

// HasTemplate reports whether any template resolves for the given
// submission code, without fetching the bytes. Used by the
// SubmissionsPanel to decide which "Generate" buttons to enable.
//
// Cheap path: walks the same fallback chain as Resolve, but stops at
// the SHA-probe step (Gitea's contents endpoint, single round-trip per
// candidate). The probe results land in the same cache as Resolve so a
// subsequent Resolve call reuses the SHA.
func (r *TemplateRegistry) HasTemplate(ctx context.Context, submissionCode string) bool {
	for _, candidate := range r.candidates(submissionCode) {
		if r.probe(ctx, candidate) {
			return true
		}
	}
	return false
}

// Resolve walks the fallback chain and returns the first template that
// fetches successfully, with bytes loaded. Returns ErrNoTemplate when
// no candidate (including the ultra-generic skeleton) resolves.
func (r *TemplateRegistry) Resolve(ctx context.Context, submissionCode string) (*ResolvedTemplate, error) {
	candidates := r.candidates(submissionCode)
	tiers := r.tiers(submissionCode)
	if len(candidates) != len(tiers) {
		return nil, fmt.Errorf("template registry: candidate/tier mismatch (%d vs %d)", len(candidates), len(tiers))
	}
	for i, candidate := range candidates {
		entry := r.cacheGet(candidate)
		entry.mu.RLock()
		hasData := !entry.missing && len(entry.data) > 0
		needsCheck := time.Since(entry.lastChecked) >= templatesCheckInterval
		isMissing := entry.missing
		entry.mu.RUnlock()

		if isMissing && !needsCheck {
			continue
		}
		if !hasData {
			if err := r.fetchInto(ctx, candidate, entry); err != nil {
				if errors.Is(err, errTemplate404) {
					continue
				}
				return nil, fmt.Errorf("%w: %v", ErrTemplateUpstream, err)
			}
		} else if needsCheck {
			go r.refresh(context.Background(), candidate, entry)
		}

		entry.mu.RLock()
		out := &ResolvedTemplate{
			Path:     candidate,
			SHA:      entry.sha,
			FirmTier: tiers[i],
			Bytes:    append([]byte(nil), entry.data...),
		}
		entry.mu.RUnlock()
		return out, nil
	}
	return nil, ErrNoTemplate
}

// candidates returns the ordered Gitea-relative paths the registry
// walks for the given submission code. The order is the m-locked Q4
// decision: firm → base/code → base/family → skeleton.
func (r *TemplateRegistry) candidates(submissionCode string) []string {
	family := familyOf(submissionCode)
	out := []string{
		fmt.Sprintf("templates/%s/%s.docx", r.firmName, submissionCode),
		fmt.Sprintf("templates/_base/%s.docx", submissionCode),
	}
	if family != "" && family != submissionCode {
		out = append(out, fmt.Sprintf("templates/_base/%s.docx", family))
	}
	out = append(out, fmt.Sprintf("templates/_base/%s.docx", templatesSkeleton))
	return out
}

// tiers labels each candidate with its fallback tier. Order is locked
// to candidates(); both functions evolve together.
func (r *TemplateRegistry) tiers(submissionCode string) []string {
	family := familyOf(submissionCode)
	out := []string{"firm", "base_code"}
	if family != "" && family != submissionCode {
		out = append(out, "base_family")
	}
	out = append(out, "skeleton")
	return out
}

// familyOf extracts the proceeding-family prefix from a submission
// code. The convention (docs/design-proceeding-code-taxonomy-2026-05-18.md)
// is jurisdiction.substantive.forum.submission, so the family is the
// first three dot-segments.
//
//	de.inf.lg.erwidg → de.inf.lg
//	upc.inf.cfi.soc  → upc.inf.cfi
//	dpma.opp.dpma    → ""           (only three segments — no submission suffix)
//
// Returns "" when the code doesn't carry a submission segment (no
// family-level fallback is meaningful).
func familyOf(submissionCode string) string {
	parts := strings.Split(submissionCode, ".")
	if len(parts) < 4 {
		return ""
	}
	return strings.Join(parts[:3], ".")
}

// cacheGet returns the cache entry for a Gitea path, creating an empty
// entry on first lookup.
func (r *TemplateRegistry) cacheGet(path string) *templateCacheEntry {
	r.cacheMu.Lock()
	defer r.cacheMu.Unlock()
	entry, ok := r.cache[path]
	if !ok {
		entry = &templateCacheEntry{}
		r.cache[path] = entry
	}
	return entry
}

// errTemplate404 is an internal sentinel: candidate doesn't exist in
// Gitea, walk the chain. Distinguished from network/5xx errors so the
// registry doesn't wrap every fallback miss as ErrTemplateUpstream.
var errTemplate404 = errors.New("template not found in gitea")

// fetchInto downloads a candidate and populates the cache entry. On
// 404 it marks the entry missing so subsequent lookups short-circuit
// without hitting the network.
func (r *TemplateRegistry) fetchInto(ctx context.Context, path string, entry *templateCacheEntry) error {
	sha, err := r.giteaSHA(ctx, path)
	if err != nil {
		if errors.Is(err, errTemplate404) {
			entry.mu.Lock()
			entry.missing = true
			entry.lastChecked = time.Now()
			entry.mu.Unlock()
		}
		return err
	}
	data, err := r.giteaDownload(ctx, path)
	if err != nil {
		return err
	}
	entry.mu.Lock()
	entry.data = data
	entry.sha = sha
	entry.lastChecked = time.Now()
	entry.missing = false
	entry.mu.Unlock()
	return nil
}

// refresh runs in the background after a stale-but-present cache hit.
// SHA-checks the candidate; re-downloads on change. Mirrors the same
// goroutine pattern as internal/handlers/files.go.
func (r *TemplateRegistry) refresh(ctx context.Context, path string, entry *templateCacheEntry) {
	entry.mu.Lock()
	if entry.checking {
		entry.mu.Unlock()
		return
	}
	entry.checking = true
	entry.mu.Unlock()

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

	latestSHA, err := r.giteaSHA(ctx, path)
	if err != nil {
		log.Printf("submission template: SHA check for %s failed: %v", path, err)
		entry.mu.Lock()
		entry.lastChecked = time.Now()
		entry.mu.Unlock()
		return
	}
	entry.mu.RLock()
	unchanged := latestSHA == entry.sha && entry.sha != ""
	entry.mu.RUnlock()
	if unchanged {
		entry.mu.Lock()
		entry.lastChecked = time.Now()
		entry.mu.Unlock()
		return
	}
	data, err := r.giteaDownload(ctx, path)
	if err != nil {
		log.Printf("submission template: download %s failed: %v", path, err)
		entry.mu.Lock()
		entry.lastChecked = time.Now()
		entry.mu.Unlock()
		return
	}
	entry.mu.Lock()
	entry.data = data
	entry.sha = latestSHA
	entry.lastChecked = time.Now()
	entry.mu.Unlock()
	log.Printf("submission template: updated %s (SHA: %.8s)", path, latestSHA)
}

// probe is the cheap existence-check used by HasTemplate. Reuses the
// cache but only fetches the SHA (not the bytes), so the
// SubmissionsPanel's per-row HasTemplate calls don't pull a megabyte
// of .docx data the user might never download.
func (r *TemplateRegistry) probe(ctx context.Context, path string) bool {
	entry := r.cacheGet(path)
	entry.mu.RLock()
	hasData := !entry.missing && len(entry.data) > 0
	hasSHA := !entry.missing && entry.sha != ""
	isMissing := entry.missing
	needsCheck := time.Since(entry.lastChecked) >= templatesCheckInterval
	entry.mu.RUnlock()
	if isMissing && !needsCheck {
		return false
	}
	if hasData || hasSHA {
		return true
	}
	sha, err := r.giteaSHA(ctx, path)
	if err != nil {
		if errors.Is(err, errTemplate404) {
			entry.mu.Lock()
			entry.missing = true
			entry.lastChecked = time.Now()
			entry.mu.Unlock()
		}
		return false
	}
	entry.mu.Lock()
	entry.sha = sha
	entry.lastChecked = time.Now()
	entry.missing = false
	entry.mu.Unlock()
	return true
}

// giteaSHA returns the SHA of the latest commit that touched the
// template path. Returns errTemplate404 when Gitea responds with 404 —
// the registry distinguishes "no such template" from "Gitea is down".
func (r *TemplateRegistry) giteaSHA(ctx context.Context, path string) (string, error) {
	apiURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/commits?path=%s&limit=1&sha=%s",
		templatesGiteaBaseURL,
		templatesGiteaRepoOwn,
		templatesGiteaRepoName,
		url.QueryEscape(path),
		templatesGiteaBranch,
	)
	req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
	if err != nil {
		return "", err
	}
	if r.giteaToken != "" {
		req.Header.Set("Authorization", "token "+r.giteaToken)
	}
	resp, err := r.httpClient.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()
	if resp.StatusCode == http.StatusNotFound {
		return "", errTemplate404
	}
	if resp.StatusCode != http.StatusOK {
		return "", fmt.Errorf("gitea sha lookup 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 "", errTemplate404
	}
	return commits[0].SHA, nil
}

// giteaDownload fetches the raw template bytes.
func (r *TemplateRegistry) giteaDownload(ctx context.Context, path string) ([]byte, error) {
	rawURL := fmt.Sprintf("%s/%s/%s/raw/branch/%s/%s",
		templatesGiteaBaseURL,
		templatesGiteaRepoOwn,
		templatesGiteaRepoName,
		templatesGiteaBranch,
		path,
	)
	req, err := http.NewRequestWithContext(ctx, "GET", rawURL, nil)
	if err != nil {
		return nil, err
	}
	if r.giteaToken != "" {
		req.Header.Set("Authorization", "token "+r.giteaToken)
	}
	resp, err := r.httpClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode == http.StatusNotFound {
		return nil, errTemplate404
	}
	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("gitea raw returned %d", resp.StatusCode)
	}
	return io.ReadAll(resp.Body)
}

// ClearCache drops every cached entry. Exposed for an admin-side
// "refresh templates" affordance — paliad's existing /api/files/refresh
// has the same shape for the HL Patents Style proxy.
func (r *TemplateRegistry) ClearCache() {
	r.cacheMu.Lock()
	defer r.cacheMu.Unlock()
	for k := range r.cache {
		r.cache[k] = &templateCacheEntry{}
	}
}
