package run

import (
	"fmt"
	"sort"
	"strings"

	"github.com/smallnest/pigo/internal/agentcore"
)

// ToolPolicy is the user-declared tool boundary for a run: the --allowed-tools
// whitelist and the --disallowed-tools blacklist, already normalized by
// SplitToolNames. It is passed as one value rather than two adjacent []string
// parameters so the two lists cannot be swapped at a call site — silently
// inverting a security boundary is exactly the bug that must be impossible.
//
// The zero value means "no restriction" and every operation on it is a no-op.
type ToolPolicy struct {
	Allow []string
	Deny  []string
}

// NewToolPolicy normalizes raw flag values into a policy.
func NewToolPolicy(allowed, disallowed []string) ToolPolicy {
	return ToolPolicy{Allow: SplitToolNames(allowed), Deny: SplitToolNames(disallowed)}
}

// IsZero reports whether the policy constrains nothing.
func (p ToolPolicy) IsZero() bool { return len(p.Allow) == 0 && len(p.Deny) == 0 }

// SplitToolNames normalizes raw --allowed-tools / --disallowed-tools values into
// a flat list of tool names. Each value may itself be a comma-separated list, so
// `--allowed-tools "read,grep"` and `--allowed-tools read --allowed-tools grep`
// are equivalent. Entries are lowercased and trimmed, and empty entries dropped,
// so `"read, ,grep"` yields [read grep]. Matching is case-insensitive on purpose:
// users coming from Claude Code write `Read`/`Bash`, which must hit pigo's
// `read`/`bash`.
func SplitToolNames(values []string) []string {
	if len(values) == 0 {
		return nil
	}
	out := make([]string, 0, len(values))
	for _, v := range values {
		for _, part := range strings.Split(v, ",") {
			if n := normalizeToolName(part); n != "" {
				out = append(out, n)
			}
		}
	}
	if len(out) == 0 {
		return nil
	}
	return out
}

// normalizeToolName is the single definition of how a tool name is compared:
// surrounding whitespace is insignificant and case is ignored.
//
// Note this is deliberately more lenient than runtime.filterToolsByName, which
// backs a skill frontmatter's allowed-tools and matches case-sensitively. The
// two are not unified: this policy is user-facing CLI input where Claude-Code
// habits (Read/Bash) must hit read/bash, whereas skill frontmatter is authored
// against pigo's canonical lowercase names. Keep them separate on purpose.
func normalizeToolName(s string) string {
	return strings.ToLower(strings.TrimSpace(s))
}

// ToolPolicyError reports --allowed-tools / --disallowed-tools entries that name
// no existing tool. It is a usage error — the caller maps it to exit code 2 —
// because silently ignoring a typo is the worst outcome available: the user
// believes a boundary is in force when it is not.
type ToolPolicyError struct {
	// UnknownAllowed and UnknownDisallowed are the unrecognized names from each
	// flag. Both are reported in one error so a user with two typos fixes both in
	// one round rather than one per run.
	UnknownAllowed    []string
	UnknownDisallowed []string
	// Available is the sorted set of names that would have been accepted.
	Available []string
}

func (e *ToolPolicyError) Error() string {
	var parts []string
	if len(e.UnknownAllowed) > 0 {
		parts = append(parts, fmt.Sprintf("--allowed-tools: unknown tool %s", quoteNames(e.UnknownAllowed)))
	}
	if len(e.UnknownDisallowed) > 0 {
		parts = append(parts, fmt.Sprintf("--disallowed-tools: unknown tool %s", quoteNames(e.UnknownDisallowed)))
	}
	return fmt.Sprintf("%s (available: %s)", strings.Join(parts, "; "), strings.Join(e.Available, ", "))
}

// quoteNames renders names as a comma-separated quoted list.
func quoteNames(names []string) string {
	out := make([]string, len(names))
	for i, n := range names {
		out[i] = fmt.Sprintf("%q", n)
	}
	return strings.Join(out, ", ")
}

