package main

import (
	"fmt"
	"os"
	"path/filepath"
	"slices"
	"strings"

	"github.com/spf13/cobra"
	"gopkg.in/yaml.v3"

	"github.com/zzet/gortex/internal/config"
	"github.com/zzet/gortex/internal/daemon"
	"github.com/zzet/gortex/internal/excludes"
)

// Flags are shared by add/remove. Cobra parses them once per command,
// so declaring package-level vars is fine here.
var (
	excludeGlobalFlag bool
	excludeRepoFlag   string
)

var configCmd = &cobra.Command{
	Use:   "config",
	Short: "Manage Gortex configuration",
}

var configExcludeCmd = &cobra.Command{
	Use:   "exclude",
	Short: "Manage the effective ignore list (indexing + watching)",
	Long: `Add, list, or remove patterns from the effective ignore list used by both
indexing and watching. Patterns follow .gitignore semantics.

Targets (in precedence order; later layers override earlier):
  1. Builtin baseline (read-only)
  2. Global   - ~/.config/gortex/config.yaml       (--global)
  3. Repo    - GlobalConfig.repos[].exclude        (--repo <name>)
  4. Workspace - ./.gortex.yaml at the repo root   (default)

Use "!pattern" in a later layer to re-include something an earlier layer
excluded.`,
}

var configExcludeAddCmd = &cobra.Command{
	Use:   "add <path-or-pattern>",
	Short: "Append a pattern to the selected target",
	Long: `Appends a pattern. If the argument is an existing directory, it is
normalised to a repo-root-relative gitignore pattern (trailing slash).
Raw glob patterns (containing *, ?, [, leading / or !) are written
verbatim.`,
	Args: cobra.ExactArgs(1),
	RunE: runConfigExcludeAdd,
}

var configExcludeListCmd = &cobra.Command{
	Use:   "list",
	Short: "Show the effective ignore list with source annotations",
	RunE:  runConfigExcludeList,
}

var configExcludeRemoveCmd = &cobra.Command{
	Use:   "remove <path-or-pattern>",
	Short: "Remove a pattern from the selected target",
	Args:  cobra.ExactArgs(1),
	RunE:  runConfigExcludeRemove,
}

func init() {
	rootCmd.AddCommand(configCmd)
	configCmd.AddCommand(configExcludeCmd)
	configExcludeCmd.AddCommand(configExcludeAddCmd)
	configExcludeCmd.AddCommand(configExcludeListCmd)
	configExcludeCmd.AddCommand(configExcludeRemoveCmd)

	for _, c := range []*cobra.Command{configExcludeAddCmd, configExcludeRemoveCmd} {
		c.Flags().BoolVar(&excludeGlobalFlag, "global", false,
			"write to ~/.config/gortex/config.yaml (GlobalConfig.exclude)")
		c.Flags().StringVar(&excludeRepoFlag, "repo", "",
			"write to the named RepoEntry in the global config")
	}
}

// --- Target abstraction ---

// excludeTarget encapsulates load/save for one of the three writable
// exclude locations. repoRoot is set for workspace targets (used to
// anchor path normalisation); it's empty for global and repo-entry
// targets where patterns are not path-anchored.
type excludeTarget struct {
	label    string
	repoRoot string // "" for non-workspace targets

	load func() ([]string, error)
	save func(patterns []string) error
}

func resolveTarget() (*excludeTarget, error) {
	if excludeGlobalFlag && excludeRepoFlag != "" {
		return nil, fmt.Errorf("--global and --repo are mutually exclusive")
	}
	switch {
	case excludeGlobalFlag:
		return newGlobalTarget()
	case excludeRepoFlag != "":
		return newRepoEntryTarget(excludeRepoFlag)
	default:
		return newWorkspaceTarget()
	}
}

func newWorkspaceTarget() (*excludeTarget, error) {
	path, root, err := findWorkspaceConfigPath()
	if err != nil {
		return nil, err
	}
	return &excludeTarget{
		label:    "workspace " + path,
		repoRoot: root,
		load: func() ([]string, error) {
			cfg, err := readWorkspaceYAML(path)
			if err != nil {
				return nil, err
			}
			if cfg == nil {
				return nil, nil
			}
			// Fold legacy keys so `list` and `remove` see what the old
			// file actually contributed.
			patterns := append([]string{}, cfg.Exclude...)
			if len(cfg.Exclude) == 0 {
				patterns = append(patterns, cfg.Index.Exclude...)
				patterns = append(patterns, cfg.Watch.Exclude...)
			}
			return patterns, nil
		},
		save: func(patterns []string) error {
			cfg, err := readWorkspaceYAML(path)
			if err != nil {
				return err
			}
			if cfg == nil {
				cfg = &config.Config{}
			}
			cfg.Exclude = patterns
			// Scrub legacy keys once the user has written anything to
			// the new shape — they've been migrated.
			cfg.Index.Exclude = nil
			cfg.Watch.Exclude = nil
			return writeWorkspaceYAML(path, cfg)
		},
	}, nil
}

