// Copyright IBM Corp. 2013, 2025
// SPDX-License-Identifier: BUSL-1.1

package github

import (
	"bufio"
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"path"
	"path/filepath"
	"strings"

	plugingetter "github.com/hashicorp/packer/packer/plugin-getter"

	"github.com/google/go-github/v33/github"
	"github.com/hashicorp/packer/hcl2template/addrs"
	"golang.org/x/oauth2"
)

const (
	ghTokenAccessor  = "PACKER_GITHUB_API_TOKEN"
	defaultUserAgent = "packer-github-plugin-getter"
	defaultHostname  = "github.com"
)

type Getter struct {
	Client    *github.Client
	UserAgent string
	Name      string
}

var _ plugingetter.Getter = &Getter{}

type PluginMetadata struct {
	Versions map[string]PluginVersion `json:"versions"`
}

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

func TransformChecksumStream() func(in io.ReadCloser) (io.ReadCloser, error) {
	return func(in io.ReadCloser) (io.ReadCloser, error) {
		defer in.Close()
		rd := bufio.NewReader(in)
		buffer := bytes.NewBufferString("[")
		json := json.NewEncoder(buffer)
		for i := 0; ; i++ {
			line, err := rd.ReadString('\n')
			if err != nil {
				if err != io.EOF {
					return nil, fmt.Errorf(
						"Error reading checksum file: %s", err)
				}
				break
			}
			parts := strings.Fields(line)
			switch len(parts) {
			case 2: // nominal case
				checksumString, checksumFilename := parts[0], parts[1]

				if i > 0 {
					_, _ = buffer.WriteString(",")
				}
				if err := json.Encode(struct {
					Checksum string `json:"checksum"`
					Filename string `json:"filename"`
				}{
					Checksum: checksumString,
					Filename: checksumFilename,
				}); err != nil {
					return nil, err
				}
			}
		}
		_, _ = buffer.WriteString("]")
		return io.NopCloser(buffer), nil
	}
}

// transformVersionStream get a stream from github tags and transforms it into
// something Packer wants, namely a json list of Release.
func transformVersionStream(in io.ReadCloser) (io.ReadCloser, error) {
	if in == nil {
		return nil, fmt.Errorf("transformVersionStream got nil body")
	}
	defer in.Close()
	dec := json.NewDecoder(in)

	m := []struct {
		Ref string `json:"ref"`
	}{}
	if err := dec.Decode(&m); err != nil {
		return nil, err
	}

	out := []plugingetter.Release{}
	for _, m := range m {
		out = append(out, plugingetter.Release{
			Version: strings.TrimPrefix(m.Ref, "refs/tags/"),
		})
	}

	buf := &bytes.Buffer{}
	if err := json.NewEncoder(buf).Encode(out); err != nil {
		return nil, err
	}

	return io.NopCloser(buf), nil
}

// HostSpecificTokenAuthTransport makes sure the http roundtripper only sets an
// auth token for requests aimed at a specific host.
//
// This helps for example to get release files from Github as Github will
// redirect to s3 which will error if we give it a Github auth token.
type HostSpecificTokenAuthTransport struct {
	// Host to TokenSource map
	TokenSources map[string]oauth2.TokenSource

	// actual RoundTripper, nil means we use the default one from http.
	Base http.RoundTripper
}

// RoundTrip authorizes and authenticates the request with an
// access token from Transport's Source.
func (t *HostSpecificTokenAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
	source, found := t.TokenSources[req.Host]
	if found {
		reqBodyClosed := false
		if req.Body != nil {
			defer func() {
				if !reqBodyClosed {
					req.Body.Close()
				}
			}()
		}

		if source == nil {
			return nil, errors.New("transport's Source is nil")
		}
		token, err := source.Token()
		if err != nil {
			return nil, err
		}

		token.SetAuthHeader(req)

		// req.Body is assumed to be closed by the base RoundTripper.
		reqBodyClosed = true
	}

	return t.base().RoundTrip(req)
}

func (t *HostSpecificTokenAuthTransport) base() http.RoundTripper {
	if t.Base != nil {
		return t.Base
	}
	return http.DefaultTransport
}

type GithubPlugin struct {
	Hostname  string
	Namespace string
	Type      string
}

func NewGithubPlugin(source *addrs.Plugin) (*GithubPlugin, error) {
	parts := source.Parts()
	if len(parts) != 3 {
		return nil, fmt.Errorf("Invalid github.com URI %q: a Github-compatible source must be in the github.com/<namespace>/<name> format.", source.String())
	}

	if parts[0] != defaultHostname {
		return nil, fmt.Errorf("%q doesn't appear to be a valid %q source address; check source and try again.", source.String(), defaultHostname)
	}

	return &GithubPlugin{
		Hostname:  parts[0],
		Namespace: parts[1],
		Type:      strings.Replace(parts[2], "packer-plugin-", "", 1),
	}, nil
}

func (gp GithubPlugin) RealRelativePath() string {
	return path.Join(
		gp.Namespace,
		fmt.Sprintf("packer-plugin-%s", gp.Type),
	)
}

func (gp GithubPlugin) PluginType() string {
	return fmt.Sprintf("packer-plugin-%s", gp.Type)
}

