package configcheck

import (
	"bytes"
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"strings"

	"github.com/OliveTin/OliveTin/internal/config"
	"github.com/OliveTin/OliveTin/internal/configissues"
	"github.com/OliveTin/OliveTin/internal/entities"
	"github.com/OliveTin/OliveTin/internal/tpl"
	"github.com/robfig/cron/v3"
	"gopkg.in/yaml.v3"
)

// Rebuild clears and rebuilds the issue list from the current config, entity
// state, sticky load-time reports, and any extra issues provided by callers
// (for example literal argument default validation from the executor).
func Rebuild(cfg *config.Config, extra ...configissues.Issue) {
	if cfg == nil {
		configissues.Replace(extra)
		return
	}

	collected := make([]configissues.Issue, 0)
	collected = append(collected, configissues.CopySticky()...)
	collected = append(collected, collectActionGroupIssues(cfg)...)
	collected = append(collected, collectAclReferenceIssues(cfg)...)
	collected = append(collected, collectArgumentIssues(cfg)...)
	collected = append(collected, collectIncludeIssues(cfg)...)
	collected = append(collected, collectTemplateParseIssues(cfg)...)
	collected = append(collected, collectEntityFileIssues(cfg)...)
	collected = append(collected, collectOrphanEntityTypeIssues(cfg)...)
	collected = append(collected, collectEntityEmptyIssues(cfg)...)
	collected = append(collected, collectCronIssues(cfg)...)
	collected = append(collected, collectWatcherPathIssues(cfg)...)
	collected = append(collected, extra...)
	configissues.Replace(collected)
}

func collectActionGroupIssues(cfg *config.Config) []configissues.Issue {
	out := make([]configissues.Issue, 0)
	for _, action := range cfg.Actions {
		if action == nil {
			continue
		}
		for _, groupName := range action.Groups {
			out = append(out, actionGroupIssue(cfg, action, groupName)...)
		}
	}
	return out
}

func actionGroupIssue(cfg *config.Config, action *config.Action, groupName string) []configissues.Issue {
	group, found := cfg.ActionGroups[groupName]
	if !found {
		return []configissues.Issue{actionIssue(action, configissues.SeverityWarning, configissues.CodeActionGroupUnknown,
			fmt.Sprintf("Action references unknown action group %q", groupName), groupName, "")}
	}

	if group == nil || group.MaxConcurrent < 1 {
		return []configissues.Issue{actionIssue(action, configissues.SeverityWarning, configissues.CodeActionGroupUnenforced,
			fmt.Sprintf("Action references action group %q that will not be enforced at runtime", groupName), groupName, "")}
	}

	return nil
}

func actionIssue(action *config.Action, severity, code, message, source, argName string) configissues.Issue {
	return configissues.Issue{
		Severity:     severity,
		Code:         code,
		Message:      message,
		ActionID:     action.ID,
		ActionTitle:  action.Title,
		ArgumentName: argName,
		Source:       source,
		ConfigFile:   action.SourceFile,
	}
}

func collectAclReferenceIssues(cfg *config.Config) []configissues.Issue {
	out := make([]configissues.Issue, 0)
	out = append(out, collectActionAclIssues(cfg)...)
	out = append(out, collectEntityAclIssues(cfg)...)
	return out
}

func collectActionAclIssues(cfg *config.Config) []configissues.Issue {
	out := make([]configissues.Issue, 0)
	for _, action := range cfg.Actions {
		if action == nil {
			continue
		}
		for _, aclName := range action.Acls {
			out = append(out, unknownAclIssue(cfg, aclName, action.ID, action.Title, action.SourceFile)...)
		}
	}
	return out
}

func collectEntityAclIssues(cfg *config.Config) []configissues.Issue {
	out := make([]configissues.Issue, 0)
	for _, entityFile := range cfg.Entities {
		if entityFile == nil {
			continue
		}
		for _, aclName := range entityFile.Acls {
			out = append(out, unknownAclIssue(cfg, aclName, "", entityFile.Name, entityFile.SourceFile)...)
		}
	}
	return out
}

func unknownAclIssue(cfg *config.Config, aclName, actionID, title, configFile string) []configissues.Issue {
	if cfg.FindAcl(aclName) != nil {
		return nil
	}

	return []configissues.Issue{{
		Severity:    configissues.SeverityError,
		Code:        configissues.CodeAclUnknown,
		Message:     fmt.Sprintf("References unknown ACL %q", aclName),
		ActionID:    actionID,
		ActionTitle: title,
		Source:      aclName,
		ConfigFile:  configFile,
	}}
}

func collectArgumentIssues(cfg *config.Config) []configissues.Issue {
	out := make([]configissues.Issue, 0)
	for _, action := range cfg.Actions {
		if action == nil {
			continue
		}
		for i := range action.Arguments {
			out = append(out, argumentIssues(action, &action.Arguments[i])...)
		}
	}
	return out
}

