// Mgmt
// Copyright (C) 2013-2024+ James Shubin and the project contributors
// Written by James Shubin <james@shubin.ca> and the project contributors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program.  If not, see <https://www.gnu.org/licenses/>.
//
// Additional permission under GNU GPL version 3 section 7
//
// If you modify this program, or any covered work, by linking or combining it
// with embedded mcl code and modules (and that the embedded mcl code and
// modules which link with this program, contain a copy of their source code in
// the authoritative form) containing parts covered by the terms of any other
// license, the licensors of this program grant you additional permission to
// convey the resulting work. Furthermore, the licensors of this program grant
// the original author, James Shubin, additional permission to update this
// additional permission if he deems it necessary to achieve the goals of this
// additional permission.

// Package inputs contains the input parsing logic for how mcl module
// entrypoints are handled for the language.
//
// In other words, given a string, does this represent stdin, a specific
// metadata input file, a specific code file, a directory of code, some raw and
// inline code, or nothing at all.
package inputs

import (
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"

	"github.com/purpleidea/mgmt/engine"
	"github.com/purpleidea/mgmt/gapi"
	"github.com/purpleidea/mgmt/lang/interfaces"
	"github.com/purpleidea/mgmt/util/errwrap"
)

// Module logic: We want one single file to start from. That single file will
// have `import` or `append` statements in it. Those pull in everything else. To
// kick things off, we point directly at a metadata.yaml file path, which points
// to the main.mcl entry file. Alternatively, we could point directly to a
// main.mcl file, but then we wouldn't know to import additional special dirs
// like files/ that are listed in the metadata.yaml file. If we point to a
// directory, we could read a metadata.yaml file, and if missing, read a
// main.mcl file. The ideal way to start mgmt is by pointing to a metadata.yaml
// file. It's ideal because *it* could list the dir path for files/ or
// templates/ or other modules. If we start with a single foo.mcl file, the file
// itself won't have files/ however other files/ can come in from an import that
// contains a metadata.yaml file. Below we have the input parsers that implement
// some of this logic.

var (
	// inputOrder contains the correct running order of the input functions.
	inputOrder = []func(string, engine.Fs) (*ParsedInput, error){
		inputEmpty,
		inputStdin,
		inputMetadata,
		inputMcl,
		inputDirectory,
		inputCode,
		//inputFail,
	}
)

// ParsedInput is the output struct which contains all the information we need.
type ParsedInput struct {
	//activated bool // if struct is not nil we're activated
	FS       engine.Fs // reference to the engine.Fs used to call the parse
	Base     string    // base path (abs path with trailing slash)
	Main     []byte    // contents of main entry mcl code
	Files    []string  // files and dirs to copy to fs (abs paths)
	Metadata *interfaces.Metadata
	Workers  []func(engine.WriteableFS) error // copy files here that aren't listed!
}

// String adds a pretty-printing facility to make it easier to visualize these.
func (obj *ParsedInput) String() string {
	s := "&ParsedInput{\n"
	s += "\tFS: " + obj.FS.URI() + "\n" // don't print directly
	s += "\tBase: " + obj.Base + "\n"
	//s += "\tMain: " + obj.Main + "\n" // messy
	s += fmt.Sprintf("\tFiles: %+v\n", obj.Files)
	s += fmt.Sprintf("\tMetadata: %+v\n", obj.Metadata)
	s += fmt.Sprintf("\tWorkers: %d\n", len(obj.Workers))
	return s + "}"
}

// ParseInput runs the list if input parsers to know how to run the lexer,
// parser, and so on... The fs input is the source filesystem to look in.
func ParseInput(s string, fs engine.Fs) (*ParsedInput, error) {
	var err error
	var output *ParsedInput
	activated := false
	// i decided this was a cleaner way of input parsing than a big if-else!
	for _, fn := range inputOrder { // list of input detection functions
		output, err = fn(s, fs)
		if err != nil {
			return nil, err
		}
		if output != nil { // activated!
			activated = true
			break
		}
	}
	if !activated {
		return nil, fmt.Errorf("input is invalid")
	}

	return output, nil
}

// absify makes a path absolute if it's not already.
func absify(str string) (string, error) {
	if filepath.IsAbs(str) {
		return str, nil // done early!
	}
	x, err := filepath.Abs(str)
	if err != nil {
		return "", errwrap.Wrapf(err, "can't get abs path for: `%s`", str)
	}
	if strings.HasSuffix(str, "/") { // if we started with a trailing slash
		x = dirify(x) // add it back because filepath.Abs() removes it!
	}
	return x, nil // success, we're absolute now
}

