// Package jira implements OAuth1.0a support for arbitrary JIRA installations.
package jira

import (
	"crypto/rsa"
	"crypto/x509"
	"database/sql"
	"encoding/json"
	"encoding/pem"
	"errors"
	"fmt"
	"net/http"
	"strings"

	jira "github.com/andygrunwald/go-jira"
	"github.com/dghubble/oauth1"
	"github.com/matrix-org/go-neb/database"
	"github.com/matrix-org/go-neb/realms/jira/urls"
	"github.com/matrix-org/go-neb/types"
	log "github.com/sirupsen/logrus"
	"golang.org/x/net/context"
	"maunium.net/go/mautrix/id"
)

// RealmType of the JIRA realm
const RealmType = "jira"

// Realm is an AuthRealm which can process JIRA installations.
//
// Example request:
//   {
//        "JIRAEndpoint": "matrix.org/jira/",
//        "ConsumerName": "goneb",
//        "ConsumerKey": "goneb",
//        "ConsumerSecret": "random_long_string",
//        "PrivateKeyPEM": "-----BEGIN RSA PRIVATE KEY-----\r\nMIIEowIBAAKCAQEA39UhbOvQHEkBP9fGnhU+eSObTAwX9req2l1NiuNaPU9rE7tf6Bk\r\n-----END RSA PRIVATE KEY-----"
//   }
type Realm struct {
	id          string
	redirectURL string
	privateKey  *rsa.PrivateKey

	// The HTTPS URL of the JIRA installation to authenticate with.
	JIRAEndpoint string
	// The desired "Consumer Name" field of the "Application Links" admin page on JIRA.
	// Generally this is the name of the service. Users will need to enter this string
	// into their JIRA admin web form.
	ConsumerName string
	// The desired "Consumer Key" field of the "Application Links" admin page on JIRA.
	// Generally this is the name of the service. Users will need to enter this string
	// into their JIRA admin web form.
	ConsumerKey string
	// The desired "Consumer Secret" field of the "Application Links" admin page on JIRA.
	// This should be a random long string. Users will need to enter this string into
	// their JIRA admin web form.
	ConsumerSecret string
	// A string which contains the private key for performing OAuth 1.0 requests.
	// This MUST be in PEM format. It must NOT have a password. Go-NEB will convert this
	// into a public key in PEM format and return this to users. Users will need to enter
	// the *public* key into their JIRA admin web form.
	//
	// To generate a private key PEM: (JIRA does not support bit lengths >2048):
	//    $ openssl genrsa -out privkey.pem 2048
	//    $ cat privkey.pem
	PrivateKeyPEM string
	// Optional. If supplied, !jira commands will return this link whenever someone is
	// prompted to login to JIRA.
	StarterLink string

	// The server name of the JIRA installation from /serverInfo.
	// This is an informational field populated by Go-NEB post-creation.
	Server string
	// The JIRA version string from /serverInfo.
	// This is an informational field populated by Go-NEB post-creation.
	Version string
	// The public key for the given private key. This is populated by Go-NEB.
	PublicKeyPEM string

	// Internal field. True if this realm has already registered a webhook with the JIRA installation.
	HasWebhook bool
}

// Session represents a single authentication session between a user and a JIRA endpoint.
// The endpoint is dictated by the realm ID.
type Session struct {
	id      string // request token
	userID  id.UserID
	realmID string

	// Configuration fields

	// The secret obtained when requesting an authentication session with JIRA.
	RequestSecret string
	// A JIRA access token for a Matrix user ID.
	AccessToken string
	// A JIRA access secret for a Matrix user ID.
	AccessSecret string
	// Optional. The URL to redirect the client to after authentication.
	ClientsRedirectURL string
}

// AuthRequest is a request for authenticating with JIRA
type AuthRequest struct {
	// Optional. The URL to redirect to after authentication.
	RedirectURL string
}

// AuthResponse is a response to an AuthRequest.
type AuthResponse struct {
	// The URL to visit to perform OAuth on this JIRA installation.
	URL string
}

