package loglist

import (
	_ "embed"
	"encoding/json"
	"errors"
	"fmt"
	"math/rand"
	"os"
	"strings"
	"time"

	"github.com/letsencrypt/boulder/ctpolicy/loglist/schema"
)

// purpose is the use to which a log list will be put. This type exists to allow
// the following consts to be declared for use by LogList consumers.
type purpose string

// Issuance means that the new log list should only contain Usable logs, which
// can issue SCTs that will be trusted by all Chrome clients.
const Issuance purpose = "scts"

// Informational means that the new log list can contain Usable, Qualified, and
// Pending logs, which will all accept submissions but not necessarily be
// trusted by Chrome clients.
const Informational purpose = "info"

// Validation means that the new log list should only contain Usable and
// Readonly logs, whose SCTs will be trusted by all Chrome clients but aren't
// necessarily still issuing SCTs today.
const Validation purpose = "lint"

// List represents a list of logs, grouped by their operator, arranged by
// the "v3" schema as published by Chrome:
// https://www.gstatic.com/ct/log_list/v3/log_list_schema.json
// It exports no fields so that consumers don't have to deal with the terrible
// autogenerated names of the structs it wraps.
type List map[string]OperatorGroup

// OperatorGroup represents a group of logs which are all run by the same
// operator organization. It provides constant-time lookup of logs within the
// group by their unique ID.
type OperatorGroup map[string]Log

// Log represents a single log run by an operator. It contains just the info
// necessary to contact a log, and to determine whether that log will accept
// the submission of a certificate with a given expiration.
type Log struct {
	Name           string
	Url            string
	Key            string
	StartInclusive time.Time
	EndExclusive   time.Time
	State          state
}

// State is an enum representing the various states a CT log can be in. Only
// pending, qualified, and usable logs can be submitted to. Only usable and
// readonly logs are trusted by Chrome.
type state int

const (
	unknown state = iota
	pending
	qualified
	usable
	readonly
	retired
	rejected
)

func stateFromState(s *schema.LogListSchemaJsonOperatorsElemLogsElemState) state {
	if s == nil {
		return unknown
	} else if s.Rejected != nil {
		return rejected
	} else if s.Retired != nil {
		return retired
	} else if s.Readonly != nil {
		return readonly
	} else if s.Pending != nil {
		return pending
	} else if s.Qualified != nil {
		return qualified
	} else if s.Usable != nil {
		return usable
	}
	return unknown
}

// usableForPurpose returns true if the log state is acceptable for the given
// log list purpose, and false otherwise.
func usableForPurpose(s state, p purpose) bool {
	switch p {
	case Issuance:
		return s == usable
	case Informational:
		return s == usable || s == qualified || s == pending
	case Validation:
		return s == usable || s == readonly
	}
	return false
}

// New returns a LogList of all operators and all logs parsed from the file at
// the given path. The file must conform to the JSON Schema published by Google:
// https://www.gstatic.com/ct/log_list/v3/log_list_schema.json
func New(path string) (List, error) {
	file, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("failed to read CT Log List: %w", err)
	}

	return newHelper(file)
}

// newHelper is a helper to allow the core logic of `New()` to be unit tested
// without having to write files to disk.
func newHelper(file []byte) (List, error) {
	var parsed schema.LogListSchemaJson
	err := json.Unmarshal(file, &parsed)
	if err != nil {
		return nil, fmt.Errorf("failed to parse CT Log List: %w", err)
	}

	result := make(List)
	for _, op := range parsed.Operators {
		group := make(OperatorGroup)
		for _, log := range op.Logs {
			var name string
			if log.Description != nil {
				name = *log.Description
			}

			info := Log{
				Name:  name,
				Url:   log.Url,
				Key:   log.Key,
				State: stateFromState(log.State),
			}

			if log.TemporalInterval != nil {
				startInclusive, err := time.Parse(time.RFC3339, log.TemporalInterval.StartInclusive)
				if err != nil {
					return nil, fmt.Errorf("failed to parse log %q start timestamp: %w", log.Url, err)
				}

				endExclusive, err := time.Parse(time.RFC3339, log.TemporalInterval.EndExclusive)
				if err != nil {
					return nil, fmt.Errorf("failed to parse log %q end timestamp: %w", log.Url, err)
				}

				info.StartInclusive = startInclusive
				info.EndExclusive = endExclusive
			}

			group[log.LogId] = info
		}
		result[op.Name] = group
	}

	return result, nil
}