func argumentIssues(action *config.Action, arg *config.ActionArgument) []configissues.Issue {
	out := make([]configissues.Issue, 0)
	out = append(out, entityArgumentChoicesIssue(action, arg)...)

	if arg.Type == "checklist" {
		out = append(out, checklistNoChoicesIssue(action, arg)...)
	}

	return out
}

func checklistNoChoicesIssue(action *config.Action, arg *config.ActionArgument) []configissues.Issue {
	if len(arg.Choices) > 0 {
		return nil
	}

	return []configissues.Issue{actionIssue(action, configissues.SeverityWarning, configissues.CodeChecklistNoChoices,
		"Checklist argument has no choices defined", "", arg.Name)}
}

func entityArgumentChoicesIssue(action *config.Action, arg *config.ActionArgument) []configissues.Issue {
	if arg.Entity == "" || len(arg.Choices) == 1 {
		return nil
	}

	return []configissues.Issue{actionIssue(action, configissues.SeverityError, configissues.CodeEntityArgumentChoices,
		"Arguments with entity must define exactly one choice template", arg.Entity, arg.Name)}
}

func collectIncludeIssues(cfg *config.Config) []configissues.Issue {
	if cfg.Include == "" {
		return nil
	}

	includePath := cfg.Include
	if !filepath.IsAbs(includePath) {
		includePath = filepath.Join(cfg.GetDir(), cfg.Include)
	}
	info, err := os.Stat(includePath)
	if err != nil {
		return []configissues.Issue{{
			Severity:   configissues.SeverityWarning,
			Code:       configissues.CodeIncludeMissing,
			Message:    fmt.Sprintf("Include directory not found: %s", includePath),
			Source:     includePath,
			ConfigFile: includePath,
		}}
	}

	if !info.IsDir() {
		return []configissues.Issue{{
			Severity:   configissues.SeverityWarning,
			Code:       configissues.CodeIncludeMissing,
			Message:    fmt.Sprintf("Include path is not a directory: %s", includePath),
			Source:     includePath,
			ConfigFile: includePath,
		}}
	}

	return nil
}

func collectTemplateParseIssues(cfg *config.Config) []configissues.Issue {
	out := make([]configissues.Issue, 0)
	for _, action := range cfg.Actions {
		if action == nil {
			continue
		}
		for i := range action.Arguments {
			arg := &action.Arguments[i]
			out = append(out, templateIssuesForArg(action, arg)...)
		}
	}
	return out
}

func templateIssuesForArg(action *config.Action, arg *config.ActionArgument) []configissues.Issue {
	out := make([]configissues.Issue, 0)

	if looksLikeTemplate(arg.Default) {
		if err := tpl.CheckActionTemplateParses(arg.Default); err != nil {
			out = append(out, actionIssue(action, configissues.SeverityError, configissues.CodeTemplateParse,
				fmt.Sprintf("Argument default template failed to parse: %v", err), arg.Default, arg.Name))
		}
	}

	for _, choice := range arg.Choices {
		out = append(out, templateIssueForChoice(action, arg, choice.Title, "title")...)
		out = append(out, templateIssueForChoice(action, arg, choice.Value, "value")...)
	}

	return out
}

func templateIssueForChoice(action *config.Action, arg *config.ActionArgument, value, field string) []configissues.Issue {
	if !looksLikeTemplate(value) {
		return nil
	}

	if err := tpl.CheckActionTemplateParses(value); err != nil {
		return []configissues.Issue{actionIssue(action, configissues.SeverityError, configissues.CodeTemplateParse,
			fmt.Sprintf("Argument choice %s template failed to parse: %v", field, err), value, arg.Name)}
	}

	return nil
}

func looksLikeTemplate(value string) bool {
	return strings.Contains(value, "{{")
}

func collectEntityFileIssues(cfg *config.Config) []configissues.Issue {
	out := make([]configissues.Issue, 0)
	baseDir := entities.ResolveEntitiesBaseDir(cfg.GetDir())

	for _, ef := range cfg.Entities {
		out = append(out, entityFileIssuesFor(ef, baseDir)...)
	}

	return out
}

func collectOrphanEntityTypeIssues(cfg *config.Config) []configissues.Issue {
	configured := configuredEntityTypeNames(cfg)
	out := make([]configissues.Issue, 0)

	for entityType := range entities.GetEntities() {
		if configured[entityType] {
			continue
		}

		out = append(out, configissues.Issue{
			Severity: configissues.SeverityWarning,
			Code:     configissues.CodeEntityTypeUnconfigured,
			Message:  fmt.Sprintf("Entity type %q is loaded but has no matching entities entry in configuration", entityType),
			Source:   entityType,
		})
	}

	return out
}

