package coder

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

	"github.com/diillson/chatcli/utils"
	"go.uber.org/zap"
)

type Action string

const (
	ActionAllow Action = "allow"
	ActionDeny  Action = "deny"
	ActionAsk   Action = "ask"
)

type Rule struct {
	Pattern string `json:"pattern"`
	Action  Action `json:"action"`
}

type PolicyManager struct {
	Rules      []Rule `json:"rules"`
	configPath string
	localPath  string
	mergeLocal bool
	lastRule   *Rule
	logger     *zap.Logger
	mu         sync.RWMutex
}

type policyFile struct {
	Rules []Rule `json:"rules"`
	Merge bool   `json:"merge"`
}

func NewPolicyManager(logger *zap.Logger) (*PolicyManager, error) {
	home, err := utils.GetHomeDir()
	if err != nil {
		return nil, err
	}

	configPath := filepath.Join(home, ".chatcli", "coder_policy.json")

	pm := &PolicyManager{
		Rules:      make([]Rule, 0),
		configPath: configPath,
		logger:     logger,
	}
	pm.load()
	return pm, nil
}

func (pm *PolicyManager) Check(toolName, args string) Action {
	pm.mu.RLock()
	defer pm.mu.RUnlock()

	sub, normalized := NormalizeCoderArgs(args)
	var fullCommand string
	if normalized != "" {
		fullCommand = strings.TrimSpace(fmt.Sprintf("%s %s", toolName, normalized))
	} else if sub != "" {
		fullCommand = strings.TrimSpace(fmt.Sprintf("%s %s", toolName, sub))
	} else {
		fullCommand = strings.TrimSpace(toolName)
	}

	// Classify all matching rules by action type
	var bestDeny Rule
	hasDeny := false
	var bestAllow Rule
	hasAllow := false
	var bestAsk Rule
	hasAsk := false

	for _, rule := range pm.Rules {
		if !matchesWithBoundary(fullCommand, rule.Pattern) {
			continue
		}
		switch rule.Action {
		case ActionDeny:
			if !hasDeny || len(rule.Pattern) > len(bestDeny.Pattern) {
				bestDeny = rule
				hasDeny = true
			}
		case ActionAllow:
			if !hasAllow || len(rule.Pattern) > len(bestAllow.Pattern) {
				bestAllow = rule
				hasAllow = true
			}
		case ActionAsk:
			if !hasAsk || len(rule.Pattern) > len(bestAsk.Pattern) {
				bestAsk = rule
				hasAsk = true
			}
		}
	}

	// Priority 1: Deny rules always win (strictest)
	if hasDeny {
		pm.lastRule = &bestDeny
		return ActionDeny
	}

	// Priority 2: Safety bypass immunity — certain operations ALWAYS require
	// confirmation, regardless of any allow rules. Deny rules (above) still
	// take precedence since they're more restrictive than ask.
	if IsSafetyImmune(toolName, args) {
		pm.lastRule = nil
		return ActionAsk
	}

	// Priority 3: If we have both allow and ask, the more specific pattern wins
	if hasAllow && hasAsk {
		if len(bestAsk.Pattern) > len(bestAllow.Pattern) {
			pm.lastRule = &bestAsk
			return ActionAsk
		}
		pm.lastRule = &bestAllow
		return ActionAllow
	}

	if hasAsk {
		pm.lastRule = &bestAsk
		return ActionAsk
	}

	if hasAllow {
		pm.lastRule = &bestAllow
		return ActionAllow
	}

	// Priority 4: No explicit rule — check if it's a read-only exec command
	if sub == "exec" {
		_, cmdNormalized := NormalizeCoderArgs(args)
		shellCmd := extractShellCommand(cmdNormalized)
		if shellCmd != "" && IsReadOnlyCommand(shellCmd) {
			pm.lastRule = nil
			return ActionAllow
		}
	}

	// Priority 5: capability gate. When the plugin manager has declared
	// the tool read-only (via plugins.ReadOnlyAware), auto-allow. This
	// lets @websearch, @webfetch GET, @read, @search, @tree,
	// @scheduler query/list run without a security prompt while keeping
	// every other plugin gated. Failing closed: an unknown plugin or
	// nil resolver falls through to ActionAsk.
	if r := currentPluginCapabilityResolver(); r != nil {
		// capRes (not cap) to avoid shadowing Go's builtin cap function.
		capRes := r(toolName, args)
		if capRes.Known && capRes.ReadOnly {
			pm.lastRule = nil
			return ActionAllow
		}
	}

	pm.lastRule = nil
	return ActionAsk
}