// SubsetForPurpose returns a new log list containing only those logs whose
// names match those in the given list, and whose state is acceptable for the
// given purpose. It returns an error if any of the given names are not found
// in the starting list, or if the resulting list is too small to satisfy the
// Chrome "two operators" policy.
func (ll List) SubsetForPurpose(names []string, p purpose) (List, error) {
	sub, err := ll.subset(names)
	if err != nil {
		return nil, err
	}

	res, err := sub.forPurpose(p)
	if err != nil {
		return nil, err
	}

	return res, nil
}

// subset returns a new log list containing only those logs whose names match
// those in the given list. It returns an error if any of the given names are
// not found.
func (ll List) subset(names []string) (List, error) {
	remaining := make(map[string]struct{}, len(names))
	for _, name := range names {
		remaining[name] = struct{}{}
	}

	newList := make(List)
	for operator, group := range ll {
		newGroup := make(OperatorGroup)
		for id, log := range group {
			if _, found := remaining[log.Name]; !found {
				continue
			}

			newLog := Log{
				Name:           log.Name,
				Url:            log.Url,
				Key:            log.Key,
				State:          log.State,
				StartInclusive: log.StartInclusive,
				EndExclusive:   log.EndExclusive,
			}

			newGroup[id] = newLog
			delete(remaining, newLog.Name)
		}
		if len(newGroup) > 0 {
			newList[operator] = newGroup
		}
	}

	if len(remaining) > 0 {
		missed := make([]string, len(remaining))
		for name := range remaining {
			missed = append(missed, fmt.Sprintf("%q", name))
		}
		return nil, fmt.Errorf("failed to find logs matching name(s): %s", strings.Join(missed, ", "))
	}

	return newList, nil
}

// forPurpose returns a new log list containing only those logs whose states are
// acceptable for the given purpose. It returns an error if the purpose is
// Issuance or Validation and the set of remaining logs is too small to satisfy
// the Google "two operators" log policy.
func (ll List) forPurpose(p purpose) (List, error) {
	newList := make(List)
	for operator, group := range ll {
		newGroup := make(OperatorGroup)
		for id, log := range group {
			if !usableForPurpose(log.State, p) {
				continue
			}

			newLog := Log{
				Name:           log.Name,
				Url:            log.Url,
				Key:            log.Key,
				State:          log.State,
				StartInclusive: log.StartInclusive,
				EndExclusive:   log.EndExclusive,
			}

			newGroup[id] = newLog
		}
		if len(newGroup) > 0 {
			newList[operator] = newGroup
		}
	}

	if len(newList) < 2 && p != Informational {
		return nil, errors.New("log list does not have enough groups to satisfy Chrome policy")
	}

	return newList, nil
}

// OperatorForLogID returns the Name of the Group containing the Log with the
// given ID, or an error if no such log/group can be found.
func (ll List) OperatorForLogID(logID string) (string, error) {
	for op, group := range ll {
		if _, found := group[logID]; found {
			return op, nil
		}
	}
	return "", fmt.Errorf("no log with ID %q found", logID)
}

// Permute returns the list of operator group names in a randomized order.
func (ll List) Permute() []string {
	keys := make([]string, 0, len(ll))
	for k := range ll {
		keys = append(keys, k)
	}

	result := make([]string, len(ll))
	for i, j := range rand.Perm(len(ll)) {
		result[i] = keys[j]
	}
	return result
}

// PickOne returns the URI and Public Key of a single randomly-selected log
// which is run by the given operator and whose temporal interval includes the
// given expiry time. It returns an error if no such log can be found.
func (ll List) PickOne(operator string, expiry time.Time) (string, string, error) {
	group, ok := ll[operator]
	if !ok {
		return "", "", fmt.Errorf("no log operator group named %q", operator)
	}

	candidates := make([]Log, 0)
	for _, log := range group {
		if log.StartInclusive.IsZero() || log.EndExclusive.IsZero() {
			candidates = append(candidates, log)
			continue
		}

		if (log.StartInclusive.Equal(expiry) || log.StartInclusive.Before(expiry)) && log.EndExclusive.After(expiry) {
			candidates = append(candidates, log)
		}
	}

	// Ensure rand.Intn below won't panic.
	if len(candidates) < 1 {
		return "", "", fmt.Errorf("no log found for group %q and expiry %s", operator, expiry)
	}

	log := candidates[rand.Intn(len(candidates))]
	return log.Url, log.Key, nil
}
