package module

import (
	"bufio"
	"os"
	"path/filepath"
	"strconv"
	"strings"

	"github.com/gomods/athens/pkg/errors"
)

var (
	pathSeparator    = "/"
	versionSeparator = "."
)

// Filter is a filter of modules.
type Filter struct {
	root     ruleNode
	filePath string
}

// NewFilter creates new filter based on rules defined in a configuration file.
// WARNING: this is not concurrently safe.
// Configuration consists of two operations: + for include and - for exclude:
// e.g.
//   - github.com/a
//   - github.com/a/b
//
// will communicate all modules except github.com/a and its children, but github.com/a/b will be communicated
// example 2:
//
//	-
//	+ github.com/a
//
// will exclude all items from communication except github.com/a.
func NewFilter(filterFilePath string) (*Filter, error) {
	// Do not return an error if the file path is empty
	// Do not attempt to parse it as well.
	if filterFilePath == "" {
		return nil, nil
	}

	return initFromConfig(filterFilePath)
}

// AddRule adds rule for specified path.
func (f *Filter) AddRule(path string, qualifiers []string, rule FilterRule) {
	f.ensurePath(path)

	segments := getPathSegments(path)

	if len(segments) == 0 {
		f.root.rule = rule
		return
	}

	// look for latest node in a path
	latest := f.root
	for _, p := range segments[:len(segments)-1] {
		latest = latest.next[p]
	}

	// replace with updated node
	last := segments[len(segments)-1]
	rn := latest.next[last]
	rn.rule = rule
	rn.qualifiers = qualifiers
	latest.next[last] = rn
}

// Rule returns the filter rule to be applied to the given path.
func (f *Filter) Rule(path, version string) FilterRule {
	segs := getPathSegments(path)
	rule := f.getAssociatedRule(version, segs...)
	if rule == Default {
		rule = Include
	}

	return rule
}

func (f *Filter) ensurePath(path string) {
	latest := f.root.next
	pathSegments := getPathSegments(path)

	for _, p := range pathSegments {
		if _, ok := latest[p]; !ok {
			latest[p] = newRule(Default)
		}
		latest = latest[p].next
	}
}

func (f *Filter) getAssociatedRule(version string, path ...string) FilterRule {
	if len(path) == 0 {
		return f.root.rule
	}

	rules := make([]FilterRule, 0, len(path))
	rn := f.root
	for _, p := range path {
		if _, ok := rn.next[p]; !ok {
			break
		}
		rn = rn.next[p]
		// default to true if no version filter, false otherwise
		match := len(rn.qualifiers) == 0
		for _, q := range rn.qualifiers {
			if matches(version, q) {
				match = true
				break
			}
		}
		if match || version == "" {
			rules = append(rules, rn.rule)
		}
	}

	if len(rules) == 0 {
		return f.root.rule
	}

	for i := len(rules) - 1; i >= 0; i-- {
		if rules[i] != Default {
			return rules[i]
		}
	}

	return f.root.rule
}

func initFromConfig(filePath string) (*Filter, error) {
	const op errors.Op = "module.initFromConfig"
	lines, err := getConfigLines(filePath)
	if err != nil {
		return nil, err
	}

	rn := newRule(Default)
	f := &Filter{
		filePath: filePath,
	}
	f.root = rn

	for idx, line := range lines {
		// Ignore newline
		if len(line) == 0 {
			continue
		}
		if len(line) > 0 && line[0] == '#' {
			continue
		}

		split := strings.Split(line, " ")
		if len(split) > 3 {
			return nil, errors.E(op, "Invalid configuration found in filter file at the line "+strconv.Itoa(idx+1))
		}

		ruleSign := strings.TrimSpace(split[0])
		var rule FilterRule
		switch ruleSign {
		case "+":
			rule = Include
		case "-":
			rule = Exclude
		case "D":
			rule = Direct
		default:
			return nil, errors.E(op, "Invalid configuration found in filter file at the line "+strconv.Itoa(idx+1))
		}
		// is root config
		if len(split) == 1 {
			f.AddRule("", nil, rule)
			continue
		}
		var qual []string
		if len(split) == 3 {
			qual = strings.Split(split[2], ",")
			for i := range qual {
				qual[i] = strings.TrimRight(qual[i], "*")
				if qual[i][len(qual[i])-1] != '.' && strings.Count(qual[i], ".") < 2 {
					qual[i] += "."
				}
			}
		}

		path := strings.TrimSpace(split[1])
		f.AddRule(path, qual, rule)
	}
	return f, nil
}

// matches checks if the given version matches the given qualifier.
// Qualifiers can be:
// - plain versions.
// - v1.2.3 enables v1.2.3.
// - ~1.2.3: enables 1.2.x  which are at least 1.2.3.
// - ^1.2.3: enables 1.x.x which are at least 1.2.3.
// - <1.2.3: enables everything lower than 1.2.3 includes 1.2.2 and 0.58.9 as well.
func matches(version, qualifier string) bool {
	if len(qualifier) < 2 || len(version) < 1 {
		return false
	}

	prefix := qualifier[0]
	first := qualifier[1]

	// v1.2.3 means we accept every version starting with v1.2.3
	// handle this special case first, then go for ~v1.2.3 and similar
	if prefix == 'v' && first >= '0' && first <= '9' { // a number
		return strings.HasPrefix(version, qualifier)
	}

	v, err := getVersionSegments(version[1:])
	if err != nil {
		return false
	}

	q, err := getVersionSegments(qualifier[2:])
	if err != nil {
		return false
	}

	if len(v) != len(q) {
		return false
	}
	// no semver
	if len(v) != 3 || len(q) != 3 {
		return false
	}

	switch prefix {
	case '~':
		if v[0] == q[0] && v[1] == q[1] && v[2] >= q[2] {
			return true
		}
		return false
	case '^':
		if v[0] == q[0] && v[1] > q[1] {
			return true
		}
		if v[0] == q[0] && v[1] == q[1] && v[2] >= q[2] {
			return true
		}
		return false
	case '<':
		if v[0] < q[0] {
			return true
		}
		if v[0] == q[0] && v[1] < q[1] {
			return true
		}
		if v[0] == q[0] && v[1] == q[1] && v[2] <= q[2] {
			return true
		}
		return false
	}
	return false
}

func getPathSegments(path string) []string {
	return getSegments(path, pathSeparator)
}

func getVersionSegments(path string) ([]int, error) {
	vv := getSegments(path, versionSeparator)
	res := make([]int, len(vv))
	for i, v := range vv {
		n, err := strconv.Atoi(v)
		if err != nil {
			return nil, err
		}
		res[i] = n
	}
	return res, nil
}

func getSegments(path, separator string) []string {
	path = strings.TrimSpace(path)
	path = strings.Trim(path, separator)
	if path == "" {
		return []string{}
	}
	return strings.Split(path, separator)
}

func newRule(r FilterRule) ruleNode {
	rn := ruleNode{}
	rn.next = make(map[string]ruleNode)
	rn.rule = r
	return rn
}

func getConfigLines(filterFile string) ([]string, error) {
	const op errors.Op = "module.getConfigLines"

	f, err := os.Open(filepath.Clean(filterFile))
	if err != nil {
		return nil, errors.E(op, err)
	}

	scanner := bufio.NewScanner(f)
	var lines []string
	for scanner.Scan() {
		lines = append(lines, strings.TrimSpace(scanner.Text()))
	}

	return lines, f.Close()
}