func newGlobalTarget() (*excludeTarget, error) {
	return &excludeTarget{
		label: "global " + config.DefaultGlobalConfigPath(),
		load: func() ([]string, error) {
			gc, err := config.LoadGlobal()
			if err != nil {
				return nil, err
			}
			return append([]string{}, gc.Exclude...), nil
		},
		save: func(patterns []string) error {
			gc, err := config.LoadGlobal()
			if err != nil {
				return err
			}
			gc.Exclude = patterns
			return gc.Save()
		},
	}, nil
}

func newRepoEntryTarget(name string) (*excludeTarget, error) {
	return &excludeTarget{
		label: "repo entry " + name + " in " + config.DefaultGlobalConfigPath(),
		load: func() ([]string, error) {
			gc, err := config.LoadGlobal()
			if err != nil {
				return nil, err
			}
			entry := gc.FindRepoByPrefix(name)
			if entry == nil {
				return nil, fmt.Errorf("no repo entry named %q in global config", name)
			}
			return append([]string{}, entry.Exclude...), nil
		},
		save: func(patterns []string) error {
			gc, err := config.LoadGlobal()
			if err != nil {
				return err
			}
			entry := gc.FindRepoByPrefix(name)
			if entry == nil {
				return fmt.Errorf("no repo entry named %q in global config", name)
			}
			entry.Exclude = patterns
			return gc.Save()
		},
	}, nil
}

// --- Command implementations ---

func runConfigExcludeAdd(_ *cobra.Command, args []string) error {
	t, err := resolveTarget()
	if err != nil {
		return err
	}
	pattern, err := normalizePattern(args[0], t.repoRoot)
	if err != nil {
		return err
	}
	existing, err := t.load()
	if err != nil {
		return err
	}
	if slices.Contains(existing, pattern) {
		fmt.Fprintf(os.Stderr, "[gortex] already present in %s: %s\n", t.label, pattern)
		return nil
	}
	existing = append(existing, pattern)
	if err := t.save(existing); err != nil {
		return err
	}
	fmt.Fprintf(os.Stderr, "[gortex] added %q to %s\n", pattern, t.label)
	notifyDaemonReload()
	return nil
}

func runConfigExcludeRemove(_ *cobra.Command, args []string) error {
	t, err := resolveTarget()
	if err != nil {
		return err
	}
	existing, err := t.load()
	if err != nil {
		return err
	}

	// Accept both the raw arg and its normalised form so a user can
	// remove by path just as they added.
	candidates := map[string]struct{}{args[0]: {}}
	if p, err := normalizePattern(args[0], t.repoRoot); err == nil {
		candidates[p] = struct{}{}
	}

	filtered := existing[:0]
	removed := false
	for _, p := range existing {
		if _, drop := candidates[p]; drop {
			removed = true
			continue
		}
		filtered = append(filtered, p)
	}
	if !removed {
		return fmt.Errorf("pattern not found in %s", t.label)
	}
	if err := t.save(filtered); err != nil {
		return err
	}
	fmt.Fprintf(os.Stderr, "[gortex] removed %q from %s\n", args[0], t.label)
	notifyDaemonReload()
	return nil
}

func runConfigExcludeList(_ *cobra.Command, _ []string) error {
	rows := [][2]string{}
	for _, p := range excludes.Builtin {
		rows = append(rows, [2]string{"builtin", p})
	}

	gc, err := config.LoadGlobal()
	if err == nil {
		for _, p := range gc.Exclude {
			rows = append(rows, [2]string{"global", p})
		}
	}

	wsPath, wsRoot, wsErr := findWorkspaceConfigPath()
	if wsErr == nil && wsRoot != "" {
		wsPrefix := filepath.Base(wsRoot)
		if gc != nil {
			if entry := gc.FindRepoByPrefix(wsPrefix); entry != nil {
				for _, p := range entry.Exclude {
					rows = append(rows, [2]string{"repo:" + wsPrefix, p})
				}
			}
		}
		// Only read the workspace file if it actually exists; findWorkspaceConfigPath
		// returns a prospective path for the create case.
		if _, err := os.Stat(wsPath); err == nil {
			ws, err := readWorkspaceYAML(wsPath)
			if err == nil && ws != nil {
				for _, p := range ws.Exclude {
					rows = append(rows, [2]string{"workspace", p})
				}
				if len(ws.Exclude) == 0 {
					for _, p := range ws.Index.Exclude {
						rows = append(rows, [2]string{"workspace (legacy index.exclude)", p})
					}
					for _, p := range ws.Watch.Exclude {
						rows = append(rows, [2]string{"workspace (legacy watch.exclude)", p})
					}
				}
			}
		}
	}

	width := 0
	for _, r := range rows {
		if len(r[0]) > width {
			width = len(r[0])
		}
	}
	for _, r := range rows {
		fmt.Printf("[%-*s] %s\n", width, r[0], r[1])
	}
	return nil
}