func configuredEntityTypeNames(cfg *config.Config) map[string]bool {
	configured := make(map[string]bool)
	if cfg == nil {
		return configured
	}

	for _, entityFile := range cfg.Entities {
		if entityFile == nil || entityFile.Name == "" {
			continue
		}
		configured[entityFile.Name] = true
	}

	return configured
}

func entityFileIssuesFor(ef *config.EntityFile, baseDir string) []configissues.Issue {
	if ef == nil || ef.File == "" {
		return nil
	}

	path := resolveEntityPath(ef.File, baseDir)
	issue := entityFileIssue(ef, path)
	if issue == nil {
		return nil
	}
	return []configissues.Issue{*issue}
}

func resolveEntityPath(file, baseDir string) string {
	if filepath.IsAbs(file) {
		return file
	}
	return filepath.Join(baseDir, file)
}

func entityFileIssue(ef *config.EntityFile, path string) *configissues.Issue {
	data, err := os.ReadFile(path)
	if err != nil {
		return entityReadIssue(ef, path, err)
	}

	if len(strings.TrimSpace(string(data))) == 0 {
		return &configissues.Issue{
			Severity:   configissues.SeverityWarning,
			Code:       configissues.CodeEntityFile,
			Message:    fmt.Sprintf("Entity %q file is empty", ef.Name),
			Source:     path,
			ConfigFile: ef.SourceFile,
		}
	}

	if strings.HasSuffix(path, ".json") {
		return entityJSONParseIssue(ef, path, data)
	}

	return entityYAMLParseIssue(ef, path, data)
}

func entityReadIssue(ef *config.EntityFile, path string, err error) *configissues.Issue {
	return &configissues.Issue{
		Severity:   configissues.SeverityError,
		Code:       configissues.CodeEntityFile,
		Message:    fmt.Sprintf("Entity %q file could not be read: %v", ef.Name, err),
		Source:     path,
		ConfigFile: ef.SourceFile,
	}
}

func entityJSONParseIssue(ef *config.EntityFile, path string, data []byte) *configissues.Issue {
	decoder := json.NewDecoder(bytes.NewReader(data))
	for decoder.More() {
		d := make(map[string]any)
		if err := decoder.Decode(&d); err != nil {
			return &configissues.Issue{
				Severity:   configissues.SeverityError,
				Code:       configissues.CodeEntityFile,
				Message:    fmt.Sprintf("Entity %q file could not be parsed as JSON: %v", ef.Name, err),
				Source:     path,
				ConfigFile: ef.SourceFile,
			}
		}
	}
	return nil
}

func entityYAMLParseIssue(ef *config.EntityFile, path string, data []byte) *configissues.Issue {
	var parsed []map[string]any
	if err := yaml.Unmarshal(data, &parsed); err != nil {
		return &configissues.Issue{
			Severity:   configissues.SeverityError,
			Code:       configissues.CodeEntityFile,
			Message:    fmt.Sprintf("Entity %q file could not be parsed as YAML: %v", ef.Name, err),
			Source:     path,
			ConfigFile: ef.SourceFile,
		}
	}
	return nil
}

func collectEntityEmptyIssues(cfg *config.Config) []configissues.Issue {
	out := make([]configissues.Issue, 0)
	for _, action := range cfg.Actions {
		out = append(out, entityEmptyIssueFor(cfg, action)...)
	}
	return out
}

func entityEmptyIssueFor(cfg *config.Config, action *config.Action) []configissues.Issue {
	if !shouldReportEntityEmpty(cfg, action) {
		return nil
	}

	return []configissues.Issue{actionIssue(action, configissues.SeverityWarning, configissues.CodeEntityEmpty,
		fmt.Sprintf("Entity-bound action has no instances of entity %q", action.Entity), action.Entity, "")}
}

func shouldReportEntityEmpty(cfg *config.Config, action *config.Action) bool {
	if action == nil || action.Entity == "" {
		return false
	}
	if len(entities.GetEntityInstances(action.Entity)) > 0 {
		return false
	}
	return entityEmptyLoadGatePassed(cfg, action.Entity)
}

func entityEmptyLoadGatePassed(cfg *config.Config, entityName string) bool {
	// Avoid startup false positives: entity files load after the first action-map rebuild.
	if entityTypeConfigured(cfg, entityName) && !entities.HasEntityLoadAttempted(entityName) {
		return false
	}
	return true
}

func entityTypeConfigured(cfg *config.Config, entityName string) bool {
	for _, ef := range cfg.Entities {
		if ef != nil && ef.Name == entityName {
			return true
		}
	}
	return false
}

