package runner

import (
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"slices"
	"strings"

	"github.com/buildkite/test-engine-client/internal/debug"
	"github.com/buildkite/test-engine-client/internal/plan"
	"github.com/kballard/go-shellquote"
)

type Jest struct {
	RunnerConfig
}

func NewJest(j RunnerConfig) Jest {
	if j.TestCommand == "" {
		j.TestCommand = "npx jest {{testExamples}} --json --testLocationInResults --outputFile {{resultPath}}"
	}

	if j.TestFilePattern == "" {
		j.TestFilePattern = "**/{__tests__/**/*,*.spec,*.test}.{ts,js,tsx,jsx}"
	}

	if j.RetryTestCommand == "" {
		j.RetryTestCommand = "npx jest --testNamePattern '{{testNamePattern}}' --json --testLocationInResults --outputFile {{resultPath}}"
	}

	return Jest{
		RunnerConfig: j,
	}
}

func (j Jest) Name() string {
	return "Jest"
}

// GetFiles returns an array of file names using the discovery pattern.
func (j Jest) GetFiles() ([]string, error) {
	debug.Println("Discovering test files with include pattern:", j.TestFilePattern, "exclude pattern:", j.TestFileExcludePattern)
	files, err := discoverTestFiles(j.TestFilePattern, j.TestFileExcludePattern)
	debug.Println("Discovered", len(files), "files")

	if err != nil {
		return nil, err
	}

	if len(files) == 0 {
		return nil, fmt.Errorf("no files found with pattern %q and exclude pattern %q", j.TestFilePattern, j.TestFileExcludePattern)
	}

	return files, nil
}

func (j Jest) Run(result *RunResult, testCases []plan.TestCase, retry bool) error {
	var cmd *exec.Cmd
	var err error

	testPaths := make([]string, len(testCases))
	for i, testCase := range testCases {
		testPaths[i] = testCase.Path
	}

	slices.Sort(testPaths)
	testPaths = slices.Compact(testPaths)

	if !retry {
		commandName, commandArgs, err := j.commandNameAndArgs(j.TestCommand, testPaths)
		if err != nil {
			return fmt.Errorf("failed to build command: %w", err)
		}

		cmd = exec.Command(commandName, commandArgs...)
	} else {
		testNames := make([]string, len(testCases))
		for i, testCase := range testCases {
			testNames[i] = fmt.Sprintf("%s %s", testCase.Scope, testCase.Name)
		}
		commandName, commandArgs, err := j.retryCommandNameAndArgs(j.RetryTestCommand, testNames, testPaths)
		if err != nil {
			return fmt.Errorf("failed to build command: %w", err)
		}

		cmd = exec.Command(commandName, commandArgs...)
	}

	err = runAndForwardSignal(cmd)

	if ProcessSignaledError := new(ProcessSignaledError); errors.As(err, &ProcessSignaledError) {
		return err
	}

	report, parseErr := j.ParseReport(j.ResultPath)
	if parseErr != nil {
		fmt.Println("Buildkite Test Engine Client: Failed to read Jest output, tests will not be retried.")
		return err
	}

	for _, testResult := range report.TestResults {
		for _, example := range testResult.AssertionResults {
			var status TestStatus
			switch example.Status {
			case "failed":
				status = TestStatusFailed
			case "passed":
				status = TestStatusPassed
			case "pending":
				status = TestStatusSkipped
			case "todo":
				status = TestStatusSkipped
			}

			wordDir, err := os.Getwd()
			if err != nil {
				return fmt.Errorf("failed to get current working directory: %v", err)
			}
			testPath, err := filepath.Rel(wordDir, testResult.FileName)
			if err != nil {
				return fmt.Errorf("failed to get relative path of test file: %v", err)
			}

			// The scope and name has to match with the scope generated by Buildkite test collector.
			// For more details, see:
			// [Buildkite Test Collector - Jest implementation](https://github.com/buildkite/test-collector-javascript/blob/42b803a618a15a07edf0169038ef4b5eba88f98d/jest/reporter.js#L40)
			testCase := plan.TestCase{
				Name:  example.Title,
				Scope: strings.Join(example.AncestorTitles, " "),
				Path:  testPath,
			}

			result.RecordTestResult(testCase, status)
		}
	}

	if report.NumRuntimeErrorTestSuites > 0 {
		result.error = fmt.Errorf("Jest failed with runtime error test suites")
	}

	return nil
}

type JestExample struct {
	Name           string   `json:"fullName"`
	Status         string   `json:"status"`
	Title          string   `json:"title"`
	AncestorTitles []string `json:"ancestorTitles"`
	Location       struct {
		Line   int
		Column int
	}
}

type JestReport struct {
	NumFailedTests            int
	NumRuntimeErrorTestSuites int
	TestResults               []struct {
		AssertionResults []JestExample
		FileName         string `json:"name"`
	}
}

func (j Jest) ParseReport(path string) (JestReport, error) {
	var report JestReport
	data, err := os.ReadFile(path)
	if err != nil {
		return JestReport{}, fmt.Errorf("failed to read Jest output: %v", err)
	}

	if err := json.Unmarshal(data, &report); err != nil {
		return JestReport{}, fmt.Errorf("failed to parse Jest output: %s", err)
	}

	return report, nil
}

func (j Jest) commandNameAndArgs(cmd string, testCases []string) (string, []string, error) {
	words, err := shellquote.Split(cmd)
	if err != nil {
		return "", []string{}, err
	}
	idx := slices.Index(words, "{{testExamples}}")
	if idx < 0 {
		words = append(words, testCases...)
	} else {
		words = slices.Replace(words, idx, idx+1, testCases...)
	}

	outputIdx := slices.Index(words, "{{resultPath}}")
	if outputIdx < 0 {
		err := fmt.Errorf("couldn't find '{{resultPath}}' sentinel in command, exiting.")
		return "", []string{}, err
	}
	words = slices.Replace(words, outputIdx, outputIdx+1, j.ResultPath)

	return words[0], words[1:], nil
}

func (j Jest) retryCommandNameAndArgs(cmd string, testCases []string, testPaths []string) (string, []string, error) {
	words, err := shellquote.Split(cmd)
	if err != nil {
		return "", []string{}, err
	}

	idx := slices.Index(words, "{{testNamePattern}}")
	if idx < 0 {
		err := fmt.Errorf("couldn't find '{{testNamePattern}}' sentinel in retry command")
		return "", []string{}, err
	}

	escapedTestCases := make([]string, len(testCases))
	for i, testCase := range testCases {
		escapedTestCases[i] = regexp.QuoteMeta(testCase)
	}

	testNamePattern := fmt.Sprintf("(%s)", strings.Join(escapedTestCases, "|"))

	words = slices.Replace(words, idx, idx+1, testNamePattern)

	testExamplesIdx := slices.Index(words, "{{testExamples}}")
	if testExamplesIdx >= 0 {
		words = slices.Replace(words, testExamplesIdx, testExamplesIdx+1, testPaths...)
	}

	outputIdx := slices.Index(words, "{{resultPath}}")
	if outputIdx < 0 {
		err := fmt.Errorf("couldn't find '{{resultPath}}' sentinel in retry command, exiting.")
		return "", []string{}, err
	}
	words = slices.Replace(words, outputIdx, outputIdx+1, j.ResultPath)

	return words[0], words[1:], err
}

func (j Jest) GetExamples(files []string) ([]plan.TestCase, error) {
	return nil, fmt.Errorf("not supported in Jest")
}
