// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later

package updateserver

import (
	"context"
	"encoding/xml"
	"fmt"
	"io"
	"regexp"
	"strings"
	"time"

	"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
	updateserver_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/updateserver"
	repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
	"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
	"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/storage"
	"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
)

// Joomla-compatible updates.xml structures for XML marshaling.

type xmlUpdates struct {
	XMLName xml.Name    `xml:"updates"`
	Updates []xmlUpdate `xml:"update"`
}

type xmlUpdate struct {
	Name           string          `xml:"name"`
	Element        string          `xml:"element"`
	Type           string          `xml:"type"`
	Version        string          `xml:"version"`
	InfoURL        xmlInfoURL      `xml:"infourl"`
	Downloads      xmlDownloads    `xml:"downloads"`
	Tags           xmlTags         `xml:"tags"`
	TargetPlatform xmlTargetPlat   `xml:"targetplatform"`
	SHA256         string          `xml:"sha256,omitempty"`
	SHA512         string          `xml:"sha512,omitempty"`
	Client         string          `xml:"client,omitempty"`
	PHPMinimum     string          `xml:"php_minimum,omitempty"`
	Description    string          `xml:"description,omitempty"`
	CreationDate   string          `xml:"creationDate,omitempty"`
	ChangelogURL   string          `xml:"changelogurl,omitempty"`
	Maintainer     string          `xml:"maintainer,omitempty"`
	MaintainerURL  string          `xml:"maintainerurl,omitempty"`
	DownloadKey    *xmlDownloadKey `xml:"downloadkey,omitempty"`
}

type xmlDownloadKey struct {
	Prefix string `xml:"prefix,attr"`
	Suffix string `xml:"suffix,attr"`
}

type xmlInfoURL struct {
	Title string `xml:"title,attr"`
	URL   string `xml:",chardata"`
}

type xmlDownloads struct {
	DownloadURL []xmlDownloadURL `xml:"downloadurl"`
}

type xmlDownloadURL struct {
	Type   string `xml:"type,attr"`
	Format string `xml:"format,attr"`
	URL    string `xml:",chardata"`
}

type xmlTags struct {
	Tag string `xml:"tag"`
}

type xmlTargetPlat struct {
	Name    string `xml:"name,attr"`
	Version string `xml:"version,attr"`
}

// channelFromTag maps a release tag name to a Joomla update channel.
// Joomla update stream names (full convention).
const (
	ChannelStable           = "stable"
	ChannelReleaseCandidate = "release-candidate"
	ChannelBeta             = "beta"
	ChannelAlpha            = "alpha"
	ChannelDevelopment      = "development"
)

// AllChannels in display order (most stable first).
var AllChannels = []string{ChannelStable, ChannelReleaseCandidate, ChannelBeta, ChannelAlpha, ChannelDevelopment}

// channelFromTag maps a release tag name to a Joomla update channel.
func channelFromTag(tagName string, isPrerelease bool) string {
	lower := strings.ToLower(tagName)
	switch {
	case strings.Contains(lower, "-dev") || strings.Contains(lower, "development"):
		return ChannelDevelopment
	case strings.Contains(lower, "-alpha"):
		return ChannelAlpha
	case strings.Contains(lower, "-beta"):
		return ChannelBeta
	case strings.Contains(lower, "-rc") || strings.Contains(lower, "release-candidate"):
		return ChannelReleaseCandidate
	case isPrerelease:
		return ChannelReleaseCandidate
	default:
		return ChannelStable
	}
}

// isStreamName checks if a string matches any stream name (indicating the tag
// is a stream name, not a version number).
func isStreamName(s string, streams []updateserver_model.StreamDef) bool {
	for _, st := range streams {
		if strings.EqualFold(st.Name, s) {
			return true
		}
	}
	return false
}

// joomlaTagName maps internal stream names to Joomla-standard tag values.
// Joomla's Update.php maps tags via STABILITY_ + strtoupper(tag) constants.
// Valid values: dev (0), alpha (1), beta (2), rc (3), stable (4).
// Using full names like "development" or "release-candidate" would silently
// fall back to STABILITY_STABLE, breaking pre-release channel filtering.
func joomlaTagName(channel string) string {
	switch channel {
	case ChannelDevelopment:
		return "dev"
	case ChannelAlpha:
		return "alpha"
	case ChannelBeta:
		return "beta"
	case ChannelReleaseCandidate:
		return "rc"
	case ChannelStable:
		return "stable"
	default:
		return channel
	}
}

