package nixbuild

import (
	"bufio"
	"context"
	"errors"
	"io"
	"iter"
	"path"
	"slices"
	"strings"
)

const (
	instantiatedPrefix    = "instantiated '"
	instantiatedSeparator = " -> "
	instantiatedFields    = 2

	// a reasonable starting buffer capacity: pretty close to average of test cases
	instantiatedInitialCap = 1 << 13
)

const (
	InstantiatedBadFields = iota
	InstantiatedUnexpectedQuotes
	InstantiatedNotAbsolute
)

type MalformedInstantiatedError struct {
	Message string
	Type    int
}

func (m *MalformedInstantiatedError) Error() string {
	switch m.Type {
	case InstantiatedBadFields:
		return "incorrect amount of fields in instantiated message"
	case InstantiatedUnexpectedQuotes:
		return "unexpected quotes in final instantiated field"
	case InstantiatedNotAbsolute:
		return "instantiated derivation path is not absolute"
	default:
		panic("unreachable")
	}
}

func (m *MalformedInstantiatedError) Is(err error) bool {
	var e *MalformedInstantiatedError
	if !errors.As(err, &e) {
		return false
	}
	if e == nil || m == nil {
		return e == m
	}
	return e.Type == m.Type
}

// InstantiatedDecoder interprets the verbose output of `nix build` and collects instantiated derivations.
type InstantiatedDecoder struct {
	err     error
	scanner *bufio.Scanner
}

// Err returns the first error encountered by the InstantiatedDecoder.
func (d *InstantiatedDecoder) Err() error { return errors.Join(d.err, d.scanner.Err()) }

// NewInstantiatedDecoder returns a [InstantiatedDecoder] struct for collecting derivations from r.
func NewInstantiatedDecoder(r io.Reader) *InstantiatedDecoder {
	return &InstantiatedDecoder{scanner: bufio.NewScanner(r)}
}

// Drv returns a non-reusable iterator over instantiated derivations collected from [io.Reader].
func (d *InstantiatedDecoder) Drv() iter.Seq[string] {
	return func(yield func(string) bool) {
		for d.scanner.Scan() {
			v := d.scanner.Text()
			if !strings.HasPrefix(v, instantiatedPrefix) {
				// write skipped lines to stderr
				if Stderr != nil {
					_, _ = Stderr.Write([]byte(v + "\n"))
				}
				continue
			}

			fields := strings.SplitN(v, instantiatedSeparator, instantiatedFields)
			if len(fields) != instantiatedFields {
				// no more than a single -> is expected
				d.err = &MalformedInstantiatedError{v, InstantiatedBadFields}
				break
			}

			// very basic validation here: the output format is not fully understood
			if len(fields[1]) < 3 || fields[1][0] != '\'' || fields[1][len(fields[1])-1] != '\'' {
				d.err = &MalformedInstantiatedError{v, InstantiatedUnexpectedQuotes}
				break
			}
			drv := fields[1][1 : len(fields[1])-1]

			if !path.IsAbs(drv) {
				d.err = &MalformedInstantiatedError{v, InstantiatedNotAbsolute}
				break
			}

			if !yield(drv) {
				break
			}
		}
	}
}

// DecodeInstantiated interprets the verbose output of `nix build` and returns a slice of all instantiated derivations.
func DecodeInstantiated(r io.Reader) ([]string, error) {
	decoder := NewInstantiatedDecoder(r)

	instantiated := make([]string, 0, instantiatedInitialCap)
	for drv := range decoder.Drv() {
		if len(instantiated) == cap(instantiated) {
			// grow the buffer exponentially to minimise copies
			instantiated = slices.Grow(instantiated, cap(instantiated)<<1)
		}
		instantiated = append(instantiated, drv)
	}

	slices.Sort(instantiated)
	return slices.Compact(instantiated), decoder.Err()

}

// EvalInstantiated evaluates the installable and returns all derivations instantiated during the evaluation.
// This relies on verbose output of `nix build` and is certainly not the correct way to do it, but so far it works
// significantly better than `nix-store -qR` and there does not appear to be a better way.
func EvalInstantiated(ctx context.Context, installable string) ([]string, error) {
	c, cancel := context.WithCancel(ctx)
	defer cancel()

	cmd := Nix(c, "build", installable,
		// 'instantiated' messages are only emitted when actually evaluating something
		"--option", "eval-cache", "false",
		// do not actually build anything
		"--dry-run",
		// increase verbosity so nix outputs 'instantiated' messages
		"-Lvvv",
	)

	cmd.Stdout = Stdout
	stderr, err := cmd.StderrPipe()
	if err != nil {
		return nil, err
	}

	if err := cmd.Start(); err != nil {
		// have finalizer take care of the pipe
		return nil, err
	}

	instantiated, decodeErr := DecodeInstantiated(stderr)
	waitErr := cmd.Wait()
	return instantiated, errors.Join(decodeErr, waitErr)
}
