// Package gitattributes provides utilities for parsing and matching gitattributes files.
package gitattributes

import (
	"bufio"
	"errors"
	"io"
	"strings"
)

const (
	commentPrefix = "#"
	eol           = "\n"
	macroPrefix   = "[attr]"
)

var (
	// ErrMacroNotAllowed is returned when a macro is used where it is not allowed.
	ErrMacroNotAllowed = errors.New("macro not allowed")
	// ErrInvalidAttributeName is returned when an invalid attribute name is used.
	ErrInvalidAttributeName = errors.New("invalid attribute name")
)

// MatchAttribute represents a gitattribute pattern match with its attributes.
type MatchAttribute struct {
	Name       string
	Pattern    Pattern
	Attributes []Attribute
}

type attributeState byte

const (
	attributeUnknown     attributeState = 0
	attributeSet         attributeState = 1
	attributeUnspecified attributeState = '!'
	attributeUnset       attributeState = '-'
	attributeSetValue    attributeState = '='
)

// Attribute represents a gitattribute.
type Attribute interface {
	Name() string
	IsSet() bool
	IsUnset() bool
	IsUnspecified() bool
	IsValueSet() bool
	Value() string
	String() string
}

type attribute struct {
	name  string
	state attributeState
	value string
}

func (a attribute) Name() string {
	return a.name
}

func (a attribute) IsSet() bool {
	return a.state == attributeSet
}

func (a attribute) IsUnset() bool {
	return a.state == attributeUnset
}

func (a attribute) IsUnspecified() bool {
	return a.state == attributeUnspecified
}

func (a attribute) IsValueSet() bool {
	return a.state == attributeSetValue
}

func (a attribute) Value() string {
	return a.value
}

func (a attribute) String() string {
	switch a.state {
	case attributeSet:
		return a.name + ": set"
	case attributeUnset:
		return a.name + ": unset"
	case attributeUnspecified:
		return a.name + ": unspecified"
	default:
		return a.name + ": " + a.value
	}
}

// ReadAttributes reads patterns and attributes from the gitattributes format.
func ReadAttributes(r io.Reader, domain []string, allowMacro bool) (attributes []MatchAttribute, err error) {
	scanner := bufio.NewScanner(r)

	for scanner.Scan() {
		attribute, err := ParseAttributesLine(scanner.Text(), domain, allowMacro)
		if err != nil {
			return attributes, err
		}
		if len(attribute.Name) == 0 {
			continue
		}

		attributes = append(attributes, attribute)
	}

	if err := scanner.Err(); err != nil {
		return attributes, err
	}

	return attributes, nil
}

// ParseAttributesLine parses a gitattribute line, extracting path pattern and
// attributes.
func ParseAttributesLine(line string, domain []string, allowMacro bool) (m MatchAttribute, err error) {
	line = strings.TrimSpace(line)

	if strings.HasPrefix(line, commentPrefix) || len(line) == 0 {
		return m, err
	}

	name, unquoted := unquote(line)
	attrs := strings.Fields(unquoted)
	if len(name) == 0 {
		name = attrs[0]
		attrs = attrs[1:]
	}

	var macro bool
	macro, name, err = checkMacro(name, allowMacro)
	if err != nil {
		return m, err
	}

	m.Name = name
	m.Attributes = make([]Attribute, 0, len(attrs))

	for _, attrName := range attrs {
		attr := attribute{
			name:  attrName,
			state: attributeSet,
		}

		// ! and - prefixes
		state := attributeState(attr.name[0])
		if state == attributeUnspecified || state == attributeUnset {
			attr.state = state
			attr.name = attr.name[1:]
		}

		kv := strings.SplitN(attrName, "=", 2)
		if len(kv) == 2 {
			attr.name = kv[0]
			attr.value = kv[1]
			attr.state = attributeSetValue
		}

		if !validAttributeName(attr.name) {
			return m, ErrInvalidAttributeName
		}
		m.Attributes = append(m.Attributes, attr)
	}

	if !macro {
		m.Pattern = ParsePattern(name, domain)
	}
	return m, err
}

func checkMacro(name string, allowMacro bool) (macro bool, macroName string, err error) {
	if !strings.HasPrefix(name, macroPrefix) {
		return false, name, nil
	}
	if !allowMacro {
		return true, name, ErrMacroNotAllowed
	}

	macroName = name[len(macroPrefix):]
	if !validAttributeName(macroName) {
		return true, name, ErrInvalidAttributeName
	}
	return true, macroName, nil
}

func validAttributeName(name string) bool {
	if len(name) == 0 || name[0] == '-' {
		return false
	}

	for _, ch := range name {
		if (ch != '-' && ch != '.' && ch != '_') &&
			(ch < '0' || ch > '9') &&
			(ch < 'a' || ch > 'z') &&
			(ch < 'A' || ch > 'Z') {
			return false
		}
	}
	return true
}

func unquote(str string) (string, string) {
	if str[0] != '"' {
		return "", str
	}

	for i := 1; i < len(str); i++ {
		switch str[i] {
		case '\\':
			i++
		case '"':
			return str[1:i], str[i+1:]
		}
	}
	return "", str
}
