// -*- Mode: Go; indent-tabs-mode: t -*-

/*
 * Copyright (C) 2016 Canonical Ltd
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License version 3 as
 * published by the Free Software Foundation.
 *
 * This program 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 this program.  If not, see <http://www.gnu.org/licenses/>.
 *
 */

package asserts

import (
	"fmt"
	"net/mail"
	"regexp"
	"strconv"
	"strings"
	"time"
)

// validSystemUserUsernames matches the regex we allow by osutil/user.go:IsValidUsername
var validSystemUserUsernames = regexp.MustCompile(`^[a-z0-9][-a-z0-9._]*$`)

// SystemUser holds a system-user assertion which allows creating local
// system users.
type SystemUser struct {
	assertionBase
	series     []string
	models     []string
	serials    []string
	sshKeys    []string
	since      time.Time
	until      time.Time
	expiration string

	forcePasswordChange bool
}

// BrandID returns the brand identifier that signed this assertion.
func (su *SystemUser) BrandID() string {
	return su.HeaderString("brand-id")
}

// Email returns the email address that this assertion is valid for.
func (su *SystemUser) Email() string {
	return su.HeaderString("email")
}

// Series returns the series that this assertion is valid for.
func (su *SystemUser) Series() []string {
	return su.series
}

// Models returns the models that this assertion is valid for.
func (su *SystemUser) Models() []string {
	return su.models
}

// Serials returns the serials that this assertion is valid for.
func (su *SystemUser) Serials() []string {
	return su.serials
}

// Name returns the full name of the user (e.g. Random Guy).
func (su *SystemUser) Name() string {
	return su.HeaderString("name")
}

// Username returns the system user name that should be created (e.g. "foo").
func (su *SystemUser) Username() string {
	return su.HeaderString("username")
}

// Password returns the crypt(3) compatible password for the user.
// Note that only ID: $6$ or stronger is supported (sha512crypt).
func (su *SystemUser) Password() string {
	return su.HeaderString("password")
}

// ForcePasswordChange returns true if the user needs to change the password
// after the first login.
func (su *SystemUser) ForcePasswordChange() bool {
	return su.forcePasswordChange
}

// SSHKeys returns the ssh keys for the user.
func (su *SystemUser) SSHKeys() []string {
	return su.sshKeys
}

// Since returns the time since the assertion is valid.
func (su *SystemUser) Since() time.Time {
	return su.since
}

// Until returns the time until the assertion is valid.
func (su *SystemUser) Until() time.Time {
	return su.until
}

// UserExpiration returns the expiration or validity duration of the user created.
//
// If no expiration was specified, this will return an zero time.Time structure.
//
// If expiration was set to 'until-expiration' then the .Until() time will be
// returned.
func (su *SystemUser) UserExpiration() time.Time {
	if su.expiration == "until-expiration" {
		return su.until
	}
	return time.Time{}
}

// ValidAt returns whether the system-user is valid at 'when' time.
func (su *SystemUser) ValidAt(when time.Time) bool {
	valid := when.After(su.since) || when.Equal(su.since)
	if valid {
		valid = when.Before(su.until)
	}
	return valid
}

// Implement further consistency checks.
func (su *SystemUser) checkConsistency(db RODatabase, acck *AccountKey) error {
	// Do the cross-checks when this assertion is actually used,
	// i.e. in the create-user code. See also Model.checkConsitency

	return nil
}

// expected interface is implemented
var _ consistencyChecker = (*SystemUser)(nil)

type shadow struct {
	ID     string
	Rounds string
	Salt   string
	Hash   string
}

// crypt(3) compatible hashes have the forms:
// - $id$salt$hash
// - $id$rounds=N$salt$hash
func parseShadowLine(line string) (*shadow, error) {
	l := strings.SplitN(line, "$", 5)
	if len(l) != 4 && len(l) != 5 {
		return nil, fmt.Errorf(`hashed password must be of the form "$integer-id$salt$hash", see crypt(3)`)
	}

	// if rounds is the second field, the line must consist of 4
	if strings.HasPrefix(l[2], "rounds=") && len(l) == 4 {
		return nil, fmt.Errorf(`missing hash field`)
	}

	// shadow line without $rounds=N$
	if len(l) == 4 {
		return &shadow{
			ID:   l[1],
			Salt: l[2],
			Hash: l[3],
		}, nil
	}
	// shadow line with rounds
	return &shadow{
		ID:     l[1],
		Rounds: l[2],
		Salt:   l[3],
		Hash:   l[4],
	}, nil
}

// see crypt(3) for the legal chars
var isValidSaltAndHash = regexp.MustCompile(`^[a-zA-Z0-9./]+$`).MatchString