func (g *Getter) Get(what string, opts plugingetter.GetOptions) (io.ReadCloser, error) {
	log.Printf("[TRACE] Getting %s of %s plugin from %s", what, opts.PluginRequirement.Identifier, g.Name)
	ghURI, err := NewGithubPlugin(opts.PluginRequirement.Identifier)
	if err != nil {
		return nil, err
	}

	ctx := context.TODO()
	if g.Client == nil {
		var tc *http.Client
		if tk := os.Getenv(ghTokenAccessor); tk != "" {
			log.Printf("[DEBUG] github-getter: using %s", ghTokenAccessor)
			ts := oauth2.StaticTokenSource(
				&oauth2.Token{AccessToken: tk},
			)
			tc = &http.Client{
				Transport: &HostSpecificTokenAuthTransport{
					TokenSources: map[string]oauth2.TokenSource{
						"api.github.com": ts,
					},
				},
			}
		} else {
			log.Printf("[WARNING] github-getter: no GitHub token set, if you intend to install plugins often, please set the %s env var", ghTokenAccessor)
		}
		g.Client = github.NewClient(tc)
		g.Client.UserAgent = defaultUserAgent
		if g.UserAgent != "" {
			g.Client.UserAgent = g.UserAgent
		}
	}

	var req *http.Request
	transform := func(in io.ReadCloser) (io.ReadCloser, error) {
		return in, nil
	}

	switch what {
	case "releases":
		u := filepath.ToSlash("/repos/" + ghURI.RealRelativePath() + "/git/matching-refs/tags")
		req, err = g.Client.NewRequest("GET", u, nil)
		transform = transformVersionStream
	case "sha256":
		// something like https://github.com/sylviamoss/packer-plugin-comment/releases/download/v0.2.11/packer-plugin-comment_v0.2.11_x5_SHA256SUMS
		u := filepath.ToSlash("https://github.com/" + ghURI.RealRelativePath() + "/releases/download/" + opts.Version() + "/" + opts.PluginRequirement.FilenamePrefix() + opts.Version() + "_SHA256SUMS")
		req, err = g.Client.NewRequest(
			"GET",
			u,
			nil,
		)
		transform = TransformChecksumStream()
	case "zip":
		u := filepath.ToSlash("https://github.com/" + ghURI.RealRelativePath() + "/releases/download/" + opts.Version() + "/" + opts.ExpectedZipFilename())
		req, err = g.Client.NewRequest(
			"GET",
			u,
			nil,
		)

	default:
		return nil, fmt.Errorf("%q not implemented", what)
	}
	if err != nil {
		return nil, err
	}
	log.Printf("[DEBUG] github-getter: getting %q", req.URL)
	resp, err := g.Client.BareDo(ctx, req)
	if err != nil {
		// here BareDo will return an err if the request failed or if the status
		// is not considered a valid http status. So we have to close the body
		// if it's not nil.
		if resp != nil {
			resp.Body.Close()
		}
		switch err := err.(type) {
		case *github.RateLimitError:
			return nil, &plugingetter.RateLimitError{
				SetableEnvVar: ghTokenAccessor,
				Err:           err,
				ResetTime:     err.Rate.Reset.Time,
			}
		default:
			log.Printf("[TRACE] failed requesting: %T. %v", err, err)
			return nil, err
		}

	}

	return transform(resp.Body)
}

// Init method: a file inside will look like so:
//
//	packer-plugin-comment_v0.2.12_x5.0_freebsd_amd64.zip
func (g *Getter) Init(req *plugingetter.Requirement, entry *plugingetter.ChecksumFileEntry) error {
	filename := entry.Filename
	res := strings.TrimPrefix(filename, req.FilenamePrefix())
	// res now looks like v0.2.12_x5.0_freebsd_amd64.zip

	entry.Ext = filepath.Ext(res)

	res = strings.TrimSuffix(res, entry.Ext)
	// res now looks like v0.2.12_x5.0_freebsd_amd64

	parts := strings.Split(res, "_")
	// ["v0.2.12", "x5.0", "freebsd", "amd64"]
	if len(parts) < 4 {
		return fmt.Errorf("malformed filename expected %s{version}_x{protocol-version}_{os}_{arch}", req.FilenamePrefix())
	}

	entry.BinVersion, entry.ProtVersion, entry.Os, entry.Arch = parts[0], parts[1], parts[2], parts[3]

	return nil
}

func (g *Getter) Validate(opt plugingetter.GetOptions, expectedVersion string, installOpts plugingetter.BinaryInstallationOptions, entry *plugingetter.ChecksumFileEntry) error {
	expectedBinVersion := "v" + expectedVersion
	if entry.BinVersion != expectedBinVersion {
		return fmt.Errorf("wrong version: %s does not match expected %s", entry.BinVersion, expectedBinVersion)
	}
	if entry.Os != installOpts.OS || entry.Arch != installOpts.ARCH {
		return fmt.Errorf("wrong system, expected %s_%s", installOpts.OS, installOpts.ARCH)
	}

	return installOpts.CheckProtocolVersion(entry.ProtVersion)
}

func (g *Getter) ExpectedFileName(pr *plugingetter.Requirement, version string, entry *plugingetter.ChecksumFileEntry, zipFileName string) string {
	return zipFileName
}