// NormalizeChannel maps shorthand channel names to the full Joomla convention.
// Accepts both "rc" and "release-candidate", "dev" and "development", etc.
func NormalizeChannel(ch string) string {
	switch strings.ToLower(ch) {
	case "rc", "release-candidate":
		return ChannelReleaseCandidate
	case "dev", "development":
		return ChannelDevelopment
	case "alpha":
		return ChannelAlpha
	case "beta":
		return ChannelBeta
	case "stable":
		return ChannelStable
	default:
		return ch
	}
}

// extensionMetadata holds resolved metadata for feed generation.
// Fields are resolved with priority: manifest → config table (gating only) → default.
type extensionMetadata struct {
	Element        string
	DisplayName    string
	ExtType        string
	TargetVersion  string
	PHPMinimum     string
	Description    string
	SupportURL     string
	DownloadGating string
	KeyPrefix      string
}

// resolveExtensionMetadata loads extension metadata from the repo manifest API.
// The manifest is the single source of truth for extension identity fields.
// The config table is only used for licensing/gating fields not in the manifest.
func resolveExtensionMetadata(ctx context.Context, repo *repo_model.Repository, cfg *updateserver_model.UpdateStreamConfig) extensionMetadata {
	m := extensionMetadata{
		Element:       strings.ToLower(repo.Name),
		DisplayName:   fmt.Sprintf("%s - %s", repo.Owner.Name, repo.Name),
		ExtType:       "component",
		TargetVersion: "(5|6)\\..*",
	}

	// Manifest is the source of truth for extension metadata.
	manifest, err := repo_model.GetRepoManifest(ctx, repo.ID)
	if err != nil {
		log.Error("resolveExtensionMetadata: GetRepoManifest for repo %d: %v", repo.ID, err)
	}
	if manifest != nil {
		if manifest.ElementName != "" {
			m.Element = manifest.ElementName
		}
		if manifest.PackageType != "" {
			m.ExtType = manifest.PackageType
		}
		if manifest.DisplayName != "" {
			m.DisplayName = manifest.DisplayName
		}
		if manifest.TargetVersion != "" {
			m.TargetVersion = manifest.TargetVersion
		}
		if manifest.PHPMinimum != "" {
			m.PHPMinimum = manifest.PHPMinimum
		}
		if manifest.Description != "" {
			m.Description = manifest.Description
		}
		if manifest.InfoURL != "" {
			m.SupportURL = manifest.InfoURL
		}
	}

	// Config table: only licensing/gating fields (not in manifest).
	if cfg != nil {
		if cfg.DownloadGating != "" {
			m.DownloadGating = cfg.DownloadGating
		}
		if cfg.KeyPrefix != "" {
			m.KeyPrefix = cfg.KeyPrefix
		}
		// SupportURL from config as fallback if manifest.InfoURL is empty
		if m.SupportURL == "" && cfg.SupportURL != "" {
			m.SupportURL = cfg.SupportURL
		}
	}

	return m
}

