package app

import (
	"context"
	"devsecops-quickstart/opa-scan/pkg/filesystem"
	"devsecops-quickstart/opa-scan/pkg/runners"
	"devsecops-quickstart/opa-scan/pkg/utils"
	"embed"
	"encoding/json"
	"errors"
	"fmt"
	"log"
	"reflect"
	"regexp"
	"strconv"
	"strings"
	"sync"

	"github.com/open-policy-agent/opa/ast"
	"github.com/open-policy-agent/opa/rego"
)

// Colombo struct
type Colombo struct {
	inputPath  string
	rulesPath  []string
	libsPath   string
	parameters map[string]string
}

// ColomboResult struc
type ColomboResult struct {
	Rules []RuleResult
}

// RuleResult struct definition
type RuleResult struct {
	ID      string `json:"id"`
	Message string `json:"message"`
	Type    string `json:"type"`
	Valid   string `json:"valid"`
}

var (
	app  *Colombo
	once sync.Once
	//go:embed lib/*.rego
	libs embed.FS
)

func newResult() ColomboResult {
	return ColomboResult{}
}

// New Creates a new Colombo Object
func New(rulesPath []string, parameters string) *Colombo {
	// method to ensure singleton behaviour
	once.Do(func() {
		app = &Colombo{
			libsPath: "./lib",
		}
		err := app.setRulesPath(rulesPath)
		if err != nil {
			log.Fatal(err)
		}
		app.SetParameters(parameters)
	})

	return app
}

// SetParameters set parameters to replace in input file
func (c *Colombo) SetParameters(parameters string) {
	c.parameters = map[string]string{}

	// Split paramters by "," allowing lists Example: foo1=hello,foo2=[sub1,sub2]
	r := regexp.MustCompile(`(?:\[.*?\]|[^,])+`)
	parametersSlice := r.FindAllString(parameters, -1)

	for _, param := range parametersSlice {
		keyValue := strings.Split(param, "=")
		key := keyValue[0]
		value := strings.Replace(keyValue[1], "\"", "", -1)

		if strings.HasPrefix(value, "[") {
			valueTemp := strings.Replace(value, "[", "", -1)
			valueTemp = strings.Replace(valueTemp, "]", "", -1)
			valueSlice := strings.Split(valueTemp, ",")
			for i, v := range valueSlice {
				valueSlice[i] = "\"" + v + "\""
			}
			value = "[" + strings.Join(valueSlice, ",") + "]"
		} else if _, err := strconv.Atoi(value); err != nil {
			value = "\"" + value + "\""
		}

		c.parameters[key] = value
	}
}

func (c *Colombo) setRulesPath(rulesPath []string) error {
	for i := 0; i < len(rulesPath); i++ {
		fs := filesystem.NewFilesystemByPath(rulesPath[i])
		if ok, _ := fs.PathExists(rulesPath[i]); ok == false {
			return errors.New("Rules path (" + rulesPath[i] + ") does not exists")
		}
	}
	c.rulesPath = rulesPath
	return nil
}

func (c Colombo) getRules() (map[string]string, error) {
	rules := map[string]string{}

	for i := 0; i < len(c.rulesPath); i++ {
		fs := filesystem.NewFilesystemByPath(c.rulesPath[i])
		rulesPath, err := fs.Read(c.rulesPath[i], "^.+\\.(rego)$", false)
		if err != nil {
			return rules, err
		}
		for _, v := range rulesPath {
			fmt.Println("Loaded rule: " + v.FilePath)
			rules[v.FilePath] = v.Data
		}
	}

	return rules, nil
}

// readInputContent Reads input content and returns a map with the path as key and file content as value.
// Input can be a directory path or a file path.
func (c Colombo) readInputContent(path string) ([]utils.InputFile, error) {
	inputFilesJSON := []utils.InputFile{}

	fs := filesystem.NewFilesystemByPath(path)
	inputFiles, err := fs.Read(path, "^.+\\.(template).(json)$", true)
	if err != nil {
		return inputFilesJSON, err
	}

	for _, v := range inputFiles {
		fmt.Println("Loaded input: " + v.FilePath)
		var data map[string]interface{}
		json.Unmarshal([]byte(v.Data), &data)
		inputFilesJSON = append(inputFilesJSON, utils.InputFile{
			Type:     "JSON",
			FilePath: v.FilePath,
			Data:     data,
		})
	}

	return inputFilesJSON, nil
}

func (c Colombo) makeRunner(inputFiles []utils.InputFile) runners.RunnerInterface {
	var runner runners.RunnerInterface

	if len(inputFiles) == 1 {
		//Check if Terraform
		if _, ok := inputFiles[0].Data["terraform_version"]; ok {
			runner = runners.NewTerraform(c.parameters)
		} else {
			runner = runners.NewCloudFormation(c.parameters)
		}
	} else {
		runner = runners.NewCloudFormation(c.parameters)
	}

	return runner
}

func (c Colombo) prepareModules(runner runners.RunnerInterface) (map[string]string, error) {
	fmt.Println("Reading OpaScan Rules...")
	modules, err := c.getRules()
	if err != nil {
		return modules, err
	}

	fmt.Println("Loading Libraries....")
	for _, v := range runner.GetRegoLibraries() {
		f, err := libs.ReadFile("lib/" + v + ".rego")
		if err != nil {
			log.Fatal(err)
		}
		modules[v] = string(f)
	}

	return modules, nil
}

// Eval run evaluation
func (c *Colombo) Eval(ctx context.Context, input string) (ColomboResult, error) {
	fmt.Println("Starting OpaScan Evaluation...")

	// Reads input content and returns a map with the path as key and file content as value
	fmt.Println("Reading Input: " + input)
	c.inputPath = input
	inputFiles, err := c.readInputContent(c.inputPath)
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println("Creating final Input....")
	runner := c.makeRunner(inputFiles)
	finalInput := runner.GenerateInput(inputFiles)

	// load Rego lib and rules
	modules, err := c.prepareModules(runner)
	if err != nil {
		log.Fatal(err)
	}

	// Compile the module. The keys are used as identifiers in error messages.
	compiler, err := ast.CompileModules(modules)

	fmt.Println("Checking Compliance.....")
	rego := rego.New(
		rego.Query("data.rules[_].rule"),
		rego.Compiler(compiler),
		rego.Input(finalInput.Data))

	//Run evaluation.
	rs, err := rego.Eval(ctx)
	fmt.Println("Evaluation Finished.")
	if err != nil {
		return newResult(), err
	}
	fmt.Println(rs)
	res := c.parseScanResult(rs)

	return res, err
}

func (c Colombo) parseScanResult(rs rego.ResultSet) ColomboResult {
	result := newResult()

	if len(rs) == 0 {
		return result
	}

	for _, r := range rs {
		for _, exp := range r.Expressions {
			s := reflect.ValueOf(exp.Value)
			for i := 0; i < s.Len(); i++ {
				ret := s.Index(i).Interface()
				data, _ := json.Marshal(ret)
				rule := &RuleResult{}
				json.Unmarshal(data, rule)
				result.Rules = append(result.Rules, *rule)
			}
		}
	}

	return result
}
