package webhook

import (
	"encoding/json"
	"fmt"
	"net/http"
	"strings"

	"github.com/alexei/tinyforge/internal/workload/plugin"
)

// Vendor headers we recognize for short-circuit dispatch. The header
// presence (not just value) is the trigger — vendors version event names
// over time so matching on value alone is brittle.
const (
	headerGiteaEvent  = "X-Gitea-Event"
	headerGiteaDelivery = "X-Gitea-Delivery"
	headerGitHubEvent = "X-GitHub-Event"
	headerGitLabEvent = "X-Gitlab-Event"
)

// vendorParseResult bundles the parsed event with an "ok" flag distinct
// from the error: ok=false means "this parser doesn't apply to the
// request, try the next one"; ok=true with err!=nil means "this parser
// applied but the payload was malformed."
type vendorParseResult struct {
	event plugin.InboundEvent
	ok    bool
	err   error
}

// tryVendorParsers runs each vendor-specific parser in order and returns
// the first one that recognizes the payload. ok=false means no vendor
// parser claimed the request — caller should fall back to the generic
// simple-body parser.
func tryVendorParsers(body []byte, headers http.Header) vendorParseResult {
	for _, fn := range []func([]byte, http.Header) vendorParseResult{
		parseGiteaPackageEvent,
		parseGitHubPackageEvent,
		parseGitHubPushEvent,
		parseGiteaPushEvent,
		parseGitLabPushEvent,
	} {
		res := fn(body, headers)
		if res.ok {
			return res
		}
	}
	return vendorParseResult{}
}

// parseGiteaPackageEvent recognizes Gitea container-registry package
// pushes. Distinguishing fingerprint: X-Gitea-Event: package and a body
// with package.type == "container".
//
// Reference: https://docs.gitea.com/usage/webhooks
//
// Body excerpt:
//
//	{
//	  "action": "created",
//	  "package": {
//	    "name": "my-app",
//	    "type": "container",
//	    "version": "v1.2.3",
//	    "owner": {"login": "alexei"},
//	    "repository": {"full_name": "alexei/my-app"}
//	  }
//	}
func parseGiteaPackageEvent(body []byte, headers http.Header) vendorParseResult {
	if headers.Get(headerGiteaEvent) != "package" {
		return vendorParseResult{}
	}
	var probe struct {
		Action  string `json:"action"`
		Package struct {
			Name    string `json:"name"`
			Type    string `json:"type"`
			Version string `json:"version"`
			Owner   struct {
				Login    string `json:"login"`
				Username string `json:"username"`
			} `json:"owner"`
			Repository struct {
				FullName string `json:"full_name"`
				HTMLURL  string `json:"html_url"`
			} `json:"repository"`
		} `json:"package"`
		Sender struct {
			Login    string `json:"login"`
			Username string `json:"username"`
		} `json:"sender"`
	}
	if err := json.Unmarshal(body, &probe); err != nil {
		return vendorParseResult{ok: true, err: fmt.Errorf("invalid Gitea package JSON")}
	}
	if !strings.EqualFold(probe.Package.Type, "container") {
		// Non-container package (npm, maven, generic, etc.) — Tinyforge
		// only deploys containers today, so drop these as not-applicable.
		// Return ok=true so we don't fall through to the simple-body
		// parser and accidentally pick up the "version" field as a ref.
		return vendorParseResult{ok: true, err: fmt.Errorf("Gitea package type %q is not a container", probe.Package.Type)}
	}
	if probe.Package.Name == "" || probe.Package.Version == "" {
		return vendorParseResult{ok: true, err: fmt.Errorf("Gitea package event missing name/version")}
	}
	owner := probe.Package.Owner.Login
	if owner == "" {
		owner = probe.Package.Owner.Username
	}
	repo := probe.Package.Name
	if owner != "" {
		repo = owner + "/" + probe.Package.Name
	}
	registry := registryHostFromGiteaRepoURL(probe.Package.Repository.HTMLURL)

	evt := plugin.InboundEvent{
		Kind: "image-push",
		Image: &plugin.ImagePushEvent{
			Registry: registry,
			Repo:     repo,
			Tag:      probe.Package.Version,
		},
	}
	return vendorParseResult{event: evt, ok: true}
}

// registryHostFromGiteaRepoURL extracts the host portion of a Gitea
// repository URL. Gitea container registry lives on the same host, so
// the repo URL host is a reliable proxy for the registry host.
func registryHostFromGiteaRepoURL(repoURL string) string {
	if repoURL == "" {
		return ""
	}
	// Strip scheme.
	if i := strings.Index(repoURL, "://"); i >= 0 {
		repoURL = repoURL[i+3:]
	}
	if i := strings.Index(repoURL, "/"); i >= 0 {
		repoURL = repoURL[:i]
	}
	return repoURL
}