// Authenticated returns true if the user has completed the auth process
func (s *Session) Authenticated() bool {
	return s.AccessToken != "" && s.AccessSecret != ""
}

// Info returns nothing
func (s *Session) Info() interface{} {
	return nil
}

// UserID returns the ID of the user performing the authentication.
func (s *Session) UserID() id.UserID {
	return s.userID
}

// RealmID returns the JIRA realm ID which created this session.
func (s *Session) RealmID() string {
	return s.realmID
}

// ID returns the OAuth1 request_token which is used when looking up sessions in the redirect
// handler.
func (s *Session) ID() string {
	return s.id
}

// ID returns the ID of this JIRA realm.
func (r *Realm) ID() string {
	return r.id
}

// Type returns the type of realm this is.
func (r *Realm) Type() string {
	return RealmType
}

// Init initialises the private key for this JIRA realm.
func (r *Realm) Init() error {
	if err := r.parsePrivateKey(); err != nil {
		log.WithError(err).Print("Failed to parse private key")
		return err
	}
	// Parse the messy input URL into a canonicalised form.
	ju, err := urls.ParseJIRAURL(r.JIRAEndpoint)
	if err != nil {
		log.WithError(err).Print("Failed to parse JIRA endpoint")
		return err
	}
	r.JIRAEndpoint = ju.Base
	return nil
}

// Register is called when this realm is being created from an external entity
func (r *Realm) Register() error {
	if r.ConsumerName == "" || r.ConsumerKey == "" || r.ConsumerSecret == "" || r.PrivateKeyPEM == "" {
		return errors.New("ConsumerName, ConsumerKey, ConsumerSecret, PrivateKeyPEM must be specified")
	}
	if r.JIRAEndpoint == "" {
		return errors.New("JIRAEndpoint must be specified")
	}
	r.HasWebhook = false // never let the user set this; only NEB can.

	// Check to see if JIRA endpoint is valid by pinging an endpoint
	cli, err := r.JIRAClient("", true)
	if err != nil {
		return err
	}
	info, err := jiraServerInfo(cli)
	if err != nil {
		return err
	}
	log.WithFields(log.Fields{
		"jira_url": r.JIRAEndpoint,
		"title":    info.ServerTitle,
		"version":  info.Version,
	}).Print("Found JIRA endpoint")
	r.Server = info.ServerTitle
	r.Version = info.Version

	return nil
}

// RequestAuthSession is called by a user wishing to auth with this JIRA realm.
// The request body is of type "jira.AuthRequest". Returns a "jira.AuthResponse".
//
// Request example:
//   {
//       "RedirectURL": "https://somewhere.somehow"
//   }
// Response example:
//   {
//       "URL": "https://jira.somewhere.com/plugins/servlet/oauth/authorize?oauth_token=7yeuierbgweguiegrTbOT"
//   }
func (r *Realm) RequestAuthSession(userID id.UserID, req json.RawMessage) interface{} {
	logger := log.WithField("jira_url", r.JIRAEndpoint)

	// check if they supplied a redirect URL
	var reqBody AuthRequest
	if err := json.Unmarshal(req, &reqBody); err != nil {
		log.WithError(err).Print("Failed to decode request body")
		return nil
	}

	authConfig := r.oauth1Config(r.JIRAEndpoint)
	reqToken, reqSec, err := authConfig.RequestToken()
	if err != nil {
		logger.WithError(err).Print("Failed to request auth token")
		return nil
	}
	logger.WithField("req_token", reqToken).Print("Received request token")
	authURL, err := authConfig.AuthorizationURL(reqToken)
	if err != nil {
		logger.WithError(err).Print("Failed to create authorization URL")
		return nil
	}

	_, err = database.GetServiceDB().StoreAuthSession(&Session{
		id:                 reqToken,
		userID:             userID,
		realmID:            r.id,
		RequestSecret:      reqSec,
		ClientsRedirectURL: reqBody.RedirectURL,
	})
	if err != nil {
		log.WithError(err).Print("Failed to store new auth session")
		return nil
	}

	return &AuthResponse{authURL.String()}
}