// extractShellCommand extracts the shell command from normalized coder exec args.
func extractShellCommand(normalized string) string {
	// normalized is like: "exec --cmd ls -la --dir /tmp"
	// We need the value after --cmd
	fields := strings.Fields(normalized)
	for i, f := range fields {
		if f == "--cmd" && i+1 < len(fields) {
			// Everything after --cmd until the next -- flag is the command
			var cmd strings.Builder
			for j := i + 1; j < len(fields); j++ {
				if strings.HasPrefix(fields[j], "--") && !strings.ContainsAny(fields[j], "=") {
					break
				}
				if cmd.Len() > 0 {
					cmd.WriteByte(' ')
				}
				cmd.WriteString(fields[j])
			}
			return cmd.String()
		}
	}
	return ""
}

func (pm *PolicyManager) AddRule(pattern string, action Action) error {
	pm.mu.Lock()
	defer pm.mu.Unlock()

	pattern = strings.TrimSpace(pattern)

	// Reject empty or excessively long patterns
	if pattern == "" {
		return fmt.Errorf("empty policy pattern rejected")
	}
	if len(pattern) > 500 {
		return fmt.Errorf("policy pattern too long (%d chars)", len(pattern))
	}

	pm.logger.Info("Updating security policy", zap.String("pattern", pattern), zap.String("action", string(action)))

	updated := false
	for i, rule := range pm.Rules {
		if rule.Pattern == pattern {
			pm.Rules[i].Action = action
			updated = true
			break
		}
	}

	if !updated {
		pm.Rules = append(pm.Rules, Rule{Pattern: pattern, Action: action})
	}

	pm.lastRule = nil
	return pm.save()
}

// DeleteRule removes the rule whose Pattern matches exactly. Returns
// (true, nil) when a rule was removed, (false, nil) when no rule
// matched (so callers can distinguish "nothing to do" from an error),
// and (false, err) on persistence failure.
//
// Used by the /config security forget subcommand so operators can
// retract an Allow / Deny without hand-editing coder_policy.json.
func (pm *PolicyManager) DeleteRule(pattern string) (bool, error) {
	pm.mu.Lock()
	defer pm.mu.Unlock()

	pattern = strings.TrimSpace(pattern)
	if pattern == "" {
		return false, fmt.Errorf("empty policy pattern")
	}

	filtered := pm.Rules[:0]
	removed := false
	for _, r := range pm.Rules {
		if r.Pattern == pattern {
			removed = true
			continue
		}
		filtered = append(filtered, r)
	}
	pm.Rules = filtered
	pm.lastRule = nil

	if !removed {
		return false, nil
	}
	pm.logger.Info("Removed security policy rule", zap.String("pattern", pattern))
	return true, pm.save()
}

// RulesSnapshot returns a copy of the current rule set. Safe to show
// to UI code or write to disk without holding the PolicyManager lock
// (each caller gets its own slice).
func (pm *PolicyManager) RulesSnapshot() []Rule {
	pm.mu.RLock()
	defer pm.mu.RUnlock()
	out := make([]Rule, len(pm.Rules))
	copy(out, pm.Rules)
	return out
}

func (pm *PolicyManager) load() {
	globalPath := pm.configPath
	globalRules := []Rule{}

	if _, err := os.Stat(globalPath); os.IsNotExist(err) {
		pm.defaultRules()
		globalRules = pm.Rules
	} else {
		data, err := os.ReadFile(globalPath) //#nosec G304 -- path supplied by user/agent through validated tool surface (boundary check upstream)
		if err != nil {
			pm.logger.Warn("Failed to read security policy", zap.Error(err))
		} else {
			var pf policyFile
			if err := json.Unmarshal(data, &pf); err != nil {
				pm.logger.Warn("Failed to parse security policy", zap.Error(err))
			} else {
				globalRules = pf.Rules
			}
		}
	}

	localPath := findLocalPolicyPath()
	if localPath == "" {
		pm.Rules = globalRules
		pm.localPath = ""
		pm.mergeLocal = false
		return
	}

	localData, err := os.ReadFile(localPath) //#nosec G304 -- path supplied by user/agent through validated tool surface (boundary check upstream)
	if err != nil {
		pm.logger.Warn("Failed to read local security policy", zap.Error(err))
		pm.Rules = globalRules
		pm.localPath = ""
		pm.mergeLocal = false
		return
	}

	var local policyFile
	if err := json.Unmarshal(localData, &local); err != nil {
		pm.logger.Warn("Failed to parse local security policy", zap.Error(err))
		pm.Rules = globalRules
		pm.localPath = ""
		pm.mergeLocal = false
		return
	}

	if local.Merge {
		pm.Rules = mergeRules(globalRules, local.Rules)
	} else {
		pm.Rules = local.Rules
	}

	// Se existe policy local, persistir alterações nela.
	pm.configPath = localPath
	pm.localPath = localPath
	pm.mergeLocal = local.Merge
}

func (pm *PolicyManager) save() error {
	data, err := json.MarshalIndent(pm, "", "  ")
	if err != nil {
		return err
	}

	dir := filepath.Dir(pm.configPath)
	if err := os.MkdirAll(dir, 0o700); err != nil {
		return err
	}

	// M1: Use restrictive permissions so only the owner can read security rules
	return os.WriteFile(pm.configPath, data, 0o600)
}

