// Copyright 2024 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 pagemeta

import (
	"errors"
	"fmt"
	"path"
	"strings"
	"time"

	"github.com/gohugoio/hugo/common/hreflect"
	"github.com/gohugoio/hugo/common/hstrings"
	"github.com/gohugoio/hugo/common/htime"
	"github.com/gohugoio/hugo/common/hugio"
	"github.com/gohugoio/hugo/common/loggers"
	"github.com/gohugoio/hugo/common/maps"
	"github.com/gohugoio/hugo/common/paths"
	"github.com/gohugoio/hugo/hugofs"
	"github.com/gohugoio/hugo/hugofs/files"
	"github.com/gohugoio/hugo/hugolib/sitesmatrix"
	"github.com/gohugoio/hugo/markup"
	"github.com/gohugoio/hugo/media"
	"github.com/gohugoio/hugo/output"
	"github.com/gohugoio/hugo/resources/kinds"
	"github.com/gohugoio/hugo/resources/page"
	"github.com/gohugoio/hugo/resources/resource"
	"github.com/mitchellh/mapstructure"

	"github.com/gohugoio/hugo/config"
	"github.com/spf13/cast"
)

type DatesStrings struct {
	Date        string `json:"date"`
	Lastmod     string `json:"lastMod"`
	PublishDate string `json:"publishDate"`
	ExpiryDate  string `json:"expiryDate"`
}

type Dates struct {
	Date        time.Time
	Lastmod     time.Time
	PublishDate time.Time
	ExpiryDate  time.Time
}

func (d Dates) String() string {
	fmtDate := func(t time.Time) string {
		if t.IsZero() {
			return "<zero>"
		}
		return t.Format(time.RFC3339)
	}
	return fmt.Sprintf("Date: %s, Lastmod: %s, PublishDate: %s, ExpiryDate: %s",
		fmtDate(d.Date), fmtDate(d.Lastmod), fmtDate(d.PublishDate), fmtDate(d.ExpiryDate))
}

func (d Dates) IsDateOrLastModAfter(in Dates) bool {
	return d.Date.After(in.Date) || d.Lastmod.After(in.Lastmod)
}

func (d *Dates) UpdateDateAndLastmodAndPublishDateIfAfter(in Dates) {
	if in.Date.After(d.Date) {
		d.Date = in.Date
	}
	if in.Lastmod.After(d.Lastmod) {
		d.Lastmod = in.Lastmod
	}

	if in.PublishDate.After(d.PublishDate) && in.PublishDate.Before(htime.Now()) {
		d.PublishDate = in.PublishDate
	}
}

func (d Dates) IsAllDatesZero() bool {
	return d.Date.IsZero() && d.Lastmod.IsZero() && d.PublishDate.IsZero() && d.ExpiryDate.IsZero()
}

const (
	pageMetaKeySites  = "sites"
	pageMetaKeyMarkup = "markup"
)

func (pcfg *PageConfigEarly) SetMetaPreFromMap(ext string, frontmatter map[string]any, logger loggers.Logger, conf config.AllProvider) error {
	if frontmatter != nil {
		if err := pcfg.setFromFrontMatter(frontmatter); err != nil {
			return err
		}
	}
	return pcfg.resolveContentType(ext, conf.GetConfigSection("mediaTypes").(media.Types))
}

func (pcfg *PageConfigEarly) setFromFrontMatter(frontmatter map[string]any) error {
	// Needed for case insensitive fetching of params values.
	maps.PrepareParams(frontmatter)
	pcfg.Frontmatter = frontmatter

	if v, found := frontmatter[pageMetaKeyMarkup]; found {
		pcfg.Content.Markup = cast.ToString(v)
	}

	if v, found := frontmatter[pageMetaKeySites]; found {
		if err := mapstructure.WeakDecode(v, &pcfg.Sites); err != nil {
			return fmt.Errorf("failed to decode sites from front matter: %w", err)
		}
	}
	return nil
}

func (p *PageConfigEarly) setCascadeEarlyValueIfNotSet(key string, value any) (done bool) {
	switch key {
	case pageMetaKeySites:
		p.Sites.SetFromParamsIfNotSet(value.(maps.Params))
	}

	return !p.Sites.Matrix.IsZero()
}

