package core

import (
	"fmt"
	"maps"
	"path/filepath"
	"strings"

	"github.com/bmatcuk/doublestar/v4"
	toml "github.com/pelletier/go-toml"
	"github.com/zk-org/zk/internal/util/opt"
	"github.com/zk-org/zk/internal/util/paths"
)

// Config holds the user configuration.
type Config struct {
	Notebook NotebookConfig
	Note     NoteConfig
	Groups   map[string]GroupConfig
	Format   FormatConfig
	Tool     ToolConfig
	LSP      LSPConfig
	Filters  map[string]string
	Aliases  map[string]string
	Extra    map[string]string
}

// NOTE: config generation occurs in internal/core/notebook_store.go. The below function is used
// for test cases and as a program level default if the user conf is missing or
// has values missing.

// NewDefaultConfig creates a new Config with the default settings.
func NewDefaultConfig() Config {
	return Config{
		Notebook: NotebookConfig{
			Dir: opt.NullString,
		},
		Note: NoteConfig{
			FilenameTemplate: "{{id}}",
			Extension:        "md",
			BodyTemplatePath: opt.NullString,
			Lang:             "en",
			DefaultTitle:     "Untitled",
			IDOptions: IDOptions{
				Charset: CharsetAlphanum,
				Length:  4,
				Case:    CaseLower,
			},
			Exclude: []string{},
		},
		Groups: map[string]GroupConfig{},
		Format: FormatConfig{
			Markdown: MarkdownConfig{
				Hashtags:          true,
				ColonTags:         false,
				MultiwordTags:     false,
				LinkFormat:        "markdown",
				LinkEncodePath:    true,
				LinkDropExtension: true,
			},
		},
		LSP: LSPConfig{
			Completion: LSPCompletionConfig{
				Note: LSPCompletionTemplates{
					Label:      opt.NullString,
					FilterText: opt.NullString,
					Detail:     opt.NullString,
				},
			},
			Diagnostics: LSPDiagnosticConfig{
				WikiTitle:       LSPDiagnosticNone,
				DeadLink:        LSPDiagnosticError,
				MissingBacklink: MissingBacklinkConfig{}, // Disabled by default (Level = LSPDiagnosticNone)
			},
		},
		Filters: map[string]string{},
		Aliases: map[string]string{},
		Extra:   map[string]string{},
	}
}

// RootGroupConfig returns the default GroupConfig for the root directory and its descendants.
func (c Config) RootGroupConfig() GroupConfig {
	return GroupConfig{
		Paths: []string{},
		Note:  c.Note,
		Extra: c.Extra,
	}
}

// GroupConfigForPath returns the GroupConfig for the group matching the given
// path relative to the notebook. Fallback on the root GroupConfig.
func (c Config) GroupConfigForPath(path string) (GroupConfig, error) {
	name, err := c.GroupNameForPath(path)
	if err != nil {
		return GroupConfig{}, err
	}
	return c.GroupConfigNamed(name)
}

// GroupConfigNamed returns the GroupConfig for the group with the given name.
// An empty name matches the root GroupConfig.
func (c Config) GroupConfigNamed(name string) (GroupConfig, error) {
	if name == "" {
		return c.RootGroupConfig(), nil
	} else {
		group, ok := c.Groups[name]
		if !ok {
			return GroupConfig{}, fmt.Errorf("no group named `%s` found in the config", name)
		}
		return group, nil
	}
}

// GroupNameForPath returns the name of the GroupConfig matching the given
// path, relative to the notebook.
func (c Config) GroupNameForPath(path string) (string, error) {
	var longestMatch string
	var matchedName string

	for name, config := range c.Groups {
		for _, groupPath := range config.Paths {
			matches, err := doublestar.Match(groupPath, path)
			if err != nil {
				return "", fmt.Errorf("failed to match group %s to %s: %w", name, path, err)
			} else if matches {
				// Early return if an exact match
				return name, nil
			}
			if strings.HasPrefix(path, groupPath+"/") {
				// If the match is partial, find the longest prefix overlap
				if len(groupPath) > len(longestMatch) {
					longestMatch = groupPath
					matchedName = name
				}
			}
		}
	}

	return matchedName, nil
}

// FormatConfig holds the configuration for document formats, such as Markdown.
type FormatConfig struct {
	Markdown MarkdownConfig
}

