// Copyright 2019 The Hugo Authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package page

import (
	"errors"
	"fmt"
	"os"
	"path"
	"path/filepath"
	"regexp"
	"strconv"
	"strings"
	"time"

	"github.com/gohugoio/hugo/common/hstrings"
	"github.com/gohugoio/hugo/common/maps"
	"github.com/gohugoio/hugo/helpers"
	"github.com/gohugoio/hugo/resources/kinds"
)

// PermalinkExpander holds permalink mappings per section.
type PermalinkExpander struct {
	// knownPermalinkAttributes maps :tags in a permalink specification to a
	// function which, given a page and the tag, returns the resulting string
	// to be used to replace that tag.
	knownPermalinkAttributes map[string]pageToPermaAttribute

	expanders map[string]map[string]func(Page) (string, error)

	urlize func(uri string) string

	patternCache *maps.Cache[string, func(Page) (string, error)]
}

// Time for checking date formats. Every field is different than the
// Go reference time for date formatting. This ensures that formatting this date
// with a Go time format always has a different output than the format itself.
var referenceTime = time.Date(2019, time.November, 9, 23, 1, 42, 1, time.UTC)

// Return the callback for the given permalink attribute and a boolean indicating if the attribute is valid or not.
func (p PermalinkExpander) callback(attr string) (pageToPermaAttribute, bool) {
	if callback, ok := p.knownPermalinkAttributes[attr]; ok {
		return callback, true
	}

	if strings.HasPrefix(attr, "sections[") {
		fn := p.toSliceFunc(strings.TrimPrefix(attr, "sections"))
		return func(p Page, s string) (string, error) {
			return path.Join(fn(p.CurrentSection().SectionsEntries())...), nil
		}, true
	}

	// Make sure this comes after all the other checks.
	if referenceTime.Format(attr) != attr {
		return p.pageToPermalinkDate, true
	}

	return nil, false
}

// NewPermalinkExpander creates a new PermalinkExpander configured by the given
// urlize func.
func NewPermalinkExpander(urlize func(uri string) string, patterns map[string]map[string]string) (PermalinkExpander, error) {
	p := PermalinkExpander{
		urlize:       urlize,
		patternCache: maps.NewCache[string, func(Page) (string, error)](),
	}

	p.knownPermalinkAttributes = map[string]pageToPermaAttribute{
		"year":                  p.pageToPermalinkDate,
		"month":                 p.pageToPermalinkDate,
		"monthname":             p.pageToPermalinkDate,
		"day":                   p.pageToPermalinkDate,
		"weekday":               p.pageToPermalinkDate,
		"weekdayname":           p.pageToPermalinkDate,
		"yearday":               p.pageToPermalinkDate,
		"section":               p.pageToPermalinkSection,
		"sections":              p.pageToPermalinkSections,
		"title":                 p.pageToPermalinkTitle,
		"slug":                  p.pageToPermalinkSlugElseTitle,
		"slugorfilename":        p.pageToPermalinkSlugElseFilename,
		"filename":              p.pageToPermalinkFilename,
		"contentbasename":       p.pageToPermalinkContentBaseName,
		"slugorcontentbasename": p.pageToPermalinkSlugOrContentBaseName,
	}

	p.expanders = make(map[string]map[string]func(Page) (string, error))

	for kind, patterns := range patterns {
		e, err := p.parse(patterns)
		if err != nil {
			return p, err
		}
		p.expanders[kind] = e
	}

	return p, nil
}

// Escape sequence for colons in permalink patterns.
const escapePlaceholderColon = "\x00"

func (l PermalinkExpander) normalizeEscapeSequencesIn(s string) (string, bool) {
	s2 := strings.ReplaceAll(s, "\\:", escapePlaceholderColon)
	return s2, s2 != s
}

func (l PermalinkExpander) normalizeEscapeSequencesOut(result string) string {
	return strings.ReplaceAll(result, escapePlaceholderColon, ":")
}

// ExpandPattern expands the path in p with the specified expand pattern.
func (l PermalinkExpander) ExpandPattern(pattern string, p Page) (string, error) {
	expand, err := l.getOrParsePattern(pattern)
	if err != nil {
		return "", err
	}

	return expand(p)
}

// Expand expands the path in p according to the rules defined for the given key.
// If no rules are found for the given key, an empty string is returned.
func (l PermalinkExpander) Expand(key string, p Page) (string, error) {
	expanders, found := l.expanders[p.Kind()]
	if !found {
		return "", nil
	}

	expand, found := expanders[key]
	if !found {
		return "", nil
	}

	return expand(p)
}

// Allow " " and / to represent the root section.
var sectionCutSet = " /"

func init() {
	if string(os.PathSeparator) != "/" {
		sectionCutSet += string(os.PathSeparator)
	}
}