// GenerateJoomlaXML builds a Joomla-compatible updates.xml from repository releases.
// It returns the raw XML bytes. Extension metadata is resolved from custom fields first,
// then the update stream config, then repo-derived defaults.
// allowedChannels optionally restricts output to specific channels (nil = all).
func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, requireKey, stripDownloads bool, allowedChannels ...string) ([]byte, error) {
	releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
		RepoID:        repo.ID,
		ListOptions:   db.ListOptionsAll,
		IncludeDrafts: false,
		IncludeTags:   false,
	})
	if err != nil {
		return nil, fmt.Errorf("GetReleasesByRepoID: %w", err)
	}

	if err := repo.LoadOwner(ctx); err != nil {
		return nil, fmt.Errorf("LoadOwner: %w", err)
	}

	baseURL := setting.AppURL
	if strings.HasSuffix(baseURL, "/") {
		baseURL = baseURL[:len(baseURL)-1]
	}
	repoLink := fmt.Sprintf("%s/%s/%s", baseURL, repo.Owner.Name, repo.Name)

	// Load extension metadata with cascading fallback:
	// custom fields → config table → repo-derived defaults.
	cfg := updateserver_model.GetEffectiveConfig(ctx, repo.OwnerID, repo.ID)
	meta := resolveExtensionMetadata(ctx, repo, cfg)

	element := meta.Element
	displayName := meta.DisplayName
	extType := meta.ExtType
	targetVersion := meta.TargetVersion
	phpMinimum := meta.PHPMinimum
	feedDescription := meta.Description

	// Maintainer and URL always come from the org profile.
	maintainer := repo.Owner.FullName
	if maintainer == "" {
		maintainer = repo.Owner.Name
	}
	maintainerURL := repo.Owner.Website
	if maintainerURL == "" {
		maintainerURL = fmt.Sprintf("%s/%s", baseURL, repo.Owner.Name)
	}

	// Resolve effective streams (repo override → org default → Joomla default).
	streams := updateserver_model.GetEffectiveStreams(ctx, repo.OwnerID, repo.ID)

	// Track best (latest) release per channel to emit one entry per channel.
	bestByChannel := make(map[string]*repo_model.Release)
	for _, rel := range releases {
		if rel.IsDraft || rel.IsTag {
			continue
		}
		ch := updateserver_model.ResolveReleaseStream(ctx, rel.ID, rel.TagName, rel.IsPrerelease, streams)
		existing, ok := bestByChannel[ch]
		if !ok || rel.CreatedUnix > existing.CreatedUnix {
			bestByChannel[ch] = rel
		}
	}

	// Build allowed channel set for filtering.
	// Normalize shorthand names so both "rc" and "release-candidate" work.
	channelAllowed := make(map[string]bool)
	if len(allowedChannels) > 0 {
		for _, c := range allowedChannels {
			channelAllowed[NormalizeChannel(c)] = true
		}
	}

	var updates xmlUpdates
	for _, stream := range streams {
		ch := stream.Name
		// Skip channels not in the allowed set (when filtering is active).
		if len(channelAllowed) > 0 && !channelAllowed[ch] {
			continue
		}
		rel, ok := bestByChannel[ch]
		if !ok {
			continue
		}

		// Load attachments for download URLs.
		if err := rel.LoadAttributes(ctx); err != nil {
			continue
		}

		// Find the first .zip attachment as the download URL, and its SHA256 sidecar.
		var downloadURL, sha256Hash string
		var zipName string
		for _, att := range rel.Attachments {
			if strings.HasSuffix(strings.ToLower(att.Name), ".zip") && !strings.HasSuffix(att.Name, ".sha256") {
				downloadURL = fmt.Sprintf("%s/releases/download/%s/%s", repoLink, rel.TagName, att.Name)
				zipName = att.Name
				break
			}
		}
		// Look for the SHA256 sidecar file.
		if zipName != "" {
			for _, att := range rel.Attachments {
				if att.Name == zipName+".sha256" {
					sha256Hash = readSHA256FromSidecar(ctx, att)
					break
				}
			}
		}
		// Fall back to the release tag archive if no zip attachment.
		if downloadURL == "" {
			downloadURL = fmt.Sprintf("%s/archive/%s.zip", repoLink, rel.TagName)
		}

		// Extract version: prefer asset filename (matches actual download),
		// then tag name, then release title. Only fall through when empty.
		version := ""
		if zipName != "" {
			version = extractVersion(zipName)
		}
		if version == "" {
			version = extractVersion(rel.TagName)
		}
		if version == "" {
			version = extractVersion(rel.Title)
		}
		// Last resort: use the tag name as-is.
		if version == "" {
			version = rel.TagName
		}
		suffix := stream.Suffix
		if suffix == "" {
			suffix = channelSuffix(ch) // fallback for Joomla defaults
		}
		if suffix != "" {
			version = version + suffix
		}

		desc := feedDescription
		if desc == "" {
			desc = fmt.Sprintf("%s %s build.", displayName, ch)
		}

		// Info URL: use support_url (product page), fall back to releases page.
		infoURL := fmt.Sprintf("%s/releases", repoLink)
		if meta.SupportURL != "" {
			infoURL = meta.SupportURL
		}

		// Joomla <client> element: admin-side extensions use "administrator",
		// site-side extensions use "site". Packages, components, libraries,
		// and files are admin-side by default.
		client := "site"
		switch extType {
		case "package", "component", "library", "file":
			client = "administrator"
		}

		u := xmlUpdate{
			Name:        displayName,
			Description: desc,
			Element:     element,
			Type:        extType,
			Client:      client,
			Version:     version,
			CreationDate: time.Unix(int64(rel.CreatedUnix), 0).Format("2006-01-02"),
			InfoURL: xmlInfoURL{
				Title: displayName,
				URL:   infoURL,
			},
			Downloads: xmlDownloads{
				DownloadURL: func() []xmlDownloadURL {
					if stripDownloads {
						return nil
					}
					return []xmlDownloadURL{{Type: "full", Format: "zip", URL: downloadURL}}
				}(),
			},
			Tags:          xmlTags{Tag: joomlaTagName(ch)},
			ChangelogURL:  fmt.Sprintf("%s/changelog.xml", repoLink),
			Maintainer:    maintainer,
			MaintainerURL: maintainerURL,
			PHPMinimum:    phpMinimum,
			SHA256: sha256Hash,
			TargetPlatform: xmlTargetPlat{
				Name:    "joomla",
				Version: targetVersion,
			},
		}

		if requireKey {
			u.DownloadKey = &xmlDownloadKey{Prefix: "dlid=", Suffix: ""}
		}

		updates.Updates = append(updates.Updates, u)
	}

	output, err := xml.MarshalIndent(updates, "", "  ")
	if err != nil {
		return nil, fmt.Errorf("xml.MarshalIndent: %w", err)
	}

	return append([]byte(xml.Header), output...), nil
}