// dirify ensures path ends with a trailing slash, so that it's a dir. Don't
// call this on something that's not a dir! It just appends a trailing slash if
// one isn't already present.
func dirify(str string) string {
	if !strings.HasSuffix(str, "/") {
		return str + "/"
	}
	return str
}

// inputEmpty is a simple empty string contents check.
// TODO: perhaps we could have a default action here to run from /etc/ or /var/?
func inputEmpty(s string, _ engine.Fs) (*ParsedInput, error) {
	if s == "" {
		return nil, fmt.Errorf("input is empty")
	}
	return nil, nil // pass (this test never succeeds)
}

// inputStdin checks if we're looking at stdin.
func inputStdin(s string, fs engine.Fs) (*ParsedInput, error) {
	if s != "-" {
		return nil, nil // not us, but no error
	}

	// TODO: stdin passthrough is not implemented (should it be?)
	// TODO: this reads everything into memory, which isn't very efficient!

	// FIXME: check if we have a contained OsFs or not.
	//if fs != OsFs { // XXX: https://github.com/spf13/afero/issues/188
	//	return nil, errwrap.Wrapf("can't use stdin for: `%s`", fs.Name())
	//}

	// TODO: can this cause a problem if stdin is too large?
	// TODO: yes, we could pass a reader directly, but we'd
	// need to have a convention for it to get closed after
	// and we need to save it to disk for deploys to use it
	b, err := io.ReadAll(os.Stdin) // doesn't need fs
	if err != nil {
		return nil, errwrap.Wrapf(err, "can't read in stdin")
	}

	return inputCode(string(b), fs) // recurse
}

// inputMetadata checks to see if we have a metadata file path.
func inputMetadata(s string, fs engine.Fs) (*ParsedInput, error) {
	// we've got a metadata.yaml file
	if !strings.HasSuffix(s, "/"+interfaces.MetadataFilename) {
		return nil, nil // not us, but no error
	}
	var err error
	if s, err = absify(s); err != nil { // s is now absolute
		return nil, err
	}

	// does metadata file exist?
	f, err := fs.Open(s)
	if err != nil {
		return nil, errwrap.Wrapf(err, "file: `%s` does not exist", s)
	}

	// parse metadata file and save it to the fs
	metadata, err := interfaces.ParseMetadata(f)
	if err != nil {
		return nil, errwrap.Wrapf(err, "could not parse metadata file")
	}

	// base path on local system of the metadata file, with trailing slash
	basePath := dirify(filepath.Dir(s)) // absolute dir
	m := basePath + metadata.Main       // absolute file

	// does main.mcl file exist? open the file read-only...
	fm, err := fs.Open(m)
	if err != nil {
		return nil, errwrap.Wrapf(err, "can't read from file: `%s`", m)
	}
	defer fm.Close()         // we're done reading by the time this runs
	b, err := io.ReadAll(fm) // doesn't need fs
	if err != nil {
		return nil, errwrap.Wrapf(err, "can't read in file: `%s`", m)
	}

	// files that we saw
	files := []string{
		s, // the metadata.yaml input file
		m, // the main.mcl file
	}

	// real files/ directory
	if metadata.Files != "" { // TODO: nil pointer instead?
		filesDir := basePath + metadata.Files
		if _, err := fs.Stat(filesDir); err == nil {
			files = append(files, filesDir)
		}
	}

	// set this path since we know the location (it is used to find modules)
	if err := metadata.SetAbsSelfPath(basePath); err != nil { // set metadataPath
		return nil, errwrap.Wrapf(err, "could not build metadata")
	}
	return &ParsedInput{
		FS:       fs,
		Base:     basePath,
		Main:     b,
		Files:    files,
		Metadata: metadata,
		// no Workers needed, this is the ideal input
	}, nil
}

// inputMcl checks if we have a path to a *.mcl file?
func inputMcl(s string, fs engine.Fs) (*ParsedInput, error) {
	// TODO: a regexp here would be better
	if !strings.HasSuffix(s, interfaces.DotFileNameExtension) {
		return nil, nil // not us, but no error
	}
	var err error
	if s, err = absify(s); err != nil { // s is now absolute
		return nil, err
	}
	// does *.mcl file exist? open the file read-only...
	fm, err := fs.Open(s)
	if err != nil {
		return nil, errwrap.Wrapf(err, "can't read from file: `%s`", s)
	}
	defer fm.Close()         // we're done reading by the time this runs
	b, err := io.ReadAll(fm) // doesn't need fs
	if err != nil {
		return nil, errwrap.Wrapf(err, "can't read in file: `%s`", s)
	}

	// build and save a metadata file to fs
	metadata := &interfaces.Metadata{
		//Main: interfaces.MainFilename, // TODO: use standard name?
		Main: filepath.Base(s), // use the name of the input
	}
	byt, err := metadata.ToBytes()
	if err != nil {
		// probably a programming error
		return nil, errwrap.Wrapf(err, "can't built metadata file")
	}
	dst := "/" + interfaces.MetadataFilename // eg: /metadata.yaml
	workers := []func(engine.WriteableFS) error{
		func(fs engine.WriteableFS) error {
			err := gapi.CopyBytesToFs(fs, byt, dst)
			return errwrap.Wrapf(err, "could not copy metadata file to fs")
		},
	}
	return &ParsedInput{
		FS:   fs,
		Base: dirify(filepath.Dir(s)), // base path with trailing slash
		Main: b,
		Files: []string{
			s, // the input .mcl file
		},
		Metadata: metadata,
		Workers:  workers,
	}, nil
}

