// Package pow creates proof-of-work challenges and validates their solutions.
package pow

import (
	"bytes"
	"context"
	"crypto/hmac"
	"crypto/md5"
	"crypto/rand"
	"crypto/sha512"
	"encoding/binary"
	"errors"
	"fmt"
	"hash"
	"strconv"
	"time"

	"github.com/mediocregopher/blog.mediocregopher.com/srv/cfg"
	"github.com/mediocregopher/mediocre-go-lib/v2/mctx"
	"github.com/tilinna/clock"
)

type challengeParams struct {
	Target    uint32
	ExpiresAt int64
	Random    []byte
}

func (c challengeParams) MarshalBinary() ([]byte, error) {
	buf := new(bytes.Buffer)

	var err error
	write := func(v interface{}) {
		if err != nil {
			return
		}
		err = binary.Write(buf, binary.BigEndian, v)
	}

	write(c.Target)
	write(c.ExpiresAt)

	if err != nil {
		return nil, err
	}

	if _, err := buf.Write(c.Random); err != nil {
		panic(err)
	}

	return buf.Bytes(), nil
}

func (c *challengeParams) UnmarshalBinary(b []byte) error {
	buf := bytes.NewBuffer(b)

	var err error
	read := func(into interface{}) {
		if err != nil {
			return
		}
		err = binary.Read(buf, binary.BigEndian, into)
	}

	read(&c.Target)
	read(&c.ExpiresAt)

	if buf.Len() > 0 {
		c.Random = buf.Bytes() // whatever is left
	}

	return err
}

// The seed takes the form:
//
//	(version)+(signature of challengeParams)+(challengeParams)
//
// Version is currently always 0.
func newSeed(c challengeParams, secret []byte) ([]byte, error) {
	buf := new(bytes.Buffer)
	buf.WriteByte(0) // version

	cb, err := c.MarshalBinary()
	if err != nil {
		return nil, err
	}

	h := hmac.New(md5.New, secret)
	h.Write(cb)
	buf.Write(h.Sum(nil))

	buf.Write(cb)

	return buf.Bytes(), nil
}

var errMalformedSeed = errors.New("malformed seed")

func challengeParamsFromSeed(seed, secret []byte) (challengeParams, error) {
	h := hmac.New(md5.New, secret)
	hSize := h.Size()

	if len(seed) < hSize+1 || seed[0] != 0 {
		return challengeParams{}, errMalformedSeed
	}
	seed = seed[1:]

	sig, cb := seed[:hSize], seed[hSize:]

	// check signature
	h.Write(cb)
	if !hmac.Equal(sig, h.Sum(nil)) {
		return challengeParams{}, errMalformedSeed
	}

	var c challengeParams
	if err := c.UnmarshalBinary(cb); err != nil {
		return challengeParams{}, fmt.Errorf("unmarshaling challenge parameters: %w", err)
	}

	return c, nil
}

// Challenge is a set of fields presented to a client, with which they must
// generate a solution.
//
// Generating a solution is done by:
//
//	- Collect up to len(Seed) random bytes. These will be the potential
//	solution.
//
//	- Calculate the sha512 of the concatenation of Seed and PotentialSolution.
//
//	- Parse the first 4 bytes of the sha512 result as a big-endian uint32.
//
//	- If the resulting number is _less_ than Target, the solution has been
//	found. Otherwise go back to step 1 and try again.
//
type Challenge struct {
	Seed   []byte
	Target uint32
}

// Errors which may be produced by a Manager.
var (
	ErrInvalidSolution = errors.New("invalid solution")
	ErrExpiredSeed     = errors.New("expired seed")
)

// Manager is used to both produce proof-of-work challenges and check their
// solutions.
type Manager interface {
	NewChallenge() Challenge

	// Will produce ErrInvalidSolution if the solution is invalid, or
	// ErrExpiredSeed if the seed has expired.
	CheckSolution(seed, solution []byte) error
}

// ManagerParams are used to initialize a new Manager instance. All fields are
// required unless otherwise noted.
type ManagerParams struct {
	Clock clock.Clock
	Store Store

	// Secret is used to sign each Challenge's Seed, it should _not_ be shared
	// with clients.
	Secret []byte

	// The Target which Challenges should hit. Lower is more difficult.
	//
	// Defaults to 0x00FFFFFF
	Target uint32

	// ChallengeTimeout indicates how long before Challenges are considered
	// expired and cannot be solved.
	//
	// Defaults to 1 minute.
	ChallengeTimeout time.Duration
}