func checkHashedPassword(headers map[string]any, name string) (string, error) {
	pw, err := checkOptionalString(headers, name)
	if err != nil {
		return "", err
	}
	// the pw string is optional, so just return if its empty
	if pw == "" {
		return "", nil
	}

	// parse the shadow line
	shd, err := parseShadowLine(pw)
	if err != nil {
		return "", fmt.Errorf(`%q header invalid: %s`, name, err)
	}

	// and verify it

	// see crypt(3), ID 6 means SHA-512 (since glibc 2.7)
	ID, err := strconv.Atoi(shd.ID)
	if err != nil {
		return "", fmt.Errorf(`%q header must start with "$integer-id$", got %q`, name, shd.ID)
	}
	// double check that we only allow modern hashes
	if ID < 6 {
		return "", fmt.Errorf("%q header only supports $id$ values of 6 (sha512crypt) or higher", name)
	}

	// the $rounds=N$ part is optional
	if strings.HasPrefix(shd.Rounds, "rounds=") {
		rounds, err := strconv.Atoi(strings.SplitN(shd.Rounds, "=", 2)[1])
		if err != nil {
			return "", fmt.Errorf("%q header has invalid number of rounds: %s", name, err)
		}
		if rounds < 5000 || rounds > 999999999 {
			return "", fmt.Errorf("%q header rounds parameter out of bounds: %d", name, rounds)
		}
	}

	if !isValidSaltAndHash(shd.Salt) {
		return "", fmt.Errorf("%q header has invalid chars in salt %q", name, shd.Salt)
	}
	if !isValidSaltAndHash(shd.Hash) {
		return "", fmt.Errorf("%q header has invalid chars in hash %q", name, shd.Hash)
	}

	return pw, nil
}

func checkSystemUserPresence(assert assertionBase) (string, error) {
	str, err := checkOptionalString(assert.headers, "user-presence")
	if err != nil || str == "" {
		return "", err
	}
	if assert.Format() < 2 {
		return "", fmt.Errorf(`the "user-presence" header is only supported for format 2 or greater`)
	}

	if str != "until-expiration" {
		return "", fmt.Errorf(`invalid "user-presence" header, only explicit valid value is "until-expiration": %q`, str)
	}
	return str, nil
}

func assembleSystemUser(assert assertionBase) (Assertion, error) {
	// brand-id here can be different from authority-id,
	// the code using the assertion must use the policy set
	// by the model assertion system-user-authority header
	email, err := checkNotEmptyString(assert.headers, "email")
	if err != nil {
		return nil, err
	}
	if _, err := mail.ParseAddress(email); err != nil {
		return nil, fmt.Errorf(`"email" header must be a RFC 5322 compliant email address: %s`, err)
	}

	series, err := checkStringList(assert.headers, "series")
	if err != nil {
		return nil, err
	}
	models, err := checkStringList(assert.headers, "models")
	if err != nil {
		return nil, err
	}
	serials, err := checkStringList(assert.headers, "serials")
	if err != nil {
		return nil, err
	}
	if len(serials) > 0 && assert.Format() < 1 {
		return nil, fmt.Errorf(`the "serials" header is only supported for format 1 or greater`)
	}
	if len(serials) > 0 && len(models) != 1 {
		return nil, fmt.Errorf(`in the presence of the "serials" header "models" must specify exactly one model`)
	}

	if _, err := checkOptionalString(assert.headers, "name"); err != nil {
		return nil, err
	}
	if _, err := checkStringMatches(assert.headers, "username", validSystemUserUsernames); err != nil {
		return nil, err
	}
	password, err := checkHashedPassword(assert.headers, "password")
	if err != nil {
		return nil, err
	}
	forcePasswordChange, err := checkOptionalBool(assert.headers, "force-password-change")
	if err != nil {
		return nil, err
	}
	if forcePasswordChange && password == "" {
		return nil, fmt.Errorf(`cannot use "force-password-change" with an empty "password"`)
	}

	sshKeys, err := checkStringList(assert.headers, "ssh-keys")
	if err != nil {
		return nil, err
	}
	since, err := checkRFC3339Date(assert.headers, "since")
	if err != nil {
		return nil, err
	}
	until, err := checkRFC3339Date(assert.headers, "until")
	if err != nil {
		return nil, err
	}
	if until.Before(since) {
		return nil, fmt.Errorf("'until' time cannot be before 'since' time")
	}
	expiration, err := checkSystemUserPresence(assert)
	if err != nil {
		return nil, err
	}

	// "global" system-user assertion can only be valid for 1y
	if len(models) == 0 && until.After(since.AddDate(1, 0, 0)) {
		return nil, fmt.Errorf("'until' time cannot be more than 365 days in the future when no models are specified")
	}

	return &SystemUser{
		assertionBase:       assert,
		series:              series,
		models:              models,
		serials:             serials,
		sshKeys:             sshKeys,
		since:               since,
		until:               until,
		expiration:          expiration,
		forcePasswordChange: forcePasswordChange,
	}, nil
}

func systemUserFormatAnalyze(headers map[string]any, body []byte) (formatnum int, err error) {
	formatnum = 0

	serials, err := checkStringList(headers, "serials")
	if err != nil {
		return 0, err
	}
	if len(serials) > 0 {
		formatnum = 1
	}

	presence, err := checkOptionalString(headers, "user-presence")
	if err != nil {
		return 0, err
	}
	if presence != "" {
		formatnum = 2
	}

	return formatnum, nil
}