// parseGitHubPackageEvent recognizes GitHub Container Registry (ghcr.io)
// publish events. Two event names exist depending on the year the webhook
// was registered: "package" and "registry_package". Both share the same
// body shape under registry_package.
//
// Reference:
//   - https://docs.github.com/en/webhooks/webhook-events-and-payloads#package
//   - https://docs.github.com/en/webhooks/webhook-events-and-payloads#registry_package
//
// Body excerpt:
//
//	{
//	  "action": "published",
//	  "registry_package": {
//	    "name": "my-app",
//	    "namespace": "owner",
//	    "package_type": "CONTAINER",
//	    "package_version": {
//	      "container_metadata": { "tag": { "name": "v1.2.3" } },
//	      "name": "sha256:..."
//	    },
//	    "registry": { "url": "https://ghcr.io/..." }
//	  }
//	}
func parseGitHubPackageEvent(body []byte, headers http.Header) vendorParseResult {
	ev := headers.Get(headerGitHubEvent)
	if ev != "package" && ev != "registry_package" {
		return vendorParseResult{}
	}
	// GitHub sends the body under either "package" or "registry_package"
	// depending on event name; tolerate both by probing for whichever is
	// populated.
	var probe struct {
		Action          string          `json:"action"`
		Package         json.RawMessage `json:"package"`
		RegistryPackage json.RawMessage `json:"registry_package"`
	}
	if err := json.Unmarshal(body, &probe); err != nil {
		return vendorParseResult{ok: true, err: fmt.Errorf("invalid GitHub package JSON")}
	}
	raw := probe.RegistryPackage
	if len(raw) == 0 {
		raw = probe.Package
	}
	if len(raw) == 0 {
		return vendorParseResult{ok: true, err: fmt.Errorf("GitHub package event missing package body")}
	}
	var pkg struct {
		Name           string `json:"name"`
		Namespace      string `json:"namespace"`
		PackageType    string `json:"package_type"`
		PackageVersion struct {
			Name              string `json:"name"`
			Version           string `json:"version"`
			ContainerMetadata struct {
				Tag struct {
					Name string `json:"name"`
				} `json:"tag"`
			} `json:"container_metadata"`
		} `json:"package_version"`
		Registry struct {
			URL string `json:"url"`
		} `json:"registry"`
	}
	if err := json.Unmarshal(raw, &pkg); err != nil {
		return vendorParseResult{ok: true, err: fmt.Errorf("invalid GitHub package payload")}
	}
	if !strings.EqualFold(pkg.PackageType, "CONTAINER") && !strings.EqualFold(pkg.PackageType, "container") {
		return vendorParseResult{ok: true, err: fmt.Errorf("GitHub package type %q is not a container", pkg.PackageType)}
	}
	tag := pkg.PackageVersion.ContainerMetadata.Tag.Name
	digest := ""
	// PackageVersion.Name is "sha256:..." for container versions.
	if strings.HasPrefix(pkg.PackageVersion.Name, "sha256:") {
		digest = pkg.PackageVersion.Name
	}
	if tag == "" && digest == "" {
		return vendorParseResult{ok: true, err: fmt.Errorf("GitHub package event missing tag and digest")}
	}
	repo := pkg.Name
	if pkg.Namespace != "" {
		repo = pkg.Namespace + "/" + pkg.Name
	}
	evt := plugin.InboundEvent{
		Kind: "image-push",
		Image: &plugin.ImagePushEvent{
			Registry: registryHostFromGitHubRegistry(pkg.Registry.URL),
			Repo:     repo,
			Tag:      tag,
			Digest:   digest,
		},
	}
	return vendorParseResult{event: evt, ok: true}
}

// registryHostFromGitHubRegistry pulls the host from a GitHub registry
// URL ("https://ghcr.io/..." → "ghcr.io"). Defaults to "ghcr.io" since
// that's the only host GitHub uses for container packages today.
func registryHostFromGitHubRegistry(rawURL string) string {
	host := registryHostFromGiteaRepoURL(rawURL) // same scheme/host stripping
	if host == "" {
		return "ghcr.io"
	}
	return host
}

// parseGitHubPushEvent handles the standard GitHub push event with a
// vendor annotation. The generic parser already covers the body shape
// (ref / after / repository.full_name / pusher.name) — this exists so
// the resulting GitEvent.Vendor is populated and trigger plugins can
// distinguish GitHub from Gitea without re-parsing headers.
func parseGitHubPushEvent(body []byte, headers http.Header) vendorParseResult {
	if headers.Get(headerGitHubEvent) != "push" {
		return vendorParseResult{}
	}
	evt, err := parseGenericGitPush(body)
	if err != nil {
		return vendorParseResult{ok: true, err: err}
	}
	if evt.Git != nil {
		evt.Git.Vendor = "github"
	}
	return vendorParseResult{event: evt, ok: true}
}

// parseGiteaPushEvent — same idea, for Gitea. Gitea push events share
// shape with GitHub's (Gitea explicitly modeled them).
func parseGiteaPushEvent(body []byte, headers http.Header) vendorParseResult {
	if headers.Get(headerGiteaEvent) != "push" {
		return vendorParseResult{}
	}
	evt, err := parseGenericGitPush(body)
	if err != nil {
		return vendorParseResult{ok: true, err: err}
	}
	if evt.Git != nil {
		evt.Git.Vendor = "gitea"
	}
	return vendorParseResult{event: evt, ok: true}
}

