package resolver

import (
	"fmt"
	"time"

	"github.com/go-acme/lego/v4/acme"
	"github.com/go-acme/lego/v4/challenge"
	"github.com/go-acme/lego/v4/log"
)

// Interface for all challenge solvers to implement.
type solver interface {
	Solve(authorization acme.Authorization) error
}

// Interface for challenges like dns, where we can set a record in advance for ALL challenges.
// This saves quite a bit of time vs creating the records and solving them serially.
type preSolver interface {
	PreSolve(authorization acme.Authorization) error
}

// Interface for challenges like dns, where we can solve all the challenges before to delete them.
type cleanup interface {
	CleanUp(authorization acme.Authorization) error
}

type sequential interface {
	Sequential() (bool, time.Duration)
}

// an authz with the solver we have chosen and the index of the challenge associated with it.
type selectedAuthSolver struct {
	authz  acme.Authorization
	solver solver
}

type Prober struct {
	solverManager *SolverManager
}

func NewProber(solverManager *SolverManager) *Prober {
	return &Prober{
		solverManager: solverManager,
	}
}

// Solve Looks through the challenge combinations to find a solvable match.
// Then solves the challenges in series and returns.
func (p *Prober) Solve(authorizations []acme.Authorization) error {
	failures := make(obtainError)

	var (
		authSolvers           []*selectedAuthSolver
		authSolversSequential []*selectedAuthSolver
	)

	// Loop through the resources, basically through the domains.
	// First pass just selects a solver for each authz.

	for _, authz := range authorizations {
		domain := challenge.GetTargetedDomain(authz)
		if authz.Status == acme.StatusValid {
			// Boulder might recycle recent validated authz (see issue #267)
			log.Infof("[%s] acme: authorization already valid; skipping challenge", domain)
			continue
		}

		if solvr := p.solverManager.chooseSolver(authz); solvr != nil {
			authSolver := &selectedAuthSolver{authz: authz, solver: solvr}

			switch s := solvr.(type) {
			case sequential:
				if ok, _ := s.Sequential(); ok {
					authSolversSequential = append(authSolversSequential, authSolver)
				} else {
					authSolvers = append(authSolvers, authSolver)
				}
			default:
				authSolvers = append(authSolvers, authSolver)
			}
		} else {
			failures[domain] = fmt.Errorf("[%s] acme: could not determine solvers", domain)
		}
	}

	parallelSolve(authSolvers, failures)

	sequentialSolve(authSolversSequential, failures)

	// Be careful not to return an empty failures map,
	// for even an empty obtainError is a non-nil error value
	if len(failures) > 0 {
		return failures
	}

	return nil
}

func sequentialSolve(authSolvers []*selectedAuthSolver, failures obtainError) {
	// Some CA are using the same token,
	// this can be a problem with the DNS01 challenge when the DNS provider doesn't support duplicate TXT records.
	// In the sequential mode, this is not a problem because we can solve the challenges in order.
	// But it can reduce the number of call the DNS provider APIs.
	uniq := make(map[string]struct{})

	for i, authSolver := range authSolvers {
		// Submit the challenge
		domain := challenge.GetTargetedDomain(authSolver.authz)

		chlg, _ := challenge.FindChallenge(challenge.DNS01, authSolver.authz)

		if solvr, ok := authSolver.solver.(preSolver); ok {
			if _, ok := uniq[authSolver.authz.Identifier.Value+chlg.Token]; ok && chlg.Token != "" {
				log.Infof("acme: duplicate token for %q (DNS-01); skipping pre-solve.", authSolver.authz.Identifier.Value)
				continue
			}

			err := solvr.PreSolve(authSolver.authz)
			if err != nil {
				failures[domain] = err

				cleanUp(authSolver.solver, authSolver.authz)

				continue
			}

			uniq[authSolver.authz.Identifier.Value+chlg.Token] = struct{}{}
		}

		// Solve challenge
		err := authSolver.solver.Solve(authSolver.authz)
		if err != nil {
			failures[domain] = err

			cleanUp(authSolver.solver, authSolver.authz)

			continue
		}

		if _, ok := uniq[authSolver.authz.Identifier.Value+chlg.Token]; ok || chlg.Token == "" {
			// Clean challenge
			cleanUp(authSolver.solver, authSolver.authz)

			if len(authSolvers)-1 > i {
				solvr := authSolver.solver.(sequential)
				_, interval := solvr.Sequential()
				log.Infof("sequence: wait for %s", interval)
				time.Sleep(interval)
			}

			delete(uniq, authSolver.authz.Identifier.Value+chlg.Token)
		} else {
			log.Infof("acme: duplicate token for %q (DNS-01); skipping cleanup.", authSolver.authz.Identifier.Value)
		}
	}
}

func parallelSolve(authSolvers []*selectedAuthSolver, failures obtainError) {
	// Some CA are using the same token,
	// this can be a problem with the DNS01 challenge when the DNS provider doesn't support duplicate TXT records.
	uniq := make(map[string]struct{})

	// For all valid preSolvers, first submit the challenges, so they have max time to propagate
	for _, authSolver := range authSolvers {
		authz := authSolver.authz

		chlg, err := challenge.FindChallenge(challenge.DNS01, authz)
		if err == nil {
			if _, ok := uniq[authz.Identifier.Value+chlg.Token]; ok {
				log.Infof("acme: duplicate token for %q (DNS-01); skipping pre-solve.", authSolver.authz.Identifier.Value)
				continue
			}

			uniq[authz.Identifier.Value+chlg.Token] = struct{}{}
		}

		if solvr, ok := authSolver.solver.(preSolver); ok {
			err := solvr.PreSolve(authz)
			if err != nil {
				failures[challenge.GetTargetedDomain(authz)] = err
			}
		}
	}

	defer func() {
		// Clean all created TXT records
		for _, authSolver := range authSolvers {
			chlg, err := challenge.FindChallenge(challenge.DNS01, authSolver.authz)
			if err == nil {
				if _, ok := uniq[authSolver.authz.Identifier.Value+chlg.Token]; ok {
					delete(uniq, authSolver.authz.Identifier.Value+chlg.Token)
				} else {
					log.Infof("acme: duplicate token for %q (DNS-01); skipping cleanup.", authSolver.authz.Identifier.Value)
					continue
				}
			}

			cleanUp(authSolver.solver, authSolver.authz)
		}
	}()

	// Finally solve all challenges for real
	for _, authSolver := range authSolvers {
		authz := authSolver.authz

		domain := challenge.GetTargetedDomain(authz)
		if failures[domain] != nil {
			// already failed in previous loop
			continue
		}

		err := authSolver.solver.Solve(authz)
		if err != nil {
			failures[domain] = err
		}
	}
}

func cleanUp(solvr solver, authz acme.Authorization) {
	if solvr, ok := solvr.(cleanup); ok {
		domain := challenge.GetTargetedDomain(authz)

		err := solvr.CleanUp(authz)
		if err != nil {
			log.Warnf("[%s] acme: cleaning up failed: %v ", domain, err)
		}
	}
}
