package installsource

import (
	"encoding/json"
	"fmt"
	"path/filepath"
	"sort"
	"strings"

	"reasonix/internal/config"
	fileencoding "reasonix/internal/fileutil/encoding"
)

// mcpEntryAction assembles the DTO for a single MCP server install. The
// caller decides whether apply=true actually runs cfg.UpsertPlugin +
// SaveTo + connectMCP.
func (t *installSourceTool) mcpEntryAction(req request, e config.PluginEntry, source string) action {
	scope := t.installScope(req, "mcp", source)
	// install_source is an explicit user action. Preserve that provenance for
	// the live connector so a newly installed server is usable immediately,
	// while still identifying project-scoped persistence accurately enough for
	// the host to record the exact durable launch grant used on the next boot.
	if scope == "project" {
		e.Source = config.MCPSourceProjectConfig
	} else {
		e.Source = config.MCPSourceUserConfig
	}
	var normalizedCommand bool
	e, normalizedCommand = config.NormalizePluginCommandLine(e)
	// Tier comes from the call (req.Tier) or the entry; either way we
	// validate. An unrecognised tier is silently downgraded to "background" by
	// normalizeTier — we capture the original value to surface a warning.
	desired := firstNonEmpty(req.Tier, e.Tier)
	norm, ok := normalizeTier(desired)
	e.Tier = norm
	a := action{
		Kind:       "mcp",
		Action:     "install_mcp_server",
		Name:       e.Name,
		Source:     source,
		ConfigPath: t.configPath(scope),
		Scope:      scope,
		Transport:  pluginTransport(e),
		URL:        e.URL,
		Command:    e.Command,
		Args:       e.Args,
		Env:        e.Env,
		Headers:    e.Headers,
		entry:      e,
	}
	if !ok && strings.TrimSpace(desired) != "" {
		a.RiskReasons = append(a.RiskReasons, fmt.Sprintf("tier %q is unknown; treating as background", desired))
	}
	if normalizedCommand {
		a.RiskReasons = append(a.RiskReasons, "split a pasted MCP command line into command and args")
	}
	a.RiskLevel, a.RiskReasons = mcpActionRisk(e, a.RiskReasons)
	return a
}

// mcpActionRisk classifies a single MCP install. An entry with auth headers,
// an `eager` tier, or a package-name source is medium. An entry with auth
// material and an out-of-tree URL is high.
func mcpActionRisk(e config.PluginEntry, reasons []string) (RiskLevel, []string) {
	level := RiskMedium
	hasAuth := false
	for k, v := range e.Headers {
		if strings.EqualFold(k, "Authorization") || strings.Contains(strings.ToLower(v), "bearer") || strings.Contains(strings.ToLower(v), "token") {
			hasAuth = true
			reasons = append(reasons, "sends auth headers to "+e.URL)
		}
	}
	if e.Tier == "eager" {
		level = RiskHigh
		reasons = append(reasons, "tier=eager blocks startup until the handshake completes")
	}
	if hasAuth && level == RiskMedium {
		level = RiskHigh
	}
	return level, reasons
}

// remoteMCPAction builds a server entry from a URL alone. The default
// transport is http unless the URL's path smells like SSE.
func (t *installSourceTool) remoteMCPAction(req request, sourceURL string) action {
	transport := req.Transport
	if transport == "" || transport == "auto" {
		transport = "http"
		if strings.Contains(strings.ToLower(sourceURL), "sse") {
			transport = "sse"
		}
	}
	name := strings.TrimSpace(req.Name)
	if name == "" {
		name = mcpNameFromURL(sourceURL)
	}
	e := config.PluginEntry{
		Name:    name,
		Type:    transport,
		URL:     sourceURL,
		Headers: cleanMap(req.Headers),
		Tier:    req.Tier,
	}
	return t.mcpEntryAction(req, e, sourceURL)
}

// localExecutableMCPAction treats a chmod +x'd local file as a stdio MCP
// server. By default the file itself becomes the command; callers may override
// the command to wrap the source with an interpreter.
func (t *installSourceTool) localExecutableMCPAction(req request, path string) action {
	name := strings.TrimSpace(req.Name)
	if name == "" {
		name = sanitizeName(strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)))
	}
	command := strings.TrimSpace(req.Command)
	if command == "" {
		command = path
	}
	e := config.PluginEntry{
		Name:    name,
		Command: command,
		Args:    append([]string(nil), req.Args...),
		Env:     cleanMap(req.Env),
		Tier:    req.Tier,
	}
	return t.mcpEntryAction(req, e, path)
}

// packageMCPAction treats the source as an npm package name and constructs
// the canonical `npx -y <pkg>` invocation. The caller can override the
// command and args for non-npm package sources.
func (t *installSourceTool) packageMCPAction(req request) action {
	name := strings.TrimSpace(req.Name)
	if name == "" {
		name = sanitizeName(strings.TrimPrefix(req.Source, "@"))
		if i := strings.LastIndex(name, "/"); i >= 0 {
			name = name[i+1:]
		}
	}
	command := strings.TrimSpace(req.Command)
	args := append([]string(nil), req.Args...)
	if command == "" {
		command = "npx"
		args = []string{"-y", req.Source}
	}
	e := config.PluginEntry{
		Name:    name,
		Command: command,
		Args:    args,
		Env:     cleanMap(req.Env),
		Tier:    req.Tier,
	}
	return t.mcpEntryAction(req, e, req.Source)
}