// MarkdownConfig holds the configuration for Markdown documents.
type MarkdownConfig struct {
	// Hashtags indicates whether #hashtags are supported.
	Hashtags bool
	// ColonTags indicates whether :colon:tags: are supported.
	ColonTags bool
	// MultiwordTags indicates whether #multi-word tags# are supported.
	MultiwordTags bool

	// Format used to generate links between notes.
	// Either "wiki", "markdown" or a custom template. Default is "markdown".
	LinkFormat string
	// Indicates whether a link's path will be percent-encoded.
	// Defaults to true for "markdown" format only, false otherwise.
	LinkEncodePath bool
	// Indicates whether a link's path file extension will be removed.
	LinkDropExtension bool
}

// ToolConfig holds the external tooling configuration.
type ToolConfig struct {
	Editor     opt.String
	Shell      opt.String
	Pager      opt.String
	FzfPreview opt.String
	FzfLine    opt.String
	FzfOptions opt.String
	FzfBindNew opt.String
}

// LSPConfig holds the Language Server Protocol configuration.
type LSPConfig struct {
	Completion  LSPCompletionConfig
	Diagnostics LSPDiagnosticConfig
}

// LSPCompletionConfig holds the LSP auto-completion configuration.
type LSPCompletionConfig struct {
	Note                   LSPCompletionTemplates
	UseAdditionalTextEdits opt.Bool
}

// LSPCompletionTemplates holds the LSP completion templates for a particular
// completion item type (e.g. note or tag).
type LSPCompletionTemplates struct {
	Label      opt.String
	FilterText opt.String
	Detail     opt.String
}

// LSPDiagnosticConfig holds the LSP diagnostics configuration.
type LSPDiagnosticConfig struct {
	WikiTitle       LSPDiagnosticSeverity
	DeadLink        LSPDiagnosticSeverity
	SelfLink        LSPDiagnosticSeverity
	MissingBacklink MissingBacklinkConfig
}

// IsEnabled returns true if at least one diagnostic is enabled.
func (c LSPDiagnosticConfig) IsEnabled() bool {
	return c.WikiTitle != LSPDiagnosticNone ||
		c.DeadLink != LSPDiagnosticNone ||
		c.SelfLink != LSPDiagnosticNone ||
		c.MissingBacklink.Level != LSPDiagnosticNone
}

type LSPDiagnosticSeverity int

const (
	LSPDiagnosticNone    LSPDiagnosticSeverity = 0
	LSPDiagnosticError   LSPDiagnosticSeverity = 1
	LSPDiagnosticWarning LSPDiagnosticSeverity = 2
	LSPDiagnosticInfo    LSPDiagnosticSeverity = 3
	LSPDiagnosticHint    LSPDiagnosticSeverity = 4
)

type LSPDiagnosticPosition int

const (
	LSPDiagnosticPositionTop LSPDiagnosticPosition = iota + 1
	LSPDiagnosticPositionBottom
	LSPDiagnosticPositionLastSection
)

// MissingBacklinkConfig holds the configuration for missing backlink diagnostics.
type MissingBacklinkConfig struct {
	Level    LSPDiagnosticSeverity
	Position LSPDiagnosticPosition
}

// NotebookConfig holds configuration about the default notebook
type NotebookConfig struct {
	Dir opt.String
}

// NoteConfig holds the user configuration used when generating new notes.
type NoteConfig struct {
	// Handlebars template used when generating a new filename.
	FilenameTemplate string
	// Extension appended to the filename.
	Extension string
	// Path to the handlebars template used when generating the note content.
	BodyTemplatePath opt.String
	// Language of the note content.
	Lang string
	// Default title to use when none is provided.
	DefaultTitle string
	// Settings used when generating a random ID.
	IDOptions IDOptions
	// Path globs to ignore when indexing notes.
	Exclude []string
}

// GroupConfig holds the user configuration for a given group of notes.
type GroupConfig struct {
	Paths []string
	Note  NoteConfig
	Extra map[string]string
}

// ExcludeGlobs returns all the Note.Exclude path globs for the group paths,
// relative to the root of the notebook.
func (c GroupConfig) ExcludeGlobs() []string {
	if len(c.Paths) == 0 {
		return c.Note.Exclude
	}

	globs := []string{}
	for _, p := range c.Paths {
		for _, g := range c.Note.Exclude {
			globs = append(globs, filepath.Join(p, g))
		}
	}
	return globs
}

// Clone creates a copy of the GroupConfig receiver.
func (c GroupConfig) Clone() GroupConfig {
	clone := c

	clone.Paths = make([]string, len(c.Paths))
	copy(clone.Paths, c.Paths)

	clone.Extra = make(map[string]string)
	maps.Copy(clone.Extra, c.Extra)
	return clone
}