// Page config that needs to be set early.
type PageConfigEarly struct {
	Kind            string // The kind of page, e.g. "page", "section", "home" etc. This is usually derived from the content path.
	Path            string // The canonical path to the page, e.g. /sect/mypage. Note: Leading slash, no trailing slash, no extensions or language identifiers.
	SourceEntryHash uint64 // The source entry hash for content adapters.

	Sites   sitesmatrix.Sites
	Content Source // Content holds the content for this page.

	Frontmatter maps.Params `mapstructure:"-" json:"-"` // The original front matter or content adapter map.

	// Compiled values.
	SitesMatrixAndComplements `mapstructure:"-" json:"-"`
	IsFromContentAdapter      bool       `mapstructure:"-" json:"-"`
	ContentMediaType          media.Type `mapstructure:"-" json:"-"`
}

// PageConfig configures a Page, typically from front matter.
// Note that all the top level fields are reserved Hugo keywords.
// Any custom configuration needs to be set in the Params map.
type PageConfigLate struct {
	Dates Dates `json:"-"` // Dates holds the four core dates for this page.
	DatesStrings
	Params maps.Params // User defined params.

	Title          string   // The title of the page.
	LinkTitle      string   // The link title of the page.
	Type           string   // The content type of the page.
	Layout         string   // The layout to use for to render this page.
	Weight         int      // The weight of the page, used in sorting if set to a non-zero value.
	URL            string   // The URL to the rendered page, e.g. /sect/mypage.html.
	Slug           string   // The slug for this page.
	Description    string   // The description for this page.
	Summary        string   // The summary for this page.
	Draft          bool     // Whether or not the content is a draft.
	Headless       bool     `json:"-"` // Whether or not the page should be rendered.
	IsCJKLanguage  bool     // Whether or not the content is in a CJK language.
	TranslationKey string   // The translation key for this page.
	Keywords       []string // The keywords for this page.
	Aliases        []string // The aliases for this page.
	Outputs        []string // The output formats to render this page in. If not set, the site's configured output formats for this page kind will be used.

	FrontMatterOnlyValues `mapstructure:"-" json:"-"`

	Sitemap config.SitemapConfig
	Build   BuildConfig
	Menus   any // Can be a string, []string or map[string]any.

	// Set only for pages created from data files.
	ContentAdapterData map[string]any `mapstructure:"-" json:"-"`

	// Compiled values.
	ConfiguredOutputFormats output.Formats `mapstructure:"-" json:"-"`
}

// SitesMatrixAndComplements holds a sites matrix and a sites complements configuration.
type SitesMatrixAndComplements struct {
	SitesMatrix      sitesmatrix.VectorStore `mapstructure:"-" json:"-"`
	SitesComplements sitesmatrix.VectorStore `mapstructure:"-" json:"-"`
}

// MatchSiteVector checks whether the site vector matches the sites matrix.
func (p *SitesMatrixAndComplements) MatchSiteVector(siteVector sitesmatrix.Vector) bool {
	return p.SitesMatrix.HasAnyVector(siteVector)
}

// MatchLanguageCoarse checks whether the language dimension matches either
// the sites matrix or the sites complements.
func (p *SitesMatrixAndComplements) MatchLanguageCoarse(siteVector sitesmatrix.Vector) bool {
	i := siteVector.Language()
	return p.SitesMatrix.HasLanguage(i) || p.SitesComplements.HasLanguage(i)
}

// MatchRoleCoarse checks whether the role dimension matches either
// the sites matrix or the sites complements.
func (p *SitesMatrixAndComplements) MatchRoleCoarse(siteVector sitesmatrix.Vector) bool {
	i := siteVector.Role()
	return p.SitesMatrix.HasRole(i) || p.SitesComplements.HasRole(i)
}

// MatchVersionCoarse checks whether the version dimension matches either
// the sites matrix or the sites complements.
func (p *SitesMatrixAndComplements) MatchVersionCoarse(siteVector sitesmatrix.Vector) bool {
	i := siteVector.Version()
	return p.SitesMatrix.HasVersion(i) || p.SitesComplements.HasVersion(i)
}

func DefaultPageConfig() *PageConfigLate {
	return &PageConfigLate{
		Build: DefaultBuildConfig,
	}
}

func (p *PageConfigEarly) Init(pagesFromData bool) error {
	if pagesFromData {
		p.Path = strings.TrimPrefix(p.Path, "/")

		if p.Path == "" && p.Kind != kinds.KindHome {
			return fmt.Errorf("empty path is reserved for the home page")
		}

		if p.Content.Markup != "" {
			return errors.New("markup must not be set, use mediaType")
		}
	}

	return nil
}

func (p *PageConfigLate) Init() error {
	return nil
}