func (l PermalinkExpander) getOrParsePattern(pattern string) (func(Page) (string, error), error) {
	return l.patternCache.GetOrCreate(pattern, func() (func(Page) (string, error), error) {
		var normalized bool
		pattern, normalized = l.normalizeEscapeSequencesIn(pattern)

		matches := attributeRegexp.FindAllStringSubmatch(pattern, -1)
		if matches == nil {
			result := pattern
			if normalized {
				result = l.normalizeEscapeSequencesOut(result)
			}
			return func(p Page) (string, error) {
				return result, nil
			}, nil
		}

		callbacks := make([]pageToPermaAttribute, len(matches))
		replacements := make([]string, len(matches))
		for i, m := range matches {
			replacement := m[0]
			attr := replacement[1:]
			replacements[i] = replacement
			callback, ok := l.callback(attr)

			if !ok {
				return nil, &permalinkExpandError{pattern: pattern, err: errPermalinkAttributeUnknown}
			}

			callbacks[i] = callback
		}

		return func(p Page) (string, error) {
			newField := pattern

			for i, replacement := range replacements {
				attr := replacement[1:]
				callback := callbacks[i]
				newAttr, err := callback(p, attr)
				if err != nil {
					return "", &permalinkExpandError{pattern: pattern, err: err}
				}

				newField = strings.Replace(newField, replacement, newAttr, 1)
			}

			if normalized {
				newField = l.normalizeEscapeSequencesOut(newField)
			}

			return newField, nil
		}, nil
	})
}

func (l PermalinkExpander) parse(patterns map[string]string) (map[string]func(Page) (string, error), error) {
	expanders := make(map[string]func(Page) (string, error))

	for k, pattern := range patterns {
		k = strings.Trim(k, sectionCutSet)

		expander, err := l.getOrParsePattern(pattern)
		if err != nil {
			return nil, err
		}

		expanders[k] = expander
	}

	return expanders, nil
}

// pageToPermaAttribute is the type of a function which, given a page and a tag
// can return a string to go in that position in the page (or an error)
type pageToPermaAttribute func(Page, string) (string, error)

var attributeRegexp = regexp.MustCompile(`:\w+(\[.+?\])?`)

type permalinkExpandError struct {
	pattern string
	err     error
}

func (pee *permalinkExpandError) Error() string {
	return fmt.Sprintf("error expanding %q: %s", pee.pattern, pee.err)
}

var errPermalinkAttributeUnknown = errors.New("permalink attribute not recognised")

func (l PermalinkExpander) pageToPermalinkDate(p Page, dateField string) (string, error) {
	// a Page contains a Node which provides a field Date, time.Time
	switch dateField {
	case "year":
		return strconv.Itoa(p.Date().Year()), nil
	case "month":
		return fmt.Sprintf("%02d", int(p.Date().Month())), nil
	case "monthname":
		return p.Date().Month().String(), nil
	case "day":
		return fmt.Sprintf("%02d", p.Date().Day()), nil
	case "weekday":
		return strconv.Itoa(int(p.Date().Weekday())), nil
	case "weekdayname":
		return p.Date().Weekday().String(), nil
	case "yearday":
		return strconv.Itoa(p.Date().YearDay()), nil
	}

	return p.Date().Format(dateField), nil
}

// pageToPermalinkTitle returns the URL-safe form of the title
func (l PermalinkExpander) pageToPermalinkTitle(p Page, _ string) (string, error) {
	return l.urlize(p.Title()), nil
}

// pageToPermalinkFilename returns the URL-safe form of the filename
func (l PermalinkExpander) pageToPermalinkFilename(p Page, _ string) (string, error) {
	name := l.translationBaseName(p)
	if name == "index" {
		// Page bundles; the directory name will hopefully have a better name.
		dir := strings.TrimSuffix(p.File().Dir(), helpers.FilePathSeparator)
		_, name = filepath.Split(dir)
	} else if name == "_index" {
		return "", nil
	}

	return l.urlize(name), nil
}

// if the page has a slug, return the slug, else return the title
func (l PermalinkExpander) pageToPermalinkSlugElseTitle(p Page, a string) (string, error) {
	if p.Slug() != "" {
		return l.urlize(p.Slug()), nil
	}
	return l.pageToPermalinkTitle(p, a)
}

// if the page has a slug, return the slug, else return the filename
func (l PermalinkExpander) pageToPermalinkSlugElseFilename(p Page, a string) (string, error) {
	if p.Slug() != "" {
		return l.urlize(p.Slug()), nil
	}
	return l.pageToPermalinkFilename(p, a)
}

func (l PermalinkExpander) pageToPermalinkSection(p Page, _ string) (string, error) {
	return p.Section(), nil
}

func (l PermalinkExpander) pageToPermalinkSections(p Page, _ string) (string, error) {
	return p.CurrentSection().SectionsPath(), nil
}