// OpenConfig creates a new Config instance from its TOML representation stored
// in the given file.
func OpenConfig(path string, parentConfig Config, fs FileStorage, isGlobal bool) (Config, error) {
	// The local config is optional.
	exists, err := fs.FileExists(path)
	if err == nil && !exists {
		return parentConfig, nil
	}

	content, err := fs.Read(path)
	if err != nil {
		return parentConfig, fmt.Errorf("failed to open config file at %s: %w", path, err)
	}

	return ParseConfig(content, path, parentConfig, isGlobal)
}

// ParseConfig creates a new Config instance from its TOML representation.
// path is the config absolute path, from which will be derived the base path
// for templates.
//
// The parentConfig will be used to inherit default config settings.
func ParseConfig(content []byte, path string, parentConfig Config, isGlobal bool) (Config, error) {
	config := parentConfig

	var tomlConf tomlConfig
	err := toml.Unmarshal(content, &tomlConf)
	if err != nil {
		return config, fmt.Errorf("failed to read config: %w", err)
	}

	// Notebook
	notebook := tomlConf.Notebook
	if notebook.Dir != "" {
		if isGlobal {
			config.Notebook.Dir = opt.NewNotEmptyString(notebook.Dir)
		} else {
			return config, fmt.Errorf("notebook.dir should not be set on local configuration")
		}
	}

	// Note
	note := tomlConf.Note
	if note.Filename != "" {
		config.Note.FilenameTemplate = note.Filename
	}
	if note.Extension != "" {
		config.Note.Extension = note.Extension
	}
	if note.Template != "" {
		expanded, err := paths.ExpandPath(note.Template)
		if err != nil {
			return config, fmt.Errorf("failed to expand template path from config: %w", err)
		}
		config.Note.BodyTemplatePath = opt.NewNotEmptyString(expanded)
	}
	if note.IDLength != 0 {
		config.Note.IDOptions.Length = note.IDLength
	}
	if note.IDCharset != "" {
		config.Note.IDOptions.Charset = charsetFromString(note.IDCharset)
	}
	if note.IDCase != "" {
		config.Note.IDOptions.Case = caseFromString(note.IDCase)
	}
	if note.Lang != "" {
		config.Note.Lang = note.Lang
	}
	if note.DefaultTitle != "" {
		config.Note.DefaultTitle = note.DefaultTitle
	}
	config.Note.Exclude = append(config.Note.Exclude, note.Exclude...)
	config.Note.Exclude = append(config.Note.Exclude, note.Ignore...)
	if tomlConf.Extra != nil {
		maps.Copy(config.Extra, tomlConf.Extra)
	}

	// Groups
	for name, dirTOML := range tomlConf.Groups {
		parent, ok := config.Groups[name]
		if !ok {
			parent = config.RootGroupConfig()
		}

		config.Groups[name] = parent.merge(dirTOML, name)
	}

	// Format
	markdown := tomlConf.Format.Markdown
	if markdown.Hashtags != nil {
		config.Format.Markdown.Hashtags = *markdown.Hashtags
	}
	if markdown.ColonTags != nil {
		config.Format.Markdown.ColonTags = *markdown.ColonTags
	}
	if markdown.MultiwordTags != nil {
		config.Format.Markdown.MultiwordTags = *markdown.MultiwordTags
	}
	if markdown.LinkFormat != nil && *markdown.LinkFormat == "" {
		*markdown.LinkFormat = "markdown"
	}
	if markdown.LinkFormat != nil {
		config.Format.Markdown.LinkFormat = *markdown.LinkFormat
	}
	if markdown.LinkEncodePath != nil {
		config.Format.Markdown.LinkEncodePath = *markdown.LinkEncodePath
	} else if markdown.LinkFormat != nil {
		config.Format.Markdown.LinkEncodePath = (*markdown.LinkFormat == "markdown")
	}
	if markdown.LinkDropExtension != nil {
		config.Format.Markdown.LinkDropExtension = *markdown.LinkDropExtension
	}

	// Tool
	tool := tomlConf.Tool
	if tool.Editor != nil {
		config.Tool.Editor = opt.NewNotEmptyString(*tool.Editor)
	}
	if tool.Shell != nil {
		config.Tool.Shell = opt.NewNotEmptyString(*tool.Shell)
	}
	if tool.Pager != nil {
		config.Tool.Pager = opt.NewStringWithPtr(tool.Pager)
	}
	if tool.FzfPreview != nil {
		config.Tool.FzfPreview = opt.NewStringWithPtr(tool.FzfPreview)
	}
	if tool.FzfLine != nil {
		config.Tool.FzfLine = opt.NewNotEmptyString(*tool.FzfLine)
	}
	if tool.FzfOptions != nil {
		config.Tool.FzfOptions = opt.NewNotEmptyString(*tool.FzfOptions)
	}
	if tool.FzfBindNew != nil {
		config.Tool.FzfBindNew = opt.NewStringWithPtr(tool.FzfBindNew)
	}

	// LSP completion
	lspCompl := tomlConf.LSP.Completion
	if lspCompl.NoteLabel != nil {
		config.LSP.Completion.Note.Label = opt.NewNotEmptyString(*lspCompl.NoteLabel)
	}
	if lspCompl.NoteFilterText != nil {
		config.LSP.Completion.Note.FilterText = opt.NewNotEmptyString(*lspCompl.NoteFilterText)
	}
	if lspCompl.NoteDetail != nil {
		config.LSP.Completion.Note.Detail = opt.NewNotEmptyString(*lspCompl.NoteDetail)
	}
	config.LSP.Completion.UseAdditionalTextEdits = opt.NewBoolWithPtr(lspCompl.UseAdditionalTextEdits)

	// LSP diagnostics
	lspDiags := tomlConf.LSP.Diagnostics
	if lspDiags.WikiTitle != nil {
		config.LSP.Diagnostics.WikiTitle, err = lspDiagnosticSeverityFromString(*lspDiags.WikiTitle)
		if err != nil {
			return config, fmt.Errorf("failed to parse wikititle level: %w", err)
		}
	}
	if lspDiags.DeadLink != nil {
		config.LSP.Diagnostics.DeadLink, err = lspDiagnosticSeverityFromString(*lspDiags.DeadLink)
		if err != nil {
			return config, fmt.Errorf("failed to parse deadlink level: %w", err)
		}
	}
	if lspDiags.SelfLink != nil {
		config.LSP.Diagnostics.SelfLink, err = lspDiagnosticSeverityFromString(*lspDiags.SelfLink)
		if err != nil {
			return config, fmt.Errorf("failed to parse deadlink level: %w", err)
		}
	}
	if lspDiags.MissingBacklink != nil {
		config.LSP.Diagnostics.MissingBacklink.Level, err = lspDiagnosticSeverityFromString(lspDiags.MissingBacklink.Level)
		if err != nil {
			return config, fmt.Errorf("failed to parse missing backlink level: %w", err)
		}
		config.LSP.Diagnostics.MissingBacklink.Position, err = lspDiagnosticPositionFromString(lspDiags.MissingBacklink.Position)
		if err != nil {
			return config, fmt.Errorf("failed to parse missing backlink position: %w", err)
		}
	}

	// Filters
	if tomlConf.Filters != nil {
		maps.Copy(config.Filters, tomlConf.Filters)
	}

	// Aliases
	if tomlConf.Aliases != nil {
		maps.Copy(config.Aliases, tomlConf.Aliases)
	}

	return config, nil
}

