// 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 hugofs

import (
	iofs "io/fs"
	"os"
	"path"
	"runtime"
	"sort"

	"github.com/gohugoio/hugo/common/herrors"
	"github.com/gohugoio/hugo/common/paths"
	"github.com/gohugoio/hugo/hugofs/files"
	"github.com/spf13/afero"
	"golang.org/x/text/unicode/norm"
)

// NewComponentFs creates a new component filesystem.
func NewComponentFs(opts ComponentFsOptions) *componentFs {
	if opts.Component == "" {
		panic("ComponentFsOptions.PathParser.Component must be set")
	}
	if opts.Fs == nil {
		panic("ComponentFsOptions.Fs must be set")
	}
	bfs := NewBasePathFs(opts.Fs, opts.Component)
	return &componentFs{Fs: bfs, opts: opts}
}

var _ FilesystemUnwrapper = (*componentFs)(nil)

// componentFs is a filesystem that holds one of the Hugo components, e.g. content, layouts etc.
type componentFs struct {
	afero.Fs

	opts ComponentFsOptions
}

func (fs *componentFs) UnwrapFilesystem() afero.Fs {
	return fs.Fs
}

type componentFsDir struct {
	*noOpRegularFileOps
	DirOnlyOps
	name string // the name passed to Open
	fs   *componentFs
}

// ReadDir reads count entries from this virtual directory and
// sorts the entries according to the component filesystem rules.
func (f *componentFsDir) ReadDir(count int) ([]iofs.DirEntry, error) {
	fis, err := f.DirOnlyOps.(iofs.ReadDirFile).ReadDir(-1)
	if err != nil {
		return nil, err
	}

	// Filter out any symlinks.
	n := 0
	for _, fi := range fis {
		// IsDir will always be false for symlinks.
		keep := fi.IsDir()
		if !keep {
			// This is unfortunate, but is the only way to determine if it is a symlink.
			info, err := fi.Info()
			if err != nil {
				if herrors.IsNotExist(err) {
					continue
				}
				return nil, err
			}
			if info.Mode()&os.ModeSymlink == 0 {
				keep = true
			}
		}
		if keep {
			fis[n] = fi
			n++
		}
	}

	fis = fis[:n]

	n = 0
	for _, fi := range fis {
		s := path.Join(f.name, fi.Name())
		if _, ok := f.fs.applyMeta(fi, s); ok {
			fis[n] = fi
			n++
		}
	}
	fis = fis[:n]

	sort.Slice(fis, func(i, j int) bool {
		fimi, fimj := fis[i].(FileMetaInfo), fis[j].(FileMetaInfo)
		if fimi.IsDir() != fimj.IsDir() {
			return fimi.IsDir()
		}
		fimim, fimjm := fimi.Meta(), fimj.Meta()

		if fimim.ModuleOrdinal != fimjm.ModuleOrdinal {
			switch f.fs.opts.Component {
			case files.ComponentFolderI18n:
				// The way the language files gets loaded means that
				// we need to provide the least important files first (e.g. the theme files).
				return fimim.ModuleOrdinal > fimjm.ModuleOrdinal
			default:
				return fimim.ModuleOrdinal < fimjm.ModuleOrdinal
			}
		}

		pii, pij := fimim.PathInfo, fimjm.PathInfo
		if pii != nil {
			basei, basej := pii.Base(), pij.Base()
			exti, extj := pii.Ext(), pij.Ext()
			if f.fs.opts.Component == files.ComponentFolderContent {
				// Pull bundles to the top.
				if pii.IsBundle() != pij.IsBundle() {
					return pii.IsBundle()
				}
			}

			if exti != extj {
				// This pulls .md above .html.
				return exti > extj
			}

			if basei != basej {
				return basei < basej
			}
		}

		if fimim.Weight != fimjm.Weight {
			return fimim.Weight > fimjm.Weight
		}

		return fimi.Name() < fimj.Name()
	})

	return fis, nil
}

func (f *componentFsDir) Stat() (iofs.FileInfo, error) {
	fi, err := f.DirOnlyOps.Stat()
	if err != nil {
		return nil, err
	}
	fim, _ := f.fs.applyMeta(fi, f.name)
	return fim, nil
}

func (fs *componentFs) Stat(name string) (os.FileInfo, error) {
	fi, err := fs.Fs.Stat(name)
	if err != nil {
		return nil, err
	}
	fim, _ := fs.applyMeta(fi, name)
	return fim, nil
}

func (fs *componentFs) applyMeta(fi FileNameIsDir, name string) (FileMetaInfo, bool) {
	if runtime.GOOS == "darwin" {
		name = norm.NFC.String(name)
	}
	fim := fi.(FileMetaInfo)
	meta := fim.Meta()
	pi := fs.opts.PathParser.Parse(fs.opts.Component, name)
	if pi.Disabled() {
		return fim, false
	}
	if meta.Lang != "" {
		if isLangDisabled := fs.opts.PathParser.IsLangDisabled; isLangDisabled != nil && isLangDisabled(meta.Lang) {
			return fim, false
		}
	}
	meta.PathInfo = pi
	if !fim.IsDir() {
		if fileLang := meta.PathInfo.Lang(); fileLang != "" {
			// A valid lang set in filename.
			// Give priority to myfile.sv.txt inside the sv filesystem.
			meta.Weight++
			meta.Lang = fileLang
		}
	}

	if meta.Lang == "" {
		meta.Lang = fs.opts.DefaultContentLanguage
	}

	langIdx, found := fs.opts.PathParser.LanguageIndex[meta.Lang]
	if !found {
		panic("no language found for " + meta.Lang)
	}
	meta.LangIndex = langIdx

	if fi.IsDir() {
		meta.OpenFunc = func() (afero.File, error) {
			return fs.Open(name)
		}
	}

	return fim, true
}

func (f *componentFsDir) Readdir(count int) ([]os.FileInfo, error) {
	panic("not supported: Use ReadDir")
}

func (f *componentFsDir) Readdirnames(count int) ([]string, error) {
	dirsi, err := f.DirOnlyOps.(iofs.ReadDirFile).ReadDir(count)
	if err != nil {
		return nil, err
	}

	dirs := make([]string, len(dirsi))
	for i, d := range dirsi {
		dirs[i] = d.Name()
	}
	return dirs, nil
}

type ComponentFsOptions struct {
	// The filesystem where one or more components are mounted.
	Fs afero.Fs

	// The component name, e.g. "content", "layouts" etc.
	Component string

	DefaultContentLanguage string

	// The parser used to parse paths provided by this filesystem.
	PathParser *paths.PathParser
}

func (fs *componentFs) Open(name string) (afero.File, error) {
	f, err := fs.Fs.Open(name)
	if err != nil {
		return nil, err
	}

	fi, err := f.Stat()
	if err != nil {
		if err != errIsDir {
			f.Close()
			return nil, err
		}
	} else if !fi.IsDir() {
		return f, nil
	}

	return &componentFsDir{
		DirOnlyOps: f,
		name:       name,
		fs:         fs,
	}, nil
}

func (fs *componentFs) ReadDir(name string) ([]os.FileInfo, error) {
	panic("not implemented")
}