//
//  tank
//
//  Import and Playlist Daemon for autoradio project
//
//
//  Copyright (C) 2017-2019 Christian Pointner <equinox@helsinki.at>
//
//  This file is part of tank.
//
//  tank is free software: you can redistribute it and/or modify
//  it under the terms of the GNU General Public License as published by
//  the Free Software Foundation, either version 3 of the License, or
//  any later version.
//
//  tank is distributed in the hope that it will be useful,
//  but WITHOUT ANY WARRANTY; without even the implied warranty of
//  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
//  GNU General Public License for more details.
//
//  You should have received a copy of the GNU General Public License
//  along with tank. If not, see <http://www.gnu.org/licenses/>.
//

package auth

import (
	"context"
	"encoding/json"
	"errors"
	"net/http"
	"time"

	oidc "github.com/coreos/go-oidc"
	"golang.org/x/oauth2"
)

const (
	defaultLoginTimeout = 5 * time.Minute
)

type OIDCSession struct {
	backend     *OIDCBackend
	nonce       string
	tokenSource oauth2.TokenSource
}

func (os *OIDCSession) refresh(ctx context.Context, in *Session) (*Session, error) {
	userInfo, err := os.backend.provider.UserInfo(ctx, os.tokenSource)
	if err != nil {
		return nil, errors.New("fetching OIDC UserInfo failed: " + err.Error())
	}

	out := in.emptyCopy()
	if err := userInfo.Claims(out); err != nil {
		return nil, errors.New("parsing OIDC UserInfo failed: " + err.Error())
	}
	if err = auth.sessions.update(in.ID(), out); err != nil {
		return nil, errors.New("updating session failed: " + err.Error())
	}
	return out, nil
}

// TODO this needs more testing!!
func refreshSession(s *Session) {
	ticker := time.NewTicker(5 * time.Minute) // TODO: hardcoded value
	defer ticker.Stop()
	errCnt := 0

	for {
		// TODO: also subscribe to session updates...
		select {
		case <-s.ctx.Done():
			return
		case <-ticker.C:
			ctx, cancel := context.WithTimeout(s.ctx, time.Minute) // TODO: hardcoded value
			newS, err := s.oidc.refresh(ctx, s)
			cancel()
			if err == nil {
				errCnt++
				if errCnt > 3 { // TODO: hardcoded value
					s.setState(SessionStateLoggedOut)
					return
				}
			} else {
				s = newS
			}
		}
	}
}

func loginTimeout(s *Session) {
	if s.updateState(SessionStateNew, SessionStateLoginTimeout) {
		return
	}
	if s.updateState(SessionStateLoginStarted, SessionStateLoginTimeout) {
		return
	}
	if s.updateState(SessionStateLoginFinalizing, SessionStateLoginTimeout) {
		return
	}
}

// This is only safe when session is logged in!
func (s *OIDCSession) MarshalJSON() ([]byte, error) {
	t, err := s.tokenSource.Token()
	if err != nil {
		return nil, err
	}
	return json.Marshal(struct {
		Token *oauth2.Token `json:"token,omitempty"`
	}{
		Token: t,
	})
}

type OIDCBackend struct {
	loginTimeout time.Duration
	issuerURL    string
	provider     *oidc.Provider
	verifier     *oidc.IDTokenVerifier
	oauth2Config oauth2.Config
}

func NewOIDCBackend(cfg *OIDCConfig) (b *OIDCBackend, err error) {
	// TODO: make ctx a parameter?
	ctx := context.Background()
	b = &OIDCBackend{issuerURL: cfg.IssuerURL, loginTimeout: cfg.LoginTimeout}
	if b.loginTimeout <= 0 {
		b.loginTimeout = defaultLoginTimeout
	}

	if b.provider, err = oidc.NewProvider(ctx, cfg.IssuerURL); err != nil {
		return
	}

	oidcConfig := &oidc.Config{
		ClientID: cfg.ClientID,
	}
	b.verifier = b.provider.Verifier(oidcConfig)

	b.oauth2Config = oauth2.Config{
		ClientID:     cfg.ClientID,
		ClientSecret: cfg.ClientSecret,
		Endpoint:     b.provider.Endpoint(),
		RedirectURL:  cfg.CallbackURL,
		Scopes:       []string{oidc.ScopeOpenID, "username", "aura_shows"},
	}

	return
}

func (b *OIDCBackend) String() string {
	return "OpenID Connect using Identity Provider: " + b.issuerURL
}