// ValidateToolPolicy checks every allow/deny entry against the assembled tool
// set. It must run AFTER the full set exists (builtins + memory + task +
// plugins), because plugin and memory tool names are only known at runtime;
// validating right after flag parsing would reject legitimate plugin names.
//
// An empty tool set (--no-tools) skips validation entirely: there is nothing to
// constrain, and reporting every name as unknown would be noise.
func ValidateToolPolicy(tools []agentcore.AgentTool, policy ToolPolicy) error {
	if len(tools) == 0 || policy.IsZero() {
		return nil
	}
	known := toolNameSet(tools)
	unknownAllow := unknownNames(known, policy.Allow)
	unknownDeny := unknownNames(known, policy.Deny)
	if len(unknownAllow) == 0 && len(unknownDeny) == 0 {
		return nil
	}
	available := make([]string, 0, len(known))
	for n := range known {
		available = append(available, n)
	}
	sort.Strings(available)
	return &ToolPolicyError{
		UnknownAllowed:    unknownAllow,
		UnknownDisallowed: unknownDeny,
		Available:         available,
	}
}

// unknownNames returns the entries of names absent from known, preserving input
// order and dropping duplicates so a name repeated twice is reported once.
func unknownNames(known map[string]struct{}, names []string) []string {
	var out []string
	seen := make(map[string]struct{}, len(names))
	for _, n := range names {
		if _, ok := known[n]; ok {
			continue
		}
		if _, dup := seen[n]; dup {
			continue
		}
		seen[n] = struct{}{}
		out = append(out, n)
	}
	return out
}

// toolNameSet indexes a tool set by normalized name.
func toolNameSet(tools []agentcore.AgentTool) map[string]struct{} {
	set := make(map[string]struct{}, len(tools))
	for _, t := range tools {
		set[normalizeToolName(t.Name())] = struct{}{}
	}
	return set
}

// ApplyToolPolicy narrows a tool set to the allow list and then removes the deny
// list. Both empty means no restriction and the input is returned unchanged, so
// the default path is a true no-op.
//
// Deny runs after allow, which makes deny win when a name appears on both sides.
// That ordering is deliberate: the fail-closed reading of a contradictory policy
// is "do not run it".
//
// This filters the set handed to the model, so it sits at the tool-registration
// layer — strictly before the BeforeToolCall confirmation gate. A removed tool is
// never advertised and never dispatchable, which is why --approve (which only
// waives per-call confirmation) cannot widen the boundary.
func ApplyToolPolicy(tools []agentcore.AgentTool, policy ToolPolicy) []agentcore.AgentTool {
	if len(tools) == 0 || policy.IsZero() {
		return tools
	}
	allowSet := nameSet(policy.Allow)
	denySet := nameSet(policy.Deny)
	out := make([]agentcore.AgentTool, 0, len(tools))
	for _, t := range tools {
		name := normalizeToolName(t.Name())
		if len(allowSet) > 0 {
			if _, ok := allowSet[name]; !ok {
				continue
			}
		}
		if _, denied := denySet[name]; denied {
			continue
		}
		out = append(out, t)
	}
	return out
}

// nameSet indexes already-normalized names for lookup.
func nameSet(names []string) map[string]struct{} {
	if len(names) == 0 {
		return nil
	}
	set := make(map[string]struct{}, len(names))
	for _, n := range names {
		set[n] = struct{}{}
	}
	return set
}

// ChildToolSet builds the tool set for a task sub-agent: the builtins with
// "task" removed (the nesting guard capping delegation depth at one), narrowed by
// the parent's policy.
//
// Inheriting the policy is load-bearing, not a nicety. A child that ignored it
// would be a one-line escape from the boundary: under --disallowed-tools bash the
// model could dispatch a sub-agent and run bash there instead. Any future spawn
// path must route through here for the same reason.
func ChildToolSet(cwd string, policy ToolPolicy) []agentcore.AgentTool {
	return ApplyToolPolicy(BuiltinToolsExcept(cwd, false, "task"), policy)
}