func buildSitesComplementsFromSitesConfig(
	conf config.AllProvider,
	fim *hugofs.FileMeta,
	sitesConfig sitesmatrix.Sites,
) sitesmatrix.VectorStore {
	if sitesConfig.Complements.IsZero() {
		if fim != nil && fim.SitesComplements != nil {
			return fim.SitesComplements
		}
		return sitesmatrix.NilStore
	}
	intsetsCfg := sitesmatrix.IntSetsConfig{
		Globs: sitesConfig.Complements,
	}

	sitesComplements := sitesmatrix.NewIntSetsBuilder(conf.ConfiguredDimensions()).WithConfig(intsetsCfg)

	if fim != nil && fim.SitesComplements != nil {
		sitesComplements.WithDimensionsFromOtherIfNotSet(fim.SitesComplements)
	}

	return sitesComplements.Build()
}

func buildSitesMatrixFromSitesConfig(
	conf config.AllProvider,
	sitesMatrixBase sitesmatrix.VectorIterator,
	sitesConfig sitesmatrix.Sites,
) sitesmatrix.VectorStore {
	if sitesConfig.Matrix.IsZero() && sitesMatrixBase != nil {
		if sitesMatrixBase.LenVectors() == 1 {
			return conf.ConfiguredDimensions().GetOrCreateSingleVectorStore(sitesMatrixBase.VectorSample())
		}
		return sitesmatrix.VectorIteratorToStore(sitesMatrixBase)
	}

	if conf.ConfiguredDimensions().IsSingleVector() {
		return conf.ConfiguredDimensions().CommonSitesMatrix.DefaultSite
	}

	intsetsCfg := sitesmatrix.IntSetsConfig{
		Globs: sitesConfig.Matrix,
	}
	sitesMatrixPage := sitesmatrix.NewIntSetsBuilder(conf.ConfiguredDimensions()).WithConfig(intsetsCfg)

	if sitesMatrixBase != nil {
		sitesMatrixPage.WithDimensionsFromOtherIfNotSet(sitesMatrixBase)
	}
	sitesMatrixPage.WithDefaultsIfNotSet()

	matrix := sitesMatrixPage.Build()

	return matrix
}

func (p *PageConfigEarly) CompileEarly(pi *paths.Path, cascades *page.PageMatcherParamsConfigs,
	conf config.AllProvider, fim *hugofs.FileMeta, sitesMatrixBase sitesmatrix.VectorIterator, sitesMatrixBaseOnly bool,
) error {
	// First apply the cascades with no site filtering.
	for cascade := range cascades.All() {
		if cascade.Target.SitesMatrixCompiled != nil {
			continue
		}

		if !cascade.Target.Match(p.Kind, pi.Base(), conf.Environment(), p.SitesMatrix) {
			continue
		}

		for ck, cv := range cascade.Fields {
			if done := p.setCascadeEarlyValueIfNotSet(ck, cv); done {
				break
			}
		}

	}

	if sitesMatrixBaseOnly {
		p.SitesMatrix = sitesmatrix.VectorIteratorToStore(sitesMatrixBase)
	} else {
		p.SitesMatrix = buildSitesMatrixFromSitesConfig(
			conf,
			sitesMatrixBase,
			p.Sites,
		)
	}

	// Finally apply the cascades with site filtering.
	// These may also contain site specific settings,
	// so preserve the original matrix for comparison.
	sitesMatrixBefore := p.Sites.Matrix
	var hadCascadeMatch bool

	for cascade := range cascades.All() {
		if cascade.Target.SitesMatrixCompiled == nil {
			continue
		}

		if !cascade.Target.Match(p.Kind, pi.Base(), conf.Environment(), p.SitesMatrix) {
			continue
		}

		hadCascadeMatch = true

		for ck, cv := range cascade.Fields {
			if done := p.setCascadeEarlyValueIfNotSet(ck, cv); done {
				break
			}
		}

	}

	if hadCascadeMatch && !sitesMatrixBaseOnly && !sitesMatrixBefore.Equal(p.Sites.Matrix) {
		// Matrix has changed, rebuild.
		p.SitesMatrix = buildSitesMatrixFromSitesConfig(
			conf,
			sitesMatrixBase,
			p.Sites,
		)
	}

	p.SitesComplements = buildSitesComplementsFromSitesConfig(
		conf,
		fim,
		p.Sites,
	)

	mediaTypes := conf.GetConfigSection("mediaTypes").(media.Types)
	if err := p.resolveContentType(pi.Ext(), mediaTypes); err != nil {
		return err
	}

	return nil
}