func (p *ManagerParams) setDefaults() {
	if p.Target == 0 {
		p.Target = 0x00FFFFFF
	}
	if p.ChallengeTimeout == 0 {
		p.ChallengeTimeout = 1 * time.Minute
	}
}

// SetupCfg implement the cfg.Cfger interface.
func (p *ManagerParams) SetupCfg(cfg *cfg.Cfg) {
	powTargetStr := cfg.String("pow-target", "0x0000FFFF", "Proof-of-work target, lower is more difficult")
	powSecretStr := cfg.String("pow-secret", "", "Secret used to sign proof-of-work challenge seeds")

	cfg.OnInit(func(ctx context.Context) error {
		p.setDefaults()

		if *powSecretStr == "" {
			return errors.New("-pow-secret is required")
		}

		powTargetUint, err := strconv.ParseUint(*powTargetStr, 0, 32)
		if err != nil {
			return fmt.Errorf("parsing -pow-target: %w", err)
		}

		p.Target = uint32(powTargetUint)
		p.Secret = []byte(*powSecretStr)

		return nil
	})
}

// Annotate implements mctx.Annotator interface.
func (p *ManagerParams) Annotate(a mctx.Annotations) {
	a["powTarget"] = fmt.Sprintf("%x", p.Target)
}

type manager struct {
	params ManagerParams
}

// NewManager initializes and returns a Manager instance using the given
// parameters.
func NewManager(params ManagerParams) Manager {
	params.setDefaults()
	return &manager{
		params: params,
	}
}

func (m *manager) NewChallenge() Challenge {
	target := m.params.Target

	c := challengeParams{
		Target:    target,
		ExpiresAt: m.params.Clock.Now().Add(m.params.ChallengeTimeout).Unix(),
		Random:    make([]byte, 8),
	}

	if _, err := rand.Read(c.Random); err != nil {
		panic(err)
	}

	seed, err := newSeed(c, m.params.Secret)
	if err != nil {
		panic(err)
	}

	return Challenge{
		Seed:   seed,
		Target: target,
	}
}

// SolutionChecker can be used to check possible Challenge solutions. It will
// cache certain values internally to save on allocations when used in a loop
// (e.g. when generating a solution).
//
// SolutionChecker is not thread-safe.
type SolutionChecker struct {
	h   hash.Hash // sha512
	sum []byte
}

// Check returns true if the given bytes are a solution to the given Challenge.
func (s SolutionChecker) Check(challenge Challenge, solution []byte) bool {
	if s.h == nil {
		s.h = sha512.New()
	}
	s.h.Reset()

	s.h.Write(challenge.Seed)
	s.h.Write(solution)
	s.sum = s.h.Sum(s.sum[:0])

	i := binary.BigEndian.Uint32(s.sum[:4])
	return i < challenge.Target
}

func (m *manager) CheckSolution(seed, solution []byte) error {
	c, err := challengeParamsFromSeed(seed, m.params.Secret)
	if err != nil {
		return fmt.Errorf("parsing challenge parameters from seed: %w", err)

	} else if now := m.params.Clock.Now().Unix(); c.ExpiresAt <= now {
		return ErrExpiredSeed
	}

	ok := (SolutionChecker{}).Check(
		Challenge{Seed: seed, Target: c.Target}, solution,
	)

	if !ok {
		return ErrInvalidSolution
	}

	expiresAt := time.Unix(c.ExpiresAt, 0)
	if err := m.params.Store.MarkSolved(seed, expiresAt.Add(1*time.Minute)); err != nil {
		return fmt.Errorf("marking solution as solved: %w", err)
	}

	return nil
}

// Solve returns a solution for the given Challenge. This may take a while.
func Solve(challenge Challenge) []byte {

	chk := SolutionChecker{}
	b := make([]byte, len(challenge.Seed))

	for {
		if _, err := rand.Read(b); err != nil {
			panic(err)
		} else if chk.Check(challenge, b) {
			return b
		}
	}
}