// --- Helpers ---

// normalizePattern converts an arg to a canonical pattern.
// If the arg looks like a glob (contains *, ?, [, or leading ! or /),
// it's returned verbatim. If it's an existing filesystem path under
// repoRoot, it's normalised to a repo-relative gitignore pattern
// (trailing "/" for directories). Otherwise returned as-is — users
// may intentionally write literal patterns we can't stat.
func normalizePattern(arg, repoRoot string) (string, error) {
	if looksLikeGlob(arg) {
		return arg, nil
	}
	if repoRoot == "" {
		return arg, nil
	}
	absArg, err := filepath.Abs(arg)
	if err != nil {
		return arg, nil
	}
	info, err := os.Stat(absArg)
	if err != nil {
		return arg, nil
	}
	rel, err := filepath.Rel(repoRoot, absArg)
	if err != nil {
		return arg, nil
	}
	rel = filepath.ToSlash(rel)
	if rel == "." {
		return "", fmt.Errorf("refusing to ignore the repo root")
	}
	if strings.HasPrefix(rel, "../") || rel == ".." {
		return "", fmt.Errorf("path %s is outside the repo root %s", arg, repoRoot)
	}
	if info.IsDir() {
		return rel + "/", nil
	}
	return rel, nil
}

// looksLikeGlob reports whether s has pattern syntax that should be
// preserved verbatim rather than treated as a filesystem path. A leading
// "/" is intentionally NOT treated as a glob marker — users more often
// pass an absolute filesystem path than a root-anchored gitignore
// pattern; the latter is better expressed by running the command from
// the repo root with a relative arg.
func looksLikeGlob(s string) bool {
	if strings.HasPrefix(s, "!") {
		return true
	}
	return strings.ContainsAny(s, "*?[")
}

// findWorkspaceConfigPath walks up from the cwd looking for an existing
// .gortex.yaml. When one is found, its path and containing dir are
// returned. Otherwise it returns a prospective path at the nearest
// git root (or cwd as a last resort) so `add` can create the file.
func findWorkspaceConfigPath() (path, repoRoot string, err error) {
	cwd, err := os.Getwd()
	if err != nil {
		return "", "", err
	}
	dir := cwd
	for {
		candidate := filepath.Join(dir, ".gortex.yaml")
		if _, statErr := os.Stat(candidate); statErr == nil {
			return candidate, dir, nil
		}
		parent := filepath.Dir(dir)
		if parent == dir {
			break
		}
		dir = parent
	}
	if root := gitToplevel(cwd); root != "" {
		return filepath.Join(root, ".gortex.yaml"), root, nil
	}
	return filepath.Join(cwd, ".gortex.yaml"), cwd, nil
}

func gitToplevel(start string) string {
	dir := start
	for {
		if info, err := os.Stat(filepath.Join(dir, ".git")); err == nil && info.IsDir() {
			return dir
		}
		parent := filepath.Dir(dir)
		if parent == dir {
			return ""
		}
		dir = parent
	}
}

// readWorkspaceYAML loads .gortex.yaml. Returns nil with no error when
// the file is absent — the workspace target creates it on save.
func readWorkspaceYAML(path string) (*config.Config, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, nil
		}
		return nil, err
	}
	var cfg config.Config
	if err := yaml.Unmarshal(data, &cfg); err != nil {
		return nil, fmt.Errorf("parsing %s: %w", path, err)
	}
	return &cfg, nil
}

// writeWorkspaceYAML writes .gortex.yaml. The round-trip through the
// Config struct loses comments, which is an accepted trade-off until
// someone needs preservation.
func writeWorkspaceYAML(path string, cfg *config.Config) error {
	data, err := yaml.Marshal(cfg)
	if err != nil {
		return err
	}
	return os.WriteFile(path, data, 0644)
}

// notifyDaemonReload sends a Reload RPC when a daemon is running so
// exclude changes take effect without a restart. Non-fatal on error —
// the written config will be picked up on the daemon's next start.
func notifyDaemonReload() {
	if !daemon.IsRunning() {
		return
	}
	c, err := daemon.Dial(daemon.Handshake{Mode: daemon.ModeControl, ClientName: "cli-config"})
	if err != nil {
		return
	}
	defer func() { _ = c.Close() }()
	resp, err := c.Control(daemon.ControlReload, nil)
	if err != nil || !resp.OK {
		return
	}
	fmt.Fprintln(os.Stderr, "[gortex] daemon reloaded")
}