func (pm *PolicyManager) defaultRules() {
	pm.Rules = []Rule{
		{Pattern: "@coder read", Action: ActionAllow},
		{Pattern: "@coder tree", Action: ActionAllow},
		{Pattern: "@coder search", Action: ActionAllow},
		{Pattern: "@coder git-status", Action: ActionAllow},
		{Pattern: "@coder git-diff", Action: ActionAllow},
		{Pattern: "@coder git-log", Action: ActionAllow},
		{Pattern: "@coder git-changed", Action: ActionAllow},
		{Pattern: "@coder git-branch", Action: ActionAllow},
		// exec always requires per-command approval (never auto-allow)
		{Pattern: "@coder exec", Action: ActionAsk},
	}
	_ = pm.save()
}

func findLocalPolicyPath() string {
	wd, err := os.Getwd()
	if err != nil {
		return ""
	}
	path := filepath.Join(wd, "coder_policy.json")
	if _, err := os.Stat(path); err == nil {
		return path
	}
	return ""
}

// matchesWithBoundary checks if fullCommand starts with pattern, applying a
// word-boundary check only when the pattern ends with a word character (letter,
// digit, hyphen, underscore). This prevents "@coder read" from matching
// "@coder readlink" while still allowing path-prefix patterns like
// "@coder read --file /etc" to match "@coder read --file /etc/passwd".
func matchesWithBoundary(fullCommand, pattern string) bool {
	if !strings.HasPrefix(fullCommand, pattern) {
		return false
	}
	if len(fullCommand) == len(pattern) || len(pattern) == 0 {
		return true
	}
	// Only enforce word boundary when the pattern ends with a word character.
	// If it ends with '/', ' ', '=', etc., standard prefix matching applies.
	last := pattern[len(pattern)-1]
	isWordChar := (last >= 'a' && last <= 'z') || (last >= 'A' && last <= 'Z') ||
		(last >= '0' && last <= '9') || last == '_' || last == '-'
	if !isWordChar {
		return true
	}
	// When pattern ends with a word char, the next char must NOT be a word
	// char to avoid partial-word matches (e.g., "read" vs "readlink").
	// We allow space, '/', '=', and other non-word separators.
	next := fullCommand[len(pattern)]
	nextIsWord := (next >= 'a' && next <= 'z') || (next >= 'A' && next <= 'Z') ||
		(next >= '0' && next <= '9') || next == '_' || next == '-'
	return !nextIsWord
}

// mergeRules combina regras globais e locais, onde a regra local com mesmo pattern
// substitui a global. Regras locais novas são adicionadas ao final.
func mergeRules(globalRules, localRules []Rule) []Rule {
	localMap := make(map[string]Action, len(localRules))
	for _, r := range localRules {
		localMap[r.Pattern] = r.Action
	}

	merged := make([]Rule, 0, len(globalRules)+len(localRules))
	for _, r := range globalRules {
		if action, ok := localMap[r.Pattern]; ok {
			merged = append(merged, Rule{Pattern: r.Pattern, Action: action})
			delete(localMap, r.Pattern)
		} else {
			merged = append(merged, r)
		}
	}

	for _, r := range localRules {
		if action, ok := localMap[r.Pattern]; ok {
			merged = append(merged, Rule{Pattern: r.Pattern, Action: action})
			delete(localMap, r.Pattern)
		}
	}

	return merged
}

// GetSuggestedPattern returns a suggested policy pattern for the given tool
// invocation. For exec commands, it returns empty string to prevent
// "Allow Always" from being offered -- exec should always require per-command
// approval since any shell command could be destructive.
func GetSuggestedPattern(toolName, args string) string {
	sub, _ := NormalizeCoderArgs(args)
	if sub == "" {
		return toolName
	}
	// exec commands must NEVER get a blanket "Allow Always" pattern
	if strings.EqualFold(sub, "exec") {
		return ""
	}
	return fmt.Sprintf("%s %s", toolName, sub)
}

func (pm *PolicyManager) ActivePolicyPath() string {
	pm.mu.RLock()
	defer pm.mu.RUnlock()
	return pm.configPath
}

func (pm *PolicyManager) LocalPolicyPath() string {
	pm.mu.RLock()
	defer pm.mu.RUnlock()
	return pm.localPath
}

func (pm *PolicyManager) LocalMergeEnabled() bool {
	pm.mu.RLock()
	defer pm.mu.RUnlock()
	return pm.mergeLocal
}

func (pm *PolicyManager) RulesCount() int {
	pm.mu.RLock()
	defer pm.mu.RUnlock()
	return len(pm.Rules)
}

func (pm *PolicyManager) LastMatchedRule() (Rule, bool) {
	pm.mu.RLock()
	defer pm.mu.RUnlock()
	if pm.lastRule == nil {
		return Rule{}, false
	}
	return *pm.lastRule, true
}