// OnReceiveRedirect is called when JIRA installations redirect back to NEB
func (r *Realm) OnReceiveRedirect(w http.ResponseWriter, req *http.Request) {
	logger := log.WithField("jira_url", r.JIRAEndpoint)

	requestToken, verifier, err := oauth1.ParseAuthorizationCallback(req)
	if err != nil {
		failWith(logger, w, 400, "Failed to parse authorization callback", err)
		return
	}
	logger = logger.WithField("req_token", requestToken)
	logger.Print("Received authorization callback")

	session, err := database.GetServiceDB().LoadAuthSessionByID(r.id, requestToken)
	if err != nil {
		failWith(logger, w, 400, "Unrecognised request token", err)
		return
	}
	jiraSession, ok := session.(*Session)
	if !ok {
		failWith(logger, w, 500, "Unexpected session type found.", nil)
		return
	}
	logger = logger.WithField("user_id", jiraSession.UserID())
	logger.Print("Retrieved auth session for user")

	oauthConfig := r.oauth1Config(r.JIRAEndpoint)
	accessToken, accessSecret, err := oauthConfig.AccessToken(requestToken, jiraSession.RequestSecret, verifier)
	if err != nil {
		failWith(logger, w, 502, "Failed exchange for access token.", err)
		return
	}
	logger.Print("Exchanged for access token")

	jiraSession.AccessToken = accessToken
	jiraSession.AccessSecret = accessSecret

	_, err = database.GetServiceDB().StoreAuthSession(jiraSession)
	if err != nil {
		failWith(logger, w, 500, "Failed to persist JIRA session", err)
		return
	}
	if jiraSession.ClientsRedirectURL != "" {
		w.WriteHeader(302)
		w.Header().Set("Location", jiraSession.ClientsRedirectURL)
		// technically don't need a body but *shrug*
		w.Write([]byte(jiraSession.ClientsRedirectURL))
	} else {
		w.WriteHeader(200)
		w.Write([]byte(
			fmt.Sprintf("You have successfully linked your JIRA account on %s to %s",
				r.JIRAEndpoint, jiraSession.UserID(),
			),
		))
	}
}

// AuthSession returns a JIRASession with the given parameters
func (r *Realm) AuthSession(id string, userID id.UserID, realmID string) types.AuthSession {
	return &Session{
		id:      id,
		userID:  userID,
		realmID: realmID,
	}
}

// ProjectKeyExists returns true if the given project key exists on this JIRA realm.
// An authenticated client for userID will be used if one exists, else an
// unauthenticated client will be used, which may not be able to see the complete list
// of projects.
func (r *Realm) ProjectKeyExists(userID id.UserID, projectKey string) (bool, error) {
	cli, err := r.JIRAClient(userID, true)
	if err != nil {
		return false, err
	}
	var projects []jira.Project
	req, err := cli.NewRequest("GET", "rest/api/2/project", nil)
	if err != nil {
		return false, err
	}
	res, err := cli.Do(req, &projects)
	if err != nil {
		return false, err
	}
	if res == nil {
		return false, errors.New("No response returned")
	}
	if res.StatusCode < 200 || res.StatusCode >= 300 {
		return false, fmt.Errorf(
			"%srest/api/2/project returned code %d",
			r.JIRAEndpoint, res.StatusCode,
		)
	}

	for _, p := range projects {
		if strings.EqualFold(p.Key, projectKey) {
			return true, nil
		}
	}
	return false, nil
}

// JIRAClient returns an authenticated jira.Client for the given userID. Returns an unauthenticated
// client if allowUnauth is true and no authenticated session is found, else returns an error.
func (r *Realm) JIRAClient(userID id.UserID, allowUnauth bool) (*jira.Client, error) {
	// Check if user has an auth session.
	session, err := database.GetServiceDB().LoadAuthSessionByUser(r.id, userID)
	if err != nil {
		if err == sql.ErrNoRows {
			if allowUnauth {
				// make an unauthenticated client
				return jira.NewClient(nil, r.JIRAEndpoint)
			}
		}
		return nil, err
	}

	jsession, ok := session.(*Session)
	if !ok {
		return nil, errors.New("Failed to cast user session to a Session")
	}
	// Make sure they finished the auth process
	if jsession.AccessSecret == "" || jsession.AccessToken == "" {
		if allowUnauth {
			// make an unauthenticated client
			return jira.NewClient(nil, r.JIRAEndpoint)
		}
		return nil, errors.New("No authenticated session found for " + userID.String())
	}
	// make an authenticated client
	auth := r.oauth1Config(r.JIRAEndpoint)
	httpClient := auth.Client(
		context.TODO(),
		oauth1.NewToken(jsession.AccessToken, jsession.AccessSecret),
	)
	return jira.NewClient(httpClient, r.JIRAEndpoint)
}

