package launch

import (
	"encoding/json"
	"fmt"
	"path"
	"path/filepath"
	"strings"

	"github.com/google/go-cmp/cmp"
	"github.com/google/go-cmp/cmp/cmpopts"

	"github.com/buildpacks/lifecycle/api"
)

// Process represents a process to launch at runtime.
type Process struct {
	Type             string       `toml:"type" json:"type"`
	Command          RawCommand   `toml:"command" json:"command"`
	Args             []string     `toml:"args" json:"args"`
	Direct           bool         `toml:"direct" json:"direct"`
	Default          bool         `toml:"default,omitempty" json:"default,omitempty"`
	BuildpackID      string       `toml:"buildpack-id" json:"buildpackID"`
	WorkingDirectory string       `toml:"working-dir,omitempty" json:"working-dir,omitempty"`
	ExecEnv          []string     `toml:"exec-env,omitempty" json:"exec-env,omitempty"`
	PlatformAPI      *api.Version `toml:"-" json:"-"`
}

func (p Process) NoDefault() Process {
	p.Default = false
	return p
}

func (p Process) WithPlatformAPI(platformAPI *api.Version) Process {
	// set on the process itself
	p.PlatformAPI = platformAPI
	// set on the command as well, this is needed when we serialize the command
	p.Command.PlatformAPI = platformAPI

	// for platform versions < 0.10
	// we only support a single command
	// push any extra entries into the args so they aren't lost
	if p.PlatformAPI.LessThan("0.10") {
		p.Args = append(p.Command.Entries[1:], p.Args[0:]...)
		p.Command.Entries = []string{p.Command.Entries[0]}
	}
	return p
}

type RawCommand struct {
	Entries     []string
	PlatformAPI *api.Version
}

func NewRawCommand(entries []string) RawCommand {
	return RawCommand{Entries: entries}
}

func (c RawCommand) WithPlatformAPI(api *api.Version) RawCommand {
	c.PlatformAPI = api
	return c
}

func (c RawCommand) MarshalTOML() ([]byte, error) {
	if c.PlatformAPI == nil {
		return nil, fmt.Errorf("missing PlatformAPI while encoding RawCommand")
	}

	if c.PlatformAPI.AtLeast("0.10") {
		buffer := &strings.Builder{}
		// turn array into toml array
		buffer.WriteString("[")
		for i, entry := range c.Entries {
			if i != 0 {
				buffer.WriteString(", ")
			}
			escaped, err := json.Marshal(entry) // properly escape special characters in single string
			if err != nil {
				return nil, err
			}
			buffer.WriteString(string(escaped))
		}
		buffer.WriteString("]")
		return []byte(buffer.String()), nil
	}

	return json.Marshal(c.Entries[0]) // properly escape special characters in single string
}

func (c RawCommand) MarshalJSON() ([]byte, error) {
	if c.PlatformAPI == nil {
		return nil, fmt.Errorf("missing PlatformAPI while encoding RawCommand")
	}

	if c.PlatformAPI.AtLeast("0.10") {
		return json.Marshal(c.Entries)
	}

	return json.Marshal(c.Entries[0])
}

// UnmarshalTOML implements toml.Unmarshaler and is needed because we read metadata.toml
// this method will attempt to parse the command in either string or array format
func (c *RawCommand) UnmarshalTOML(data any) error {
	var entries []string
	// the raw value is either "the-command" or ["the-command", "arg1", "arg2"]
	// the latter is exposed as []interface{} by toml library and needs conversion
	switch v := data.(type) {
	case string:
		entries = []string{v}
	case []any:
		s := make([]string, len(v))
		for i, el := range v {
			s[i] = fmt.Sprint(el)
		}
		entries = s
	default:
		return fmt.Errorf("unknown command type %T with data %v", data, data)
	}

	*c = NewRawCommand(entries)
	return nil
}

// UnmarshalJSON implements json.Unmarshaler and is provided to help library consumers who need to read the build metadata label
// this method will attempt to parse the command in either string or array format
func (c *RawCommand) UnmarshalJSON(data []byte) error {
	var entries []string
	strData := string(data)
	// first try to decode array
	err := json.NewDecoder(strings.NewReader(strData)).Decode(&entries)
	if err != nil {
		// then try string
		var s string
		err = json.NewDecoder(strings.NewReader(strData)).Decode(&s)
		if err != nil {
			return err
		}
		entries = []string{s}
	}
	*c = NewRawCommand(entries)
	return nil
}

// ProcessPath returns the absolute path to the symlink for a given process type
func ProcessPath(pType string) string {
	return filepath.Join(ProcessDir, pType+exe)
}

type Metadata struct {
	Processes  []Process   `toml:"processes" json:"processes"`
	Buildpacks []Buildpack `toml:"buildpacks" json:"buildpacks"`
}

// Matches is used by goMock to compare two Metadata objects in tests
// when matching expected calls to methods containing Metadata objects
func (m Metadata) Matches(x any) bool {
	metadatax, ok := x.(Metadata)
	if !ok {
		return false
	}

	// don't compare Processes directly, we will compare those individually next
	if s := cmp.Diff(metadatax, m, cmpopts.IgnoreFields(Metadata{}, "Processes")); s != "" {
		return false
	}

	// we need to ignore the PlatformAPI field because it isn't always set where these are used
	// and trying to compare it will cause a panic
	for i, p := range m.Processes {
		if s := cmp.Diff(metadatax.Processes[i], p,
			cmpopts.IgnoreFields(Process{}, "PlatformAPI"),
			cmpopts.IgnoreFields(RawCommand{}, "PlatformAPI")); s != "" {
			return false
		}
	}

	return true
}

func (m Metadata) String() string {
	return fmt.Sprintf("%+v %+v", m.Processes, m.Buildpacks)
}

func (m Metadata) FindProcessType(pType string) (Process, bool) {
	for _, p := range m.Processes {
		if p.Type == pType {
			return p, true
		}
	}
	return Process{}, false
}

type Buildpack struct {
	API string `toml:"api"`
	ID  string `toml:"id"`
}

func EscapeID(id string) string {
	return strings.ReplaceAll(id, "/", "_")
}

func GetMetadataFilePath(layersDir string) string {
	return path.Join(layersDir, "config", "metadata.toml")
}