func (p *PageConfigEarly) CompileForPagesFromDataPre(basePath string, logger loggers.Logger, mediaTypes media.Types) error {
	// In content adapters, we always get relative paths.
	if basePath != "" {
		p.Path = path.Join(basePath, p.Path)
	}

	if p.Kind == "" {
		p.Kind = kinds.KindPage
	}

	// Note that NormalizePathStringBasic will make sure that we don't preserve the unnormalized path.
	// We do that when we create pages from the file system; mostly for backward compatibility,
	// but also because people tend to use use the filename to name their resources (with spaces and all),
	// and this isn't relevant when creating resources from an API where it's easy to add textual meta data.
	p.Path = paths.NormalizePathStringBasic(p.Path)

	return p.resolveContentType("", mediaTypes)
}

func (p *PageConfigEarly) resolveContentType(ext string, mediaTypes media.Types) error {
	if p.Content.Markup == "" && p.Content.MediaType == "" {
		if ext == "" {
			ext = "md"
		}
		p.ContentMediaType = MarkupToMediaType(ext, mediaTypes)
		if p.ContentMediaType.IsZero() {
			return fmt.Errorf("failed to resolve media type for suffix %q", ext)
		}
	}

	var s string
	if p.ContentMediaType.IsZero() {
		if p.Content.MediaType != "" {
			s = p.Content.MediaType
			p.ContentMediaType, _ = mediaTypes.GetByType(s)
		}

		if p.ContentMediaType.IsZero() && p.Content.Markup != "" {
			s = p.Content.Markup
			p.ContentMediaType = MarkupToMediaType(s, mediaTypes)
		}
	}

	if p.ContentMediaType.IsZero() {
		return fmt.Errorf("failed to resolve media type for %q", s)
	}

	if p.Content.Markup == "" {
		p.Content.Markup = p.ContentMediaType.SubType
	}
	return nil
}

// Compile sets up the page configuration after all fields have been set.
func (p *PageConfigLate) Compile(e *PageConfigEarly, logger loggers.Logger, outputFormats output.Formats) error {
	if e.IsFromContentAdapter {
		if err := mapstructure.WeakDecode(p.ContentAdapterData, p); err != nil {
			err = fmt.Errorf("failed to decode page map: %w", err)
			return err
		}
	}

	if p.Params == nil {
		p.Params = make(maps.Params)
	} else {
		maps.PrepareParams(p.Params)
	}

	if len(p.Outputs) > 0 {
		outFormats, err := outputFormats.GetByNames(p.Outputs...)
		if err != nil {
			return fmt.Errorf("failed to resolve output formats %v: %w", p.Outputs, err)
		} else {
			p.ConfiguredOutputFormats = outFormats
		}
	}

	return nil
}

// MarkupToMediaType converts a markup string to a media type.
func MarkupToMediaType(s string, mediaTypes media.Types) media.Type {
	s = strings.ToLower(s)
	mt, _ := mediaTypes.GetBestMatch(markup.ResolveMarkup(s))
	return mt
}

type ResourceConfig struct {
	Path    string
	Name    string
	Title   string
	Params  maps.Params
	Content Source
	Sites   sitesmatrix.Sites

	// Compiled values.
	ContentAdapterSourceEntryHash uint64      `mapstructure:"-" json:"-"`
	PathInfo                      *paths.Path `mapstructure:"-" json:"-"`
	ContentMediaType              media.Type  `mapstructure:"-" json:"-"`
	SitesMatrixAndComplements     `mapstructure:"-" json:"-"`
}

func (rc *ResourceConfig) Validate() error {
	if rc.Content.Markup != "" {
		return errors.New("markup must not be set, use mediaType")
	}
	return nil
}

func (rc *ResourceConfig) Compile(basePath string, fim hugofs.FileMetaInfo, conf config.AllProvider, mediaTypes media.Types) error {
	if rc.Params != nil {
		maps.PrepareParams(rc.Params)
	}

	// Note that NormalizePathStringBasic will make sure that we don't preserve the unnormalized path.
	// We do that when we create resources from the file system; mostly for backward compatibility,
	// but also because people tend to use use the filename to name their resources (with spaces and all),
	// and this isn't relevant when creating resources from an API where it's easy to add textual meta data.
	rc.Path = paths.NormalizePathStringBasic(path.Join(basePath, rc.Path))
	rc.PathInfo = conf.PathParser().Parse(files.ComponentFolderContent, rc.Path)
	if rc.Content.MediaType != "" {
		var found bool
		rc.ContentMediaType, found = mediaTypes.GetByType(rc.Content.MediaType)
		if !found {
			return fmt.Errorf("media type %q not found", rc.Content.MediaType)
		}
	}

	var sitesMatrixFile sitesmatrix.VectorStore
	if fim != nil {
		sitesMatrixFile = fim.Meta().SitesMatrix
	}

	rc.SitesMatrix = buildSitesMatrixFromSitesConfig(
		conf,
		sitesMatrixFile,
		rc.Sites,
	)

	rc.SitesComplements = buildSitesComplementsFromSitesConfig(
		conf,
		fim.Meta(),
		rc.Sites,
	)

	return nil
}