func (b *OIDCBackend) NewOIDCSession(ctx context.Context, arguments json.RawMessage) (s *Session, err error) {
	os := &OIDCSession{backend: b}
	if os.nonce, err = generateRandomString(16); err != nil {
		return
	}

	// TODO: set session state to login-failed on any error...
	if s, err = auth.sessions.new(); err != nil {
		return nil, err
	}
	s.oidc = os // TODO: this is probably fine but rather ugly...

	if arguments != nil {
		var oauth2Token oauth2.Token
		if err = json.Unmarshal(arguments, &oauth2Token); err != nil {
			return nil, errors.New("failed to parse Oauth2 Token: " + err.Error())
		}

		os.tokenSource = b.oauth2Config.TokenSource(s.ctx, &oauth2Token)
		if s, err = os.refresh(ctx, s); err != nil {
			return
		}
		s.setState(SessionStateLoggedIn)

		go refreshSession(s)
	} else {
		time.AfterFunc(b.loginTimeout, func() { loginTimeout(s) })
	}
	return
}

func (b *OIDCBackend) LoginHandler() http.Handler {
	return &oidcLoginHandler{backend: b}
}

func (b *OIDCBackend) CallbackHandler() http.Handler {
	return &oidcCallbackHandler{backend: b}
}

type oidcLoginHandler struct {
	backend *OIDCBackend
}

func (h *oidcLoginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	s := auth.sessions.get(r.URL.Query().Get("session-id"))
	if s == nil {
		sendHTTPInvalidSessionResponse(w)
		return
	}
	if s.oidc == nil {
		sendHTTPErrorResponse(w, http.StatusConflict, "this is not an OIDC session")
		return
	}

	if !s.updateState(SessionStateNew, SessionStateLoginStarted) {
		sendHTTPErrorResponse(w, http.StatusConflict, "this session is already logged in or in an invalid state")
		return
	}

	http.Redirect(w, r, h.backend.oauth2Config.AuthCodeURL(s.ID(), oidc.Nonce(s.oidc.nonce)), http.StatusFound)
}

type oidcCallbackHandler struct {
	backend *OIDCBackend
}

func (h *oidcCallbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	s := auth.sessions.get(r.URL.Query().Get("state"))
	if s == nil {
		sendHTTPInvalidSessionResponse(w)
		return
	}
	if s.oidc == nil {
		sendHTTPErrorResponse(w, http.StatusConflict, "this is not an OIDC session")
		return
	}

	// TODO: handle login error (query parameter: error, error_description)

	if !s.updateState(SessionStateLoginStarted, SessionStateLoginFinalizing) {
		sendHTTPErrorResponse(w, http.StatusConflict, "this session is already logged in or in an invalid state")
		return
	}

	oauth2Token, err := h.backend.oauth2Config.Exchange(r.Context(), r.URL.Query().Get("code"))
	if err != nil {
		s.setState(SessionStateLoginFailed)
		sendHTTPErrorResponse(w, http.StatusBadRequest, "OAuth2 token exchange failed: "+err.Error())
		return
	}
	rawIDToken, ok := oauth2Token.Extra("id_token").(string)
	if !ok {
		s.setState(SessionStateLoginFailed)
		sendHTTPErrorResponse(w, http.StatusBadRequest, "OIDC verification failed: no id_token field in oauth2 token.")
		return
	}

	// Verify the ID Token signature and nonce.
	idToken, err := h.backend.verifier.Verify(r.Context(), rawIDToken)
	if err != nil {
		s.setState(SessionStateLoginFailed)
		sendHTTPErrorResponse(w, http.StatusInternalServerError, "OAuth2 ID token verification failed: "+err.Error())
		return
	}
	if idToken.Nonce != s.oidc.nonce {
		s.setState(SessionStateLoginFailed)
		sendHTTPErrorResponse(w, http.StatusInternalServerError, "OAuth2 ID token verification failed: invalid nonce")
		return
	}

	// Populate a new session with data from UserInfo endpoint.
	s.oidc.tokenSource = h.backend.oauth2Config.TokenSource(s.ctx, oauth2Token)
	if s, err = s.oidc.refresh(r.Context(), s); err != nil {
		s.setState(SessionStateLoginFailed)
		sendHTTPErrorResponse(w, http.StatusInternalServerError, err.Error())
		return
	}
	if !s.updateState(SessionStateLoginFinalizing, SessionStateLoggedIn) {
		sendHTTPErrorResponse(w, http.StatusGone, "session login timeout")
		return
	}

	sendHTTPResponse(w, http.StatusOK, "You are now logged in as: "+s.Username)
}