// readMCPJSON reads a .mcp.json file from disk and parses it. It is a
// convenience wrapper used by planLocal; the parser itself is exported as
// parseMCPJSON for tests.
func readMCPJSON(path string) ([]config.PluginEntry, []string, error) {
	b, err := fileencoding.ReadFileUTF8(path)
	if err != nil {
		return nil, nil, err
	}
	entries, warnings, err := parseMCPJSON(b)
	if err != nil {
		return nil, warnings, err
	}
	return entries, warnings, nil
}

// parseMCPJSON extracts mcpServers entries from a .mcp.json-style document.
// Warnings are returned for non-fatal anomalies (unknown tier values) and
// collected separately so a typo does not refuse an otherwise valid file.
func parseMCPJSON(b []byte) ([]config.PluginEntry, []string, error) {
	var raw struct {
		MCPServers map[string]struct {
			Type                  string            `json:"type"`
			Command               string            `json:"command"`
			Args                  []string          `json:"args"`
			Env                   map[string]string `json:"env"`
			URL                   string            `json:"url"`
			Headers               map[string]string `json:"headers"`
			AutoStart             *bool             `json:"auto_start"`
			StartupTimeoutSeconds int               `json:"startup_timeout_seconds"`
			CallTimeoutSeconds    int               `json:"call_timeout_seconds"`
			ToolTimeoutSeconds    map[string]int    `json:"tool_timeout_seconds"`
			Tier                  string            `json:"tier"`
		} `json:"mcpServers"`
	}
	if err := json.Unmarshal(b, &raw); err != nil {
		return nil, nil, newErr(ErrInvalidManifest, "could not parse .mcp.json: %v", err)
	}
	if len(raw.MCPServers) == 0 {
		return nil, nil, newErr(ErrManifestMissing, ".mcp.json has no mcpServers")
	}
	names := make([]string, 0, len(raw.MCPServers))
	for name := range raw.MCPServers {
		names = append(names, name)
	}
	sort.Strings(names)
	out := make([]config.PluginEntry, 0, len(names))
	var warnings []string
	for _, name := range names {
		s := raw.MCPServers[name]
		// Validate the raw transport before normalizeTransport gets a
		// chance to silently map an unknown value to "auto"/"stdio".
		rawType := strings.ToLower(strings.TrimSpace(s.Type))
		if rawType != "" && rawType != "stdio" && rawType != "http" && rawType != "sse" && rawType != "streamable-http" {
			return nil, warnings, newErr(ErrInvalidManifest, "MCP server %q has unknown transport %q", name, s.Type)
		}
		typ := normalizeTransport(s.Type)
		if typ == "auto" {
			if strings.TrimSpace(s.URL) != "" {
				typ = "http"
			} else {
				typ = "stdio"
			}
		}
		tier, ok := normalizeTier(s.Tier)
		if !ok && strings.TrimSpace(s.Tier) != "" {
			warnings = append(warnings, fmt.Sprintf("%s: tier %q is unknown; treating as background", name, s.Tier))
		}
		e := config.PluginEntry{
			Name:                  name,
			Type:                  typ,
			Command:               strings.TrimSpace(s.Command),
			Args:                  append([]string(nil), s.Args...),
			Env:                   cleanMap(s.Env),
			URL:                   strings.TrimSpace(s.URL),
			Headers:               cleanMap(s.Headers),
			AutoStart:             s.AutoStart,
			StartupTimeoutSeconds: s.StartupTimeoutSeconds,
			CallTimeoutSeconds:    s.CallTimeoutSeconds,
			ToolTimeoutSeconds:    s.ToolTimeoutSeconds,
			Tier:                  tier,
		}
		// An empty Type is the canonical "stdio" form; keep it that way so
		// the rest of the config layer (UpsertPlugin / ShouldAutoStart)
		// can treat "" and "stdio" identically.
		if e.Type == "stdio" {
			e.Type = ""
		}
		normalized, changed := config.NormalizePluginCommandLine(e)
		e = normalized
		if changed {
			warnings = append(warnings, fmt.Sprintf("%s: split a pasted MCP command line into command and args", name))
		}
		if err := validateMCPEntry(e); err != nil {
			return nil, warnings, err
		}
		out = append(out, e)
	}
	return out, warnings, nil
}

// validateMCPEntry enforces the per-transport required fields. It is the
// last line of defense before a server entry is persisted.
func validateMCPEntry(e config.PluginEntry) error {
	name := strings.TrimSpace(e.Name)
	if name == "" {
		return newErr(ErrInvalidManifest, "MCP server name is required")
	}
	if !config.IsValidSkillName(name) {
		return newErr(ErrInvalidManifest, "MCP server name %q is invalid; use letters, digits, '.', '_', or '-', starting with a letter or digit, up to 64 characters", e.Name)
	}
	// Reject explicit unknown transports. Without this check, normalizeTransport
	// would silently map "carrier-pigeon" -> "auto" -> "stdio" and the entry
	// would be persisted with the wrong effective type.
	rawType := strings.ToLower(strings.TrimSpace(e.Type))
	if rawType != "" && rawType != "stdio" && rawType != "http" && rawType != "sse" {
		return newErr(ErrInvalidManifest, "MCP server %q has unknown transport %q", e.Name, e.Type)
	}
	switch pluginTransport(e) {
	case "stdio":
		if strings.TrimSpace(e.Command) == "" {
			return newErr(ErrInvalidManifest, "MCP server %q is stdio but has no command", e.Name)
		}
	case "http", "sse":
		if strings.TrimSpace(e.URL) == "" {
			return newErr(ErrInvalidManifest, "MCP server %q is remote but has no url", e.Name)
		}
	default:
		return newErr(ErrInvalidManifest, "MCP server %q has unknown transport %q", e.Name, e.Type)
	}
	return nil
}