// versionRegex matches semantic version patterns like 1.0.0, 02.29.04, etc.
var versionRegex = regexp.MustCompile(`(\d+\.\d+(?:\.\d+)?)`)

// extractVersion finds a version number from a tag name or release title.
// Tries: (1) strip common prefixes for version-style tags, (2) regex match for embedded versions.
func extractVersion(s string) string {
	// Try prefix stripping first (works for "v1.0.0", "release-1.0.0").
	v := s
	v = strings.TrimPrefix(v, "v")
	v = strings.TrimPrefix(v, "release-")
	v = strings.TrimPrefix(v, "release/")
	// Strip channel suffixes.
	for _, suffix := range []string{"-dev", "-alpha", "-beta", "-rc", "-development", "-release-candidate"} {
		if idx := strings.Index(strings.ToLower(v), suffix); idx > 0 {
			v = v[:idx]
			break
		}
	}
	// If result looks like a version (starts with digit), use it.
	if len(v) > 0 && v[0] >= '0' && v[0] <= '9' {
		return strings.TrimSpace(v)
	}

	// Fallback: extract version pattern from anywhere in the string.
	if m := versionRegex.FindString(s); m != "" {
		return m
	}

	return ""
}

// channelSuffix returns the version suffix for a channel.
func channelSuffix(channel string) string {
	switch channel {
	case ChannelDevelopment:
		return "-dev"
	case ChannelAlpha:
		return "-alpha"
	case ChannelBeta:
		return "-beta"
	case ChannelReleaseCandidate:
		return "-rc"
	default:
		return ""
	}
}

// readSHA256FromSidecar reads the SHA256 hash from a .sha256 sidecar attachment.
// The sidecar format is: "<hex>  <filename>\n"
func readSHA256FromSidecar(_ context.Context, att *repo_model.Attachment) string {
	fr, err := storage.Attachments.Open(att.RelativePath())
	if err != nil {
		return ""
	}
	defer fr.Close()

	data, err := io.ReadAll(io.LimitReader(fr, 256))
	if err != nil {
		return ""
	}

	content := strings.TrimSpace(string(data))
	// Format: "hexhash  filename"
	if idx := strings.Index(content, " "); idx > 0 {
		return content[:idx]
	}
	return content
}
