package server

import (
	"errors"
	"io/ioutil"
	"net/url"
	"regexp"
	"strings"
	"time"

	"git.autistici.org/id/go-sso"
)

var (
	// ErrBadService means the service does not follow the specification.
	ErrBadService = errors.New("bad service")

	// ErrBadDestinationScheme is returned when the destination
	// scheme isn't HTTPS.
	ErrBadDestinationScheme = errors.New("bad destination scheme")

	// ErrUnauthorizedService means that the target service has
	// not been whitelisted.
	ErrUnauthorizedService = errors.New("unauthorized service")

	// ErrUnauthorizedDestination means that the destination is
	// not part of the service.
	ErrUnauthorizedDestination = errors.New("unauthorized destination")

	// ErrUnauthorized can be returned by the Exchange method if
	// group ACLs do not match the current ticket.
	ErrUnauthorized = errors.New("unauthorized")

	serviceRx = regexp.MustCompile(`^(?:(?:[a-z0-9][-a-z0-9]*\.)+[a-z]{2,4}|localhost)(?::[0-9]{2,5})?(?:/.*)?/$`)
)

// LoginService provides the business logic for the SSO server,
// offering the Authorize and Exchange methods.
type LoginService struct {
	signer    sso.Signer
	validator sso.Validator
	config    *Config
}

func newSignerFromConfig(config *Config) (sso.Signer, error) {
	data, err := ioutil.ReadFile(config.SecretKeyFile)
	if err != nil {
		return nil, err
	}
	return sso.NewSigner(data)
}

func newValidatorFromConfig(config *Config) (sso.Validator, error) {
	data, err := ioutil.ReadFile(config.PublicKeyFile)
	if err != nil {
		return nil, err
	}
	return sso.NewValidator(data, config.Domain)
}

// NewLoginService returns a new LoginService with the specified configuration.
func NewLoginService(config *Config) (*LoginService, error) {
	signer, err := newSignerFromConfig(config)
	if err != nil {
		return nil, err
	}
	validator, err := newValidatorFromConfig(config)
	if err != nil {
		return nil, err
	}
	return &LoginService{
		config:    config,
		signer:    signer,
		validator: validator,
	}, nil
}

// Authorize a user to access a service by generating a token for
// it. Note that the user must already be successfully identified by
// some other means (e.g. passing a login form, etc).
func (s *LoginService) Authorize(username, service, destination, nonce string, groups []string) (string, error) {
	if err := s.validateServiceAccess(service, destination); err != nil {
		return "", err
	}
	tkt := sso.NewTicket(username, service, s.config.Domain, nonce, groups, s.config.getServiceTTL(service))
	return s.signer.Sign(tkt)
}

// Exchange a token for a new one scoped to a different service.
func (s *LoginService) Exchange(curToken, curService, curNonce, newService, nonce string) (string, error) {
	// Check that the current token is valid for the current service.
	curTkt, err := s.validator.Validate(curToken, curNonce, curService, nil)
	if err != nil {
		return "", err
	}

	// Verify that the exchange transition is explicitly allowed.
	if err := s.validateServiceExchange(curService, newService); err != nil {
		return "", err
	}

	// The new ticket will not be valid any longer than the
	// original one, or for a maximum of whatever service-specific
	// TTL we have configured, whichever comes first.
	ttlLeft := time.Until(curTkt.Expires)
	if svcTTL := s.config.getServiceTTL(newService); svcTTL < ttlLeft {
		ttlLeft = svcTTL
	}

	// Generate a new signed ticket for the new service. Groups
	// in the original ticket are passed identically to the newly
	// generated ticket.
	tkt := sso.NewTicket(curTkt.User, newService, s.config.Domain, nonce, curTkt.Groups, ttlLeft)
	return s.signer.Sign(tkt)
}

func (s *LoginService) validateServiceAccess(service, destination string) error {
	// Check if the service is well-formed and allowed.
	if !serviceRx.MatchString(service) {
		return ErrBadService
	}
	if !s.config.isServiceAllowed(service) {
		return ErrUnauthorizedService
	}

	// Figure out what the service should be for the destination,
	// and compare it with service.
	destinationURL, err := url.Parse(destination)
	if err != nil {
		return err
	}
	// Generic sanity checks on the destination URL.
	if destinationURL.Scheme != "https" {
		return ErrBadDestinationScheme
	}
	destinationSvc := destinationURL.Host + destinationURL.Path
	if !strings.HasPrefix(destinationSvc, service) {
		return ErrUnauthorizedDestination
	}

	return nil
}

func (s *LoginService) validateServiceExchange(srcService, dstService string) error {
	// Check that both services are well-formed and allowed.
	if !serviceRx.MatchString(srcService) || !serviceRx.MatchString(dstService) {
		return ErrBadService
	}
	if !s.config.isServiceAllowed(srcService) || !s.config.isServiceAllowed(dstService) {
		return ErrUnauthorizedService
	}

	if !s.config.isExchangeAllowed(srcService, dstService) {
		return ErrUnauthorized
	}

	return nil
}