func (c GroupConfig) merge(tomlConf tomlGroupConfig, name string) GroupConfig {
	res := c.Clone()

	if tomlConf.Paths != nil {
		res.Paths = append(res.Paths, tomlConf.Paths...)
	} else {
		// If no `paths` config property was given for this group, we assume
		// that its name will be used as the path.
		res.Paths = append(res.Paths, name)
	}

	note := tomlConf.Note
	if note.Filename != "" {
		res.Note.FilenameTemplate = note.Filename
	}
	if note.Extension != "" {
		res.Note.Extension = note.Extension
	}
	if note.Template != "" {
		res.Note.BodyTemplatePath = opt.NewNotEmptyString(note.Template)
	}
	if note.IDLength != 0 {
		res.Note.IDOptions.Length = note.IDLength
	}
	if note.IDCharset != "" {
		res.Note.IDOptions.Charset = charsetFromString(note.IDCharset)
	}
	if note.IDCase != "" {
		res.Note.IDOptions.Case = caseFromString(note.IDCase)
	}
	if note.Lang != "" {
		res.Note.Lang = note.Lang
	}
	if note.DefaultTitle != "" {
		res.Note.DefaultTitle = note.DefaultTitle
	}
	res.Note.Exclude = append(res.Note.Exclude, note.Exclude...)
	res.Note.Exclude = append(res.Note.Exclude, note.Ignore...)
	if tomlConf.Extra != nil {
		maps.Copy(res.Extra, tomlConf.Extra)
	}

	return res
}

