package token

import (
	"bytes"
	"encoding/base64"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"time"

	"github.com/pkg/errors"
	"github.com/rs/zerolog"
)

const (
	baseStoreURL  = "https://login.cloudflareaccess.org/"
	fedStoreURL   = "https://login.fed.cloudflareaccess.org/"
	clientTimeout = time.Second * 60
)

// RunTransfer does the transfer "dance" with the end result downloading the supported resource.
// The expanded description is run is encapsulation of shared business logic needed
// to request a resource (token/cert/etc) from the transfer service (loginhelper).
// The "dance" we refer to is building a HTTP request, opening that in a browser waiting for
// the user to complete an action, while it long polls in the background waiting for an
// action to be completed to download the resource.
//
// If urlFilePath is non-empty, the generated auth URL is written to that path so
// other waiting processes can display it to the user. Pass "" to skip.
func RunTransfer(transferURL *url.URL, appAUD, resourceName, key, value string, shouldEncrypt bool, useHostOnly bool, autoClose bool, fedramp bool, log *zerolog.Logger, urlFilePath string) ([]byte, error) {
	encrypterClient, err := NewEncrypter("cloudflared_priv.pem", "cloudflared_pub.pem")
	if err != nil {
		return nil, err
	}
	requestURL, err := buildRequestURL(transferURL, appAUD, key, value+encrypterClient.PublicKey(), shouldEncrypt, useHostOnly, autoClose)
	if err != nil {
		return nil, err
	}

	// write auth URL to companion file so other waiting processes can display it
	if urlFilePath != "" {
		_ = os.WriteFile(urlFilePath, []byte(requestURL), 0600) // nolint: gosec
	}

	// See AUTH-1423 for why we use stderr (the way git wraps ssh)
	err = OpenBrowser(requestURL)
	if err != nil {
		fmt.Fprintf(os.Stderr, "Please open the following URL and log in with your Cloudflare account:\n\n%s\n\nLeave cloudflared running to download the %s automatically.\n", requestURL, resourceName)
	} else {
		fmt.Fprintf(os.Stderr, "A browser window should have opened at the following URL:\n\n%s\n\nIf the browser failed to open, please visit the URL above directly in your browser.\n", requestURL)
	}

	var resourceData []byte

	storeURL := baseStoreURL

	if fedramp {
		storeURL = fedStoreURL
	}

	if shouldEncrypt {
		buf, key, err := transferRequest(storeURL+"transfer/"+encrypterClient.PublicKey(), log)
		if err != nil {
			return nil, err
		}

		decodedBuf, err := base64.StdEncoding.DecodeString(string(buf))
		if err != nil {
			return nil, err
		}
		decrypted, err := encrypterClient.Decrypt(decodedBuf, key)
		if err != nil {
			return nil, err
		}

		resourceData = decrypted
	} else {
		buf, _, err := transferRequest(storeURL+encrypterClient.PublicKey(), log)
		if err != nil {
			return nil, err
		}
		resourceData = buf
	}

	return resourceData, nil
}

// BuildRequestURL creates a request suitable for a resource transfer.
// it will return a constructed url based off the base url and query key/value provided.
// cli will build a url for cli transfer request.
func buildRequestURL(baseURL *url.URL, appAUD string, key, value string, cli, useHostOnly bool, autoClose bool) (string, error) {
	q := baseURL.Query()
	q.Set(key, value)
	q.Set("aud", appAUD)
	baseURL.RawQuery = q.Encode()
	if useHostOnly {
		baseURL.Path = ""
	}
	// TODO: pass arg for tunnel login
	if !cli {
		return baseURL.String(), nil
	}
	q.Set("redirect_url", baseURL.String()) // we add the token as a query param on both the redirect_url and the main url
	q.Set("send_org_token", "true")         // indicates that the cli endpoint should return both the org and app token
	q.Set("edge_token_transfer", "true")    // use new LoginHelper service built on workers
	if autoClose {
		q.Set("close_interstitial", "true") // Automatically close the success window.
	}

	baseURL.RawQuery = q.Encode() // and this actual baseURL.
	baseURL.Path = "cdn-cgi/access/cli"
	return baseURL.String(), nil
}

// transferRequest downloads the requested resource from the request URL
func transferRequest(requestURL string, log *zerolog.Logger) ([]byte, string, error) {
	client := &http.Client{Timeout: clientTimeout}
	const pollAttempts = 10
	// we do "long polling" on the endpoint to get the resource.
	for i := 0; i < pollAttempts; i++ {
		buf, key, err := poll(client, requestURL, log)
		if err != nil {
			return nil, "", err
		} else if len(buf) > 0 {
			return buf, key, nil
		}
	}
	return nil, "", errors.New("Failed to fetch resource")
}

// poll the endpoint for the request resource, waiting for the user interaction
func poll(client *http.Client, requestURL string, log *zerolog.Logger) ([]byte, string, error) {
	req, err := http.NewRequest(http.MethodGet, requestURL, nil)
	if err != nil {
		return nil, "", err
	}
	req.Header.Set("User-Agent", userAgent)
	resp, err := client.Do(req) // nolint: gosec
	if err != nil {
		return nil, "", err
	}
	defer func() { _ = resp.Body.Close() }()

	// ignore everything other than server errors as the resource
	// may not exist until the user does the interaction
	if resp.StatusCode >= 500 {
		buf := new(bytes.Buffer)
		if _, err := io.Copy(buf, resp.Body); err != nil {
			return nil, "", err
		}

		return nil, "", fmt.Errorf("error on request %d: %s", resp.StatusCode, buf.String())
	}
	if resp.StatusCode != 200 {
		log.Info().Msg("Waiting for login...")
		return nil, "", nil
	}

	buf := new(bytes.Buffer)
	if _, err := io.Copy(buf, resp.Body); err != nil {
		return nil, "", err
	}
	return buf.Bytes(), resp.Header.Get("service-public-key"), nil
}