func (r *Realm) parsePrivateKey() error {
	if r.privateKey != nil {
		return nil
	}
	pk, err := loadPrivateKey(r.PrivateKeyPEM)
	if err != nil {
		return err
	}
	pub, err := publicKeyAsPEM(pk)
	if err != nil {
		return err
	}
	r.PublicKeyPEM = pub
	r.privateKey = pk
	return nil
}

func (r *Realm) oauth1Config(jiraBaseURL string) *oauth1.Config {
	return &oauth1.Config{
		ConsumerKey:    r.ConsumerKey,
		ConsumerSecret: r.ConsumerSecret,
		CallbackURL:    r.redirectURL,
		// TODO: In JIRA Cloud, the Authorization URL is only the Instance BASE_URL:
		//    https://BASE_URL.atlassian.net.
		// It also does not require the + "/plugins/servlet/oauth/authorize"
		// We should probably check the provided JIRA base URL to see if it is a cloud one
		// then adjust accordingly.
		Endpoint: oauth1.Endpoint{
			RequestTokenURL: jiraBaseURL + "plugins/servlet/oauth/request-token",
			AuthorizeURL:    jiraBaseURL + "plugins/servlet/oauth/authorize",
			AccessTokenURL:  jiraBaseURL + "plugins/servlet/oauth/access-token",
		},
		Signer: &oauth1.RSASigner{
			PrivateKey: r.privateKey,
		},
	}
}

func loadPrivateKey(privKeyPEM string) (*rsa.PrivateKey, error) {
	// Decode PEM to grab the private key type
	block, _ := pem.Decode([]byte(privKeyPEM))
	if block == nil {
		return nil, errors.New("No PEM formatted block found")
	}

	priv, err := x509.ParsePKCS1PrivateKey(block.Bytes)
	if err != nil {
		return nil, err
	}
	return priv, nil
}

func publicKeyAsPEM(pkey *rsa.PrivateKey) (string, error) {
	// https://github.com/golang-samples/cipher/blob/master/crypto/rsa_keypair.go
	der, err := x509.MarshalPKIXPublicKey(&pkey.PublicKey)
	if err != nil {
		return "", err
	}
	block := pem.Block{
		Type:    "PUBLIC KEY",
		Headers: nil,
		Bytes:   der,
	}
	return string(pem.EncodeToMemory(&block)), nil
}

// jiraServiceInfo is the HTTP response to JIRA_ENDPOINT/rest/api/2/serverInfo
type jiraServiceInfo struct {
	ServerTitle    string `json:"serverTitle"`
	Version        string `json:"version"`
	VersionNumbers []int  `json:"versionNumbers"`
	BaseURL        string `json:"baseUrl"`
}

func jiraServerInfo(cli *jira.Client) (*jiraServiceInfo, error) {
	var jsi jiraServiceInfo
	req, _ := cli.NewRequest("GET", "rest/api/2/serverInfo", nil)
	if _, err := cli.Do(req, &jsi); err != nil {
		return nil, err
	}
	return &jsi, nil
}

// TODO: Github has this as well, maybe factor it out?
func failWith(logger *log.Entry, w http.ResponseWriter, code int, msg string, err error) {
	logger.WithError(err).Print(msg)
	w.WriteHeader(code)
	w.Write([]byte(msg))
}

func init() {
	types.RegisterAuthRealm(func(realmID, redirectURL string) types.AuthRealm {
		return &Realm{id: realmID, redirectURL: redirectURL}
	})
}