// inputDirectory checks if we're given the path to a directory.
func inputDirectory(s string, fs engine.Fs) (*ParsedInput, error) {
	if !strings.HasSuffix(s, "/") {
		return nil, nil // not us, but no error
	}
	var err error
	if s, err = absify(s); err != nil { // s is now absolute
		return nil, err
	}
	// does dir exist?
	fi, err := fs.Stat(s)
	if err != nil {
		return nil, errwrap.Wrapf(err, "dir: `%s` does not exist", s)
	}
	if !fi.IsDir() {
		return nil, errwrap.Wrapf(err, "dir: `%s` is not a dir", s)
	}

	// try looking for a metadata file in the root
	md := s + interfaces.MetadataFilename // absolute file
	if _, err := fs.Stat(md); err == nil {
		if x, err := inputMetadata(md, fs); err != nil { // recurse
			return nil, err
		} else if x != nil {
			return x, nil // recursed successfully!
		}
	}

	// try looking for a main.mcl file in the root
	mf := s + interfaces.MainFilename // absolute file
	if _, err := fs.Stat(mf); err == nil {
		if x, err := inputMcl(mf, fs); err != nil { // recurse
			return nil, err
		} else if x != nil {
			return x, nil // recursed successfully!
		}
	}

	// no other options left, didn't activate!
	return nil, nil
}

// inputCode checks if this is raw code? (last possibility, try and run it).
func inputCode(s string, fs engine.Fs) (*ParsedInput, error) {
	if len(s) == 0 {
		// handle empty strings in a single place by recursing
		if x, err := inputEmpty(s, fs); err != nil { // recurse
			return nil, err
		} else if x != nil {
			return x, nil // recursed successfully!
		}
	}

	// check if code is `metadata.yaml`, which is obviously not correct here
	if s == interfaces.MetadataFilename {
		return nil, fmt.Errorf("unexpected raw code '%s'. Did you mean './%s'?",
			interfaces.MetadataFilename, interfaces.MetadataFilename)
	}

	wd, err := os.Getwd() // NOTE: not meaningful for stdin unless fs is an OsFs
	if err != nil {
		return nil, errwrap.Wrapf(err, "can't get working dir")
	}

	// since by the time we run this stdin will be gone, and
	// we want this to work with deploys, we need to fake it
	// by saving the data and adding a default metadata file
	// so that everything is built in a logical input state.
	metadata := &interfaces.Metadata{ // default metadata
		Main: interfaces.MainFilename,
	}
	byt, err := metadata.ToBytes() // build a metadata file
	if err != nil {
		return nil, errwrap.Wrapf(err, "could not build metadata file")
	}

	dst1 := "/" + interfaces.MetadataFilename // eg: /metadata.yaml
	dst2 := "/" + metadata.Main               // eg: /main.mcl
	b := []byte(s)                            // unfortunately we convert things back and forth :/

	workers := []func(engine.WriteableFS) error{
		func(fs engine.WriteableFS) error {
			err := gapi.CopyBytesToFs(fs, byt, dst1)
			return errwrap.Wrapf(err, "could not copy metadata file to fs")
		},
		func(fs engine.WriteableFS) error {
			err := gapi.CopyBytesToFs(fs, b, dst2)
			return errwrap.Wrapf(err, "could not copy main file to fs")
		},
	}

	return &ParsedInput{
		FS:       fs,
		Base:     dirify(wd),
		Main:     b,
		Files:    []string{}, // they're already copied in
		Metadata: metadata,
		Workers:  workers,
	}, nil
}

// inputFail fails, because we couldn't activate anyone. We might not need this.
//func inputFail(s string, _ engine.Fs) (*ParsedInput, error) {
//	return nil, fmt.Errorf("input is invalid") // fail (this test always succeeds)
//}