// pageToPermalinkContentBaseName returns the URL-safe form of the content base name.
func (l PermalinkExpander) pageToPermalinkContentBaseName(p Page, _ string) (string, error) {
	return l.urlize(p.PathInfo().BaseNameNoIdentifier()), nil
}

// pageToPermalinkSlugOrContentBaseName returns the URL-safe form of the slug, content base name.
func (l PermalinkExpander) pageToPermalinkSlugOrContentBaseName(p Page, a string) (string, error) {
	if p.Slug() != "" {
		return l.urlize(p.Slug()), nil
	}
	name, err := l.pageToPermalinkContentBaseName(p, a)
	if err != nil {
		return "", nil
	}
	return name, nil
}

func (l PermalinkExpander) translationBaseName(p Page) string {
	if p.File() == nil {
		return ""
	}
	return p.File().TranslationBaseName()
}

var (
	nilSliceFunc = func(s []string) []string {
		return nil
	}
	allSliceFunc = func(s []string) []string {
		return s
	}
)

// toSliceFunc returns a slice func that slices s according to the cut spec.
// The cut spec must be on form [low:high] (one or both can be omitted),
// also allowing single slice indices (e.g. [2]) and the special [last] keyword
// giving the last element of the slice.
// The returned function will be lenient and not panic in out of bounds situation.
//
// The current use case for this is to use parts of the sections path in permalinks.
func (l PermalinkExpander) toSliceFunc(cut string) func(s []string) []string {
	cut = strings.ToLower(strings.TrimSpace(cut))
	if cut == "" {
		return allSliceFunc
	}

	if len(cut) < 3 || (cut[0] != '[' || cut[len(cut)-1] != ']') {
		return nilSliceFunc
	}

	toNFunc := func(s string, low bool) func(ss []string) int {
		if s == "" {
			if low {
				return func(ss []string) int {
					return 0
				}
			} else {
				return func(ss []string) int {
					return len(ss)
				}
			}
		}

		if s == "last" {
			return func(ss []string) int {
				return len(ss) - 1
			}
		}

		n, _ := strconv.Atoi(s)
		if n < 0 {
			n = 0
		}
		return func(ss []string) int {
			// Prevent out of bound situations. It would not make
			// much sense to panic here.
			if n >= len(ss) {
				if low {
					return -1
				}
				return len(ss)
			}
			return n
		}
	}

	opsStr := cut[1 : len(cut)-1]
	opts := strings.Split(opsStr, ":")

	if !strings.Contains(opsStr, ":") {
		toN := toNFunc(opts[0], true)
		return func(s []string) []string {
			if len(s) == 0 {
				return nil
			}
			n := toN(s)
			if n < 0 {
				return []string{}
			}
			v := s[n]
			if v == "" {
				return nil
			}
			return []string{v}
		}
	}

	toN1, toN2 := toNFunc(opts[0], true), toNFunc(opts[1], false)

	return func(s []string) []string {
		if len(s) == 0 {
			return nil
		}
		n1, n2 := toN1(s), toN2(s)
		if n1 < 0 || n2 < 0 {
			return []string{}
		}
		return s[n1:n2]
	}
}

var permalinksKindsSupport = []string{kinds.KindPage, kinds.KindSection, kinds.KindTaxonomy, kinds.KindTerm}

// DecodePermalinksConfig decodes the permalinks configuration in the given map
func DecodePermalinksConfig(m map[string]any) (map[string]map[string]string, error) {
	permalinksConfig := make(map[string]map[string]string)

	permalinksConfig[kinds.KindPage] = make(map[string]string)
	permalinksConfig[kinds.KindSection] = make(map[string]string)
	permalinksConfig[kinds.KindTaxonomy] = make(map[string]string)
	permalinksConfig[kinds.KindTerm] = make(map[string]string)

	config := maps.CleanConfigStringMap(m)
	for k, v := range config {
		switch v := v.(type) {
		case string:
			// [permalinks]
			//   key = '...'

			// To successfully be backward compatible, "default" patterns need to be set for both page and term
			permalinksConfig[kinds.KindPage][k] = v
			permalinksConfig[kinds.KindTerm][k] = v

		case maps.Params:
			// [permalinks.key]
			//   xyz = ???

			if hstrings.InSlice(permalinksKindsSupport, k) {
				// TODO: warn if we overwrite an already set value
				for k2, v2 := range v {
					switch v2 := v2.(type) {
					case string:
						permalinksConfig[k][k2] = v2

					default:
						return nil, fmt.Errorf("permalinks configuration invalid: unknown value %q for key %q for kind %q", v2, k2, k)
					}
				}
			} else {
				return nil, fmt.Errorf("permalinks configuration not supported for kind %q, supported kinds are %v", k, permalinksKindsSupport)
			}

		default:
			return nil, fmt.Errorf("permalinks configuration invalid: unknown value %q for key %q", v, k)
		}
	}
	return permalinksConfig, nil
}