type Source struct {
	// MediaType is the media type of the content.
	MediaType string

	// The markup used in Value. Only used in front matter.
	Markup string

	// The content.
	Value any
}

func (s Source) IsZero() bool {
	return !hreflect.IsTruthful(s.Value)
}

func (s Source) IsResourceValue() bool {
	_, ok := s.Value.(resource.Resource)
	return ok
}

func (s Source) ValueAsString() string {
	if s.Value == nil {
		return ""
	}
	ss, err := cast.ToStringE(s.Value)
	if err != nil {
		panic(fmt.Errorf("content source: failed to convert %T to string: %s", s.Value, err))
	}
	return ss
}

func (s Source) ValueAsOpenReadSeekCloser() hugio.OpenReadSeekCloser {
	return hugio.NewOpenReadSeekCloser(hugio.NewReadSeekerNoOpCloserFromString(s.ValueAsString()))
}

// FrontMatterOnlyValues holds values that can only be set via front matter.
type FrontMatterOnlyValues struct {
	ResourcesMeta []map[string]any
}

// FrontMatterHandler maps front matter into Page fields and .Params.
// Note that we currently have only extracted the date logic.
type FrontMatterHandler struct {
	fmConfig FrontmatterConfig

	contentAdapterDatesHandler func(d *FrontMatterDescriptor) error

	dateHandler        frontMatterFieldHandler
	lastModHandler     frontMatterFieldHandler
	publishDateHandler frontMatterFieldHandler
	expiryDateHandler  frontMatterFieldHandler

	// A map of all date keys configured, including any custom.
	allDateKeys map[string]bool

	logger loggers.Logger
}

// FrontMatterDescriptor describes how to handle front matter for a given Page.
// It has pointers to values in the receiving page which gets updated.
type FrontMatterDescriptor struct {
	// This is the Page's base filename (BaseFilename), e.g. page.md., or
	// if page is a leaf bundle, the bundle folder name (ContentBaseName).
	BaseFilename string

	// The Page's path if the page is backed by a file, else its title.
	PathOrTitle string

	// The content file's mod time.
	ModTime time.Time

	// May be set from the author date in Git.
	GitAuthorDate time.Time

	PageConfigEarly *PageConfigEarly

	// The below will be modified.
	PageConfigLate *PageConfigLate
	Location       *time.Location // The Location to use to parse dates without time zone info.
}

var dateFieldAliases = map[string][]string{
	fmDate:       {},
	fmLastmod:    {"modified"},
	fmPubDate:    {"pubdate", "published"},
	fmExpiryDate: {"unpublishdate"},
}

// HandleDates updates all the dates given the current configuration and the
// supplied front matter params. Note that this requires all lower-case keys
// in the params map.
func (f FrontMatterHandler) HandleDates(d *FrontMatterDescriptor) error {
	if d.PageConfigLate == nil {
		panic("missing pageConfig")
	}

	if d.PageConfigEarly.IsFromContentAdapter {
		if f.contentAdapterDatesHandler == nil {
			panic("missing content adapter date handler")
		}
		return f.contentAdapterDatesHandler(d)
	}

	if f.dateHandler == nil {
		panic("missing date handler")
	}

	if _, err := f.dateHandler(d); err != nil {
		return err
	}

	if _, err := f.lastModHandler(d); err != nil {
		return err
	}

	if _, err := f.publishDateHandler(d); err != nil {
		return err
	}

	if _, err := f.expiryDateHandler(d); err != nil {
		return err
	}

	return nil
}

// IsDateKey returns whether the given front matter key is considered a date by the current
// configuration.
func (f FrontMatterHandler) IsDateKey(key string) bool {
	return f.allDateKeys[key]
}