// parseGitLabPushEvent handles GitLab's push event, which uses a
// different body shape than GitHub/Gitea:
//
//	{
//	  "ref": "refs/heads/main",
//	  "after": "abc123",
//	  "user_username": "alice",
//	  "project": { "path_with_namespace": "owner/repo" }
//	}
//
// Reference: https://docs.gitlab.com/user/project/integrations/webhook_events/
func parseGitLabPushEvent(body []byte, headers http.Header) vendorParseResult {
	ev := headers.Get(headerGitLabEvent)
	if ev != "Push Hook" && ev != "Tag Push Hook" {
		return vendorParseResult{}
	}
	var probe struct {
		Ref          string `json:"ref"`
		After        string `json:"after"`
		UserName     string `json:"user_username"`
		UserNameAlt  string `json:"user_name"`
		Project struct {
			PathWithNamespace string `json:"path_with_namespace"`
			GitHTTPURL        string `json:"git_http_url"`
		} `json:"project"`
	}
	if err := json.Unmarshal(body, &probe); err != nil {
		return vendorParseResult{ok: true, err: fmt.Errorf("invalid GitLab push JSON")}
	}
	if probe.Ref == "" {
		return vendorParseResult{ok: true, err: fmt.Errorf("GitLab push event missing ref")}
	}
	pusher := probe.UserName
	if pusher == "" {
		pusher = probe.UserNameAlt
	}
	evt := plugin.InboundEvent{
		Kind: "git-push",
		Git: &plugin.GitEvent{
			Vendor:    "gitlab",
			Repo:      probe.Project.PathWithNamespace,
			Ref:       probe.Ref,
			CommitSHA: probe.After,
			Pusher:    pusher,
			// GitLab does not emit `deleted: true`; the canonical signal
			// is an all-zero `after` SHA. Same parser helper used for the
			// GitHub / Gitea fallback so the two branches agree.
			Deleted: isZeroSHA(probe.After),
		},
	}
	if strings.HasPrefix(probe.Ref, "refs/heads/") {
		evt.Git.Branch = strings.TrimPrefix(probe.Ref, "refs/heads/")
	}
	if strings.HasPrefix(probe.Ref, "refs/tags/") {
		evt.Git.Tag = strings.TrimPrefix(probe.Ref, "refs/tags/")
		evt.Kind = "git-tag"
	}
	return vendorParseResult{event: evt, ok: true}
}

// parseGenericGitPush extracts a GitEvent from a GitHub/Gitea-style push
// body. Shared by the GitHub and Gitea vendor wrappers and by the
// generic fallback in buildInboundEvent.
func parseGenericGitPush(body []byte) (plugin.InboundEvent, error) {
	var probe struct {
		Ref        string `json:"ref"`
		After      string `json:"after"`
		Deleted    bool   `json:"deleted"`
		Repository struct {
			FullName string `json:"full_name"`
			CloneURL string `json:"clone_url"`
		} `json:"repository"`
		Repo   string `json:"repo"`
		Pusher struct {
			Name     string `json:"name"`
			Username string `json:"username"`
		} `json:"pusher"`
	}
	if err := json.Unmarshal(body, &probe); err != nil {
		return plugin.InboundEvent{}, fmt.Errorf("invalid git push JSON")
	}
	if probe.Ref == "" {
		return plugin.InboundEvent{}, fmt.Errorf("git push event missing ref")
	}
	repo := probe.Repository.FullName
	if repo == "" {
		repo = probe.Repo
	}
	pusher := probe.Pusher.Name
	if pusher == "" {
		pusher = probe.Pusher.Username
	}
	// Branch / tag deletion is signalled either by the explicit
	// `deleted: true` flag (GitHub / Gitea) or by an all-zero `after`
	// SHA (older shapes). Both are honoured so the preview-deploy flow
	// can tear down ephemeral workloads even when a vendor omits the
	// boolean flag.
	deleted := probe.Deleted || isZeroSHA(probe.After)
	evt := plugin.InboundEvent{
		Kind: "git-push",
		Git: &plugin.GitEvent{
			Repo:      repo,
			Ref:       probe.Ref,
			CommitSHA: probe.After,
			Pusher:    pusher,
			Deleted:   deleted,
		},
	}
	if strings.HasPrefix(probe.Ref, "refs/heads/") {
		evt.Git.Branch = strings.TrimPrefix(probe.Ref, "refs/heads/")
	}
	if strings.HasPrefix(probe.Ref, "refs/tags/") {
		evt.Git.Tag = strings.TrimPrefix(probe.Ref, "refs/tags/")
		evt.Kind = "git-tag"
	}
	return evt, nil
}

// isZeroSHA returns true when sha is the canonical "no commit" sentinel
// (40 zeros) that vendors emit on the `after` field of a branch- or
// tag-delete push event. Length-tolerant because some test fixtures
// truncate the SHA.
func isZeroSHA(sha string) bool {
	if sha == "" {
		return false
	}
	for _, r := range sha {
		if r != '0' {
			return false
		}
	}
	return len(sha) >= 7
}