// tomlConfig holds the TOML representation of Config
type tomlConfig struct {
	Notebook tomlNotebookConfig
	Note     tomlNoteConfig
	Groups   map[string]tomlGroupConfig `toml:"group"`
	Format   tomlFormatConfig
	Tool     tomlToolConfig
	LSP      tomlLSPConfig
	Extra    map[string]string
	Filters  map[string]string `toml:"filter"`
	Aliases  map[string]string `toml:"alias"`
}

type tomlNotebookConfig struct {
	Dir string
}

type tomlNoteConfig struct {
	Filename     string
	Extension    string
	Template     string
	Lang         string   `toml:"language"`
	DefaultTitle string   `toml:"default-title"`
	IDCharset    string   `toml:"id-charset"`
	IDLength     int      `toml:"id-length"`
	IDCase       string   `toml:"id-case"`
	Exclude      []string `toml:"exclude"`
	Ignore       []string `toml:"ignore"` // Legacy alias to `exclude`
}

type tomlGroupConfig struct {
	Paths []string
	Note  tomlNoteConfig
	Extra map[string]string
}

type tomlFormatConfig struct {
	Markdown tomlMarkdownConfig
}

type tomlMarkdownConfig struct {
	Hashtags          *bool   `toml:"hashtags"`
	ColonTags         *bool   `toml:"colon-tags"`
	MultiwordTags     *bool   `toml:"multiword-tags"`
	LinkFormat        *string `toml:"link-format"`
	LinkEncodePath    *bool   `toml:"link-encode-path"`
	LinkDropExtension *bool   `toml:"link-drop-extension"`
}

type tomlToolConfig struct {
	Editor     *string
	Shell      *string
	Pager      *string
	FzfPreview *string `toml:"fzf-preview"`
	FzfLine    *string `toml:"fzf-line"`
	FzfOptions *string `toml:"fzf-options"`
	FzfBindNew *string `toml:"fzf-bind-new"`
}

type tomlLSPConfig struct {
	Completion struct {
		NoteLabel              *string `toml:"note-label"`
		NoteFilterText         *string `toml:"note-filter-text"`
		NoteDetail             *string `toml:"note-detail"`
		UseAdditionalTextEdits *bool   `toml:"use-additional-text-edits"`
	}
	Diagnostics struct {
		WikiTitle       *string                    `toml:"wiki-title"`
		DeadLink        *string                    `toml:"dead-link"`
		SelfLink        *string                    `toml:"self-link"`
		MissingBacklink *tomlMissingBacklinkConfig `toml:"missing-backlink"`
	}
}

type tomlMissingBacklinkConfig struct {
	Level    string `toml:"level"`
	Position string `toml:"position"`
}

func charsetFromString(charset string) Charset {
	switch charset {
	case "alphanum":
		return CharsetAlphanum
	case "hex":
		return CharsetHex
	case "letters":
		return CharsetLetters
	case "numbers":
		return CharsetNumbers
	default:
		return Charset(charset)
	}
}

func caseFromString(c string) Case {
	switch c {
	case "lower":
		return CaseLower
	case "upper":
		return CaseUpper
	case "mixed":
		return CaseMixed
	default:
		return CaseLower
	}
}

func lspDiagnosticSeverityFromString(s string) (LSPDiagnosticSeverity, error) {
	switch s {
	case "", "none":
		return LSPDiagnosticNone, nil
	case "error":
		return LSPDiagnosticError, nil
	case "warning":
		return LSPDiagnosticWarning, nil
	case "info":
		return LSPDiagnosticInfo, nil
	case "hint":
		return LSPDiagnosticHint, nil
	default:
		return LSPDiagnosticNone, fmt.Errorf("%s: unknown LSP diagnostic severity - may be none, hint, info, warning or error", s)
	}
}

func lspDiagnosticPositionFromString(s string) (LSPDiagnosticPosition, error) {
	switch s {
	case "top":
		return LSPDiagnosticPositionTop, nil
	case "bottom":
		return LSPDiagnosticPositionBottom, nil
	case "last-section":
		return LSPDiagnosticPositionLastSection, nil
	default:
		return 0, fmt.Errorf("%s: unknown LSP diagnostic position - may be top, bottom, or last-section", s)
	}
}