// dateAndSlugFromBaseFilename returns a time.Time value (resolved to the
// default system location) and a slug, extracted by parsing the provided path.
// Parsing supports YYYY-MM-DD-HH-MM-SS and YYYY-MM-DD date/time formats.
// Within the YYYY-MM-DD-HH-MM-SS format, the date and time values may be
// separated by any character including a space (e.g., YYYY-MM-DD HH-MM-SS).
func dateAndSlugFromBaseFilename(location *time.Location, path string) (time.Time, string) {
	base, _ := paths.FileAndExt(path)

	if len(base) < 10 {
		// Not long enough to start with a YYYY-MM-DD date.
		return time.Time{}, ""
	}

	// Delimiters allowed between the date and the slug.
	delimiters := " -_"

	if len(base) >= 19 {
		// Attempt to parse a YYYY-MM-DD-HH-MM-SS date-time prefix.
		ds := base[:10]
		ts := strings.ReplaceAll(base[11:19], "-", ":")

		d, err := htime.ToTimeInDefaultLocationE(ds+"T"+ts, location)
		if err == nil {
			return d, strings.Trim(base[19:], delimiters)
		}
	}

	// Attempt to parse a YYYY-MM-DD date prefix.
	ds := base[:10]

	d, err := htime.ToTimeInDefaultLocationE(ds, location)
	if err == nil {
		return d, strings.Trim(base[10:], delimiters)
	}

	// If no date is defined, return the zero time instant.
	return time.Time{}, ""
}

type frontMatterFieldHandler func(d *FrontMatterDescriptor) (bool, error)

func (f FrontMatterHandler) newChainedFrontMatterFieldHandler(handlers ...frontMatterFieldHandler) frontMatterFieldHandler {
	return func(d *FrontMatterDescriptor) (bool, error) {
		for _, h := range handlers {
			// First successful handler wins.
			success, err := h(d)
			if err != nil {
				f.logger.Errorln(err)
			} else if success {
				return true, nil
			}
		}
		return false, nil
	}
}

type FrontmatterConfig struct {
	// Controls how the Date is set from front matter.
	Date []string
	// Controls how the Lastmod is set from front matter.
	Lastmod []string
	// Controls how the PublishDate is set from front matter.
	PublishDate []string
	// Controls how the ExpiryDate is set from front matter.
	ExpiryDate []string
}

const (
	// These are all the date handler identifiers
	// All identifiers not starting with a ":" maps to a front matter parameter.
	fmDate       = "date"
	fmPubDate    = "publishdate"
	fmLastmod    = "lastmod"
	fmExpiryDate = "expirydate"

	// Gets date from filename, e.g 218-02-22-mypage.md
	fmFilename = ":filename"

	// Gets date from file OS mod time.
	fmModTime = ":filemodtime"

	// Gets date from Git
	fmGitAuthorDate = ":git"
)

// This is the config you get when doing nothing.
func newDefaultFrontmatterConfig() FrontmatterConfig {
	return FrontmatterConfig{
		Date:        []string{fmDate, fmPubDate, fmLastmod},
		Lastmod:     []string{fmGitAuthorDate, fmLastmod, fmDate, fmPubDate},
		PublishDate: []string{fmPubDate, fmDate},
		ExpiryDate:  []string{fmExpiryDate},
	}
}

func DecodeFrontMatterConfig(cfg config.Provider) (FrontmatterConfig, error) {
	c := newDefaultFrontmatterConfig()
	defaultConfig := c

	if cfg.IsSet("frontmatter") {
		fm := cfg.GetStringMap("frontmatter")
		for k, v := range fm {
			loki := strings.ToLower(k)
			switch loki {
			case fmDate:
				c.Date = toLowerSlice(v)
			case fmPubDate:
				c.PublishDate = toLowerSlice(v)
			case fmLastmod:
				c.Lastmod = toLowerSlice(v)
			case fmExpiryDate:
				c.ExpiryDate = toLowerSlice(v)
			}
		}
	}

	expander := func(c, d []string) []string {
		out := expandDefaultValues(c, d)
		out = addDateFieldAliases(out)
		return out
	}

	c.Date = expander(c.Date, defaultConfig.Date)
	c.PublishDate = expander(c.PublishDate, defaultConfig.PublishDate)
	c.Lastmod = expander(c.Lastmod, defaultConfig.Lastmod)
	c.ExpiryDate = expander(c.ExpiryDate, defaultConfig.ExpiryDate)

	return c, nil
}

func addDateFieldAliases(values []string) []string {
	var complete []string

	for _, v := range values {
		complete = append(complete, v)
		if aliases, found := dateFieldAliases[v]; found {
			complete = append(complete, aliases...)
		}
	}
	return hstrings.UniqueStringsReuse(complete)
}