func collectCronIssues(cfg *config.Config) []configissues.Issue {
	out := make([]configissues.Issue, 0)
	parser := cronParser(cfg)

	for _, action := range cfg.Actions {
		out = append(out, cronIssuesForAction(action, parser)...)
	}

	return out
}

func cronIssuesForAction(action *config.Action, parser cron.Parser) []configissues.Issue {
	if action == nil {
		return nil
	}

	out := make([]configissues.Issue, 0)
	out = append(out, cronEntityBindingIssue(action)...)
	out = append(out, cronScheduleIssues(action, parser)...)
	return out
}

func cronEntityBindingIssue(action *config.Action) []configissues.Issue {
	if action.Entity == "" || len(action.ExecOnCron) == 0 {
		return nil
	}

	return []configissues.Issue{actionIssue(action, configissues.SeverityWarning, configissues.CodeCronEntityBinding,
		"Entity-bound action uses execOnCron; cron runs without an entity binding and will not execute", action.Entity, "")}
}

func cronScheduleIssues(action *config.Action, parser cron.Parser) []configissues.Issue {
	out := make([]configissues.Issue, 0)
	for _, cronline := range action.ExecOnCron {
		out = append(out, cronScheduleIssue(action, parser, cronline)...)
	}
	return out
}

func cronScheduleIssue(action *config.Action, parser cron.Parser, cronline string) []configissues.Issue {
	_, err := parser.Parse(cronline)
	if err == nil {
		return nil
	}

	return []configissues.Issue{actionIssue(action, configissues.SeverityError, configissues.CodeCronInvalid,
		fmt.Sprintf("Invalid cron schedule %q: %v", cronline, err), cronline, "")}
}

func cronParser(cfg *config.Config) cron.Parser {
	if cfg.CronSupportForSeconds {
		return cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
	}
	return cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
}

func collectWatcherPathIssues(cfg *config.Config) []configissues.Issue {
	out := make([]configissues.Issue, 0)
	for _, action := range cfg.Actions {
		out = append(out, watcherPathIssuesForAction(action)...)
	}
	out = append(out, watcherPathIssuesForEntities(cfg)...)
	return out
}

func watcherPathIssuesForAction(action *config.Action) []configissues.Issue {
	if action == nil {
		return nil
	}

	out := make([]configissues.Issue, 0)
	out = append(out, watcherCreatedDirIssues(action)...)
	out = append(out, watcherChangedDirIssues(action)...)
	out = append(out, watcherCalendarFileIssues(action)...)
	return out
}

func watcherCreatedDirIssues(action *config.Action) []configissues.Issue {
	out := make([]configissues.Issue, 0)
	for _, dir := range action.ExecOnFileCreatedInDir {
		out = append(out, watcherDirIssue(action, dir)...)
	}
	return out
}

func watcherChangedDirIssues(action *config.Action) []configissues.Issue {
	out := make([]configissues.Issue, 0)
	for _, dir := range action.ExecOnFileChangedInDir {
		out = append(out, watcherDirIssue(action, dir)...)
	}
	return out
}

func watcherCalendarFileIssues(action *config.Action) []configissues.Issue {
	return watcherFileIssue(action, action.ExecOnCalendarFile)
}

func watcherPathIssuesForEntities(cfg *config.Config) []configissues.Issue {
	out := make([]configissues.Issue, 0)
	baseDir := entities.ResolveEntitiesBaseDir(cfg.GetDir())

	for _, ef := range cfg.Entities {
		if ef == nil || ef.File == "" {
			continue
		}
		path := resolveEntityPath(ef.File, baseDir)
		out = append(out, missingWatchDirIssue(path, filepath.Dir(path), ef.SourceFile, "", "")...)
	}
	return out
}

func watcherDirIssue(action *config.Action, dir string) []configissues.Issue {
	if dir == "" {
		return nil
	}
	return missingWatchDirIssue(dir, dir, action.SourceFile, action.ID, action.Title)
}

func watcherFileIssue(action *config.Action, filePath string) []configissues.Issue {
	if filePath == "" {
		return nil
	}
	return missingWatchDirIssue(filePath, filepath.Dir(filePath), action.SourceFile, action.ID, action.Title)
}

func missingWatchDirIssue(displayPath, dirToStat, configFile, actionID, actionTitle string) []configissues.Issue {
	_, err := os.Stat(dirToStat)
	if err == nil {
		return nil
	}

	return []configissues.Issue{{
		Severity:    configissues.SeverityError,
		Code:        configissues.CodeWatcherPath,
		Message:     fmt.Sprintf("Could not create watcher for %q: %v", displayPath, err),
		ActionID:    actionID,
		ActionTitle: actionTitle,
		Source:      displayPath,
		ConfigFile:  configFile,
	}}
}