func expandDefaultValues(values []string, defaults []string) []string {
	var out []string
	for _, v := range values {
		if v == ":default" {
			out = append(out, defaults...)
		} else {
			out = append(out, v)
		}
	}
	return out
}

func toLowerSlice(in any) []string {
	out := cast.ToStringSlice(in)
	for i := range out {
		out[i] = strings.ToLower(out[i])
	}

	return out
}

// NewFrontmatterHandler creates a new FrontMatterHandler with the given logger and configuration.
// If no logger is provided, one will be created.
func NewFrontmatterHandler(logger loggers.Logger, frontMatterConfig FrontmatterConfig) (FrontMatterHandler, error) {
	if logger == nil {
		logger = loggers.NewDefault()
	}

	allDateKeys := make(map[string]bool)
	addKeys := func(vals []string) {
		for _, k := range vals {
			if !strings.HasPrefix(k, ":") {
				allDateKeys[k] = true
			}
		}
	}

	addKeys(frontMatterConfig.Date)
	addKeys(frontMatterConfig.ExpiryDate)
	addKeys(frontMatterConfig.Lastmod)
	addKeys(frontMatterConfig.PublishDate)

	f := FrontMatterHandler{logger: logger, fmConfig: frontMatterConfig, allDateKeys: allDateKeys}

	if err := f.createHandlers(); err != nil {
		return f, err
	}

	return f, nil
}

func (f *FrontMatterHandler) createHandlers() error {
	var err error

	if f.contentAdapterDatesHandler, err = f.createContentAdapterDatesHandler(f.fmConfig); err != nil {
		return err
	}

	if f.dateHandler, err = f.createDateHandler(f.fmConfig.Date,
		func(d *FrontMatterDescriptor, t time.Time) {
			d.PageConfigLate.Dates.Date = t
			setParamIfNotSet(fmDate, t, d)
		}); err != nil {
		return err
	}

	if f.lastModHandler, err = f.createDateHandler(f.fmConfig.Lastmod,
		func(d *FrontMatterDescriptor, t time.Time) {
			setParamIfNotSet(fmLastmod, t, d)
			d.PageConfigLate.Dates.Lastmod = t
		}); err != nil {
		return err
	}

	if f.publishDateHandler, err = f.createDateHandler(f.fmConfig.PublishDate,
		func(d *FrontMatterDescriptor, t time.Time) {
			setParamIfNotSet(fmPubDate, t, d)
			d.PageConfigLate.Dates.PublishDate = t
		}); err != nil {
		return err
	}

	if f.expiryDateHandler, err = f.createDateHandler(f.fmConfig.ExpiryDate,
		func(d *FrontMatterDescriptor, t time.Time) {
			setParamIfNotSet(fmExpiryDate, t, d)
			d.PageConfigLate.Dates.ExpiryDate = t
		}); err != nil {
		return err
	}

	return nil
}

func setParamIfNotSet(key string, value any, d *FrontMatterDescriptor) {
	if _, found := d.PageConfigLate.Params[key]; found {
		return
	}
	d.PageConfigLate.Params[key] = value
}

func (f FrontMatterHandler) createContentAdapterDatesHandler(fmcfg FrontmatterConfig) (func(d *FrontMatterDescriptor) error, error) {
	setTime := func(key string, value time.Time, in *PageConfigLate) {
		switch key {
		case fmDate:
			in.Dates.Date = value
		case fmLastmod:
			in.Dates.Lastmod = value
		case fmPubDate:
			in.Dates.PublishDate = value
		case fmExpiryDate:
			in.Dates.ExpiryDate = value
		}
	}

	getTime := func(key string, in *PageConfigLate) time.Time {
		switch key {
		case fmDate:
			return in.Dates.Date
		case fmLastmod:
			return in.Dates.Lastmod
		case fmPubDate:
			return in.Dates.PublishDate
		case fmExpiryDate:
			return in.Dates.ExpiryDate
		}
		return time.Time{}
	}

	createSetter := func(identifiers []string, date string) func(pcfg *PageConfigLate) {
		var getTimes []func(in *PageConfigLate) time.Time
		for _, identifier := range identifiers {
			if strings.HasPrefix(identifier, ":") {
				continue
			}
			switch identifier {
			case fmDate:
				getTimes = append(getTimes, func(in *PageConfigLate) time.Time {
					return getTime(fmDate, in)
				})
			case fmLastmod:
				getTimes = append(getTimes, func(in *PageConfigLate) time.Time {
					return getTime(fmLastmod, in)
				})
			case fmPubDate:
				getTimes = append(getTimes, func(in *PageConfigLate) time.Time {
					return getTime(fmPubDate, in)
				})
			case fmExpiryDate:
				getTimes = append(getTimes, func(in *PageConfigLate) time.Time {
					return getTime(fmExpiryDate, in)
				})
			}
		}

		return func(pcfg *PageConfigLate) {
			for _, get := range getTimes {
				if t := get(pcfg); !t.IsZero() {
					setTime(date, t, pcfg)
					return
				}
			}
		}
	}

	setDate := createSetter(fmcfg.Date, fmDate)
	setLastmod := createSetter(fmcfg.Lastmod, fmLastmod)
	setPublishDate := createSetter(fmcfg.PublishDate, fmPubDate)
	setExpiryDate := createSetter(fmcfg.ExpiryDate, fmExpiryDate)

	fn := func(d *FrontMatterDescriptor) error {
		pcfg := d.PageConfigLate
		setDate(pcfg)
		setLastmod(pcfg)
		setPublishDate(pcfg)
		setExpiryDate(pcfg)
		return nil
	}
	return fn, nil
}

func (f FrontMatterHandler) createDateHandler(identifiers []string, setter func(d *FrontMatterDescriptor, t time.Time)) (frontMatterFieldHandler, error) {
	var h *frontmatterFieldHandlers
	var handlers []frontMatterFieldHandler

	for _, identifier := range identifiers {
		switch identifier {
		case fmFilename:
			handlers = append(handlers, h.newDateFilenameHandler(setter))
		case fmModTime:
			handlers = append(handlers, h.newDateModTimeHandler(setter))
		case fmGitAuthorDate:
			handlers = append(handlers, h.newDateGitAuthorDateHandler(setter))
		default:
			handlers = append(handlers, h.newDateFieldHandler(identifier, setter))
		}
	}

	return f.newChainedFrontMatterFieldHandler(handlers...), nil
}

type frontmatterFieldHandlers int

func (f *frontmatterFieldHandlers) newDateFieldHandler(key string, setter func(d *FrontMatterDescriptor, t time.Time)) frontMatterFieldHandler {
	return func(d *FrontMatterDescriptor) (bool, error) {
		v, found := d.PageConfigEarly.Frontmatter[key]
		if found {
			d.PageConfigLate.Params[key] = v
		} else {
			// Reentry from previous handlers.
			v, found = d.PageConfigLate.Params[key]
		}

		if !found || v == "" || v == nil {
			return false, nil
		}

		var date time.Time
		if vt, ok := v.(time.Time); ok && vt.Location() == d.Location {
			date = vt
		} else {
			var err error
			date, err = htime.ToTimeInDefaultLocationE(v, d.Location)
			if err != nil {
				return false, fmt.Errorf("the %q front matter field is not a parsable date: see %s", key, d.PathOrTitle)
			}
			d.PageConfigLate.Params[key] = date
		}

		// We map several date keys to one, so, for example,
		// "expirydate", "unpublishdate" will all set .ExpiryDate (first found).
		setter(d, date)

		return true, nil
	}
}

func (f *frontmatterFieldHandlers) newDateFilenameHandler(setter func(d *FrontMatterDescriptor, t time.Time)) frontMatterFieldHandler {
	return func(d *FrontMatterDescriptor) (bool, error) {
		date, slug := dateAndSlugFromBaseFilename(d.Location, d.BaseFilename)
		if date.IsZero() {
			return false, nil
		}

		setter(d, date)

		if _, found := d.PageConfigEarly.Frontmatter["slug"]; !found {
			// Use slug from filename
			d.PageConfigLate.Slug = slug
		}

		return true, nil
	}
}

func (f *frontmatterFieldHandlers) newDateModTimeHandler(setter func(d *FrontMatterDescriptor, t time.Time)) frontMatterFieldHandler {
	return func(d *FrontMatterDescriptor) (bool, error) {
		if d.ModTime.IsZero() {
			return false, nil
		}
		setter(d, d.ModTime)
		return true, nil
	}
}

func (f *frontmatterFieldHandlers) newDateGitAuthorDateHandler(setter func(d *FrontMatterDescriptor, t time.Time)) frontMatterFieldHandler {
	return func(d *FrontMatterDescriptor) (bool, error) {
		if d.GitAuthorDate.IsZero() {
			return false, nil
		}
		setter(d, d.GitAuthorDate)
		return true, nil
	}
}
