// Package tool is maven's act executor: it runs the ENABLED tools from the
// store's allowlist, and drafts 'proposed' scaffolds for acts that aren't on
// it yet.
//
// Boundary discipline (docs/design.md § "Tool registration — drafting is suggest,
// enabling is act"):
//
//   - The store is the allowlist. Only status='enabled' rows run. A verb not
//     on it → refuse ("not on the list → refuse, don't improvise") and draft a
//     'proposed' scaffold instead. Enabling a proposal is a human act on an
//     authed surface (mavweb), gated at AuthStepUp — never the voice path, so
//     a compromised router can't grant itself a capability.
//   - Args are passed as argv, NEVER through a shell. STT text lands as
//     positional arguments to Cmd; there is no `sh -c`, so "restart nginx;
//     rm -rf" can't inject — the tail is one argv element to the named binary.
//   - An enabled row whose cmd is ["smarthome", "<entity_id>", "<service>"] is
//     a Home Assistant service call instead of a process (Vikunja #256), by
//     exactly the same trick and under exactly the same rules. Control rows are
//     always destructive, so flipping something in his flat always costs a
//     confirm turn.
//   - An enabled row whose cmd is ["mcp", "<server>", "<tool>"] is a call to a
//     configured MCP server instead of a process (Vikunja #251). It goes
//     through every rule above unchanged — enabled, and confirmed if it
//     mutates — because the store is still the allowlist; only the dispatch at
//     the bottom of Exec differs.
//   - Destructive tools don't run on first hearing: Exec returns ErrNeedsConfirm
//     and the handler runs a confirm turn ("выполнить X? да/нет"); only a
//     confirmed re-Exec runs them. A gate assumes a fully-formed action, which
//     an enabled+matched act is (docs/design.md § "Confirmation is not one
//     mechanism").
package tool

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"log"
	"os/exec"
	"strings"
	"time"

	"github.com/kami/maven/internal/ipc"
	"github.com/kami/maven/internal/mcp"
	"github.com/kami/maven/internal/router"
	"github.com/kami/maven/internal/smarthome"
)

// API — the narrow slice of ipc.CoreAPI the executor and matcher need. Backed
// in-process by the daemon's store adapter (a direct sqlite query per call —
// acts are rare, personal-scale; no cache).
type API interface {
	LookupTool(ctx context.Context, name string) (ipc.Tool, error)
	ListTools(ctx context.Context, status string) ([]ipc.Tool, error)
	ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error)
}

var (
	// ErrNotEnabled — the fn isn't an enabled tool (absent, or still proposed).
	ErrNotEnabled = errors.New("tool not on the enabled allowlist")
	// ErrNeedsConfirm — the fn is enabled but destructive; needs a confirm turn.
	ErrNeedsConfirm = errors.New("destructive tool needs confirmation")
	// ErrNotConnected — the row is enabled and well formed, but the thing it
	// dispatches to is not wired: the mcp block was dropped from the config
	// while enabled MCP rows remained, or the same for the house. Held apart
	// from ErrNotEnabled because the act path turns that one into a fresh
	// proposal, and drafting a new proposal for a tool that already exists and
	// is enabled is a lie about what is wrong.
	ErrNotConnected = errors.New("tool is enabled but its backend is not connected")
	// ErrNeedsAuthedSurface — the row is enabled and the act is understood,
	// and its tier is one a spoken "да" may not authorise (risk.go,
	// TierIrreversible). Held apart from ErrNeedsConfirm because there is no
	// confirm turn that would help: asking again would imply the second answer
	// changes the outcome.
	ErrNeedsAuthedSurface = errors.New("tool is irreversible and voice may not authorise it")
)

// MCPCaller is the seam for an act that is an MCP tool call rather than a
// process (Vikunja #251). internal/mcp.Manager satisfies it via CallPositional.
// nil ⇒ MCP is not configured, and an MCP row refuses to run rather than
// silently doing nothing.
type MCPCaller interface {
	CallPositional(ctx context.Context, server, tool string, args []string) (string, error)
}

// HomeCaller is the seam for an act that is a Home Assistant service call
// rather than a process (Vikunja #256). internal/smarthome.Client satisfies it.
// nil ⇒ the house is not configured, and a house row refuses to run rather than
// silently doing nothing.
type HomeCaller interface {
	CallService(ctx context.Context, entityID, service string) (string, error)
}

// Executor runs enabled tools. run is the exec seam (default: real process);
// tests swap it. timeout bounds each invocation.
type Executor struct {
	api     API
	timeout time.Duration
	run     func(ctx context.Context, argv []string) (string, error)
	mcp     MCPCaller
	home    HomeCaller
}

// NewExecutor builds the executor. timeout<=0 defaults to 30s.
func NewExecutor(api API, timeout time.Duration) *Executor {
	if timeout <= 0 {
		timeout = 30 * time.Second
	}
	return &Executor{api: api, timeout: timeout, run: runProcess}
}

// WithMCP attaches the MCP caller. Called once at wiring time when the mcp
// config block is present; without it, a row whose cmd is ["mcp", …] refuses.
func (e *Executor) WithMCP(m MCPCaller) *Executor {
	e.mcp = m
	return e
}

// WithHome attaches the Home Assistant caller. Called once at wiring time when
// the smarthome block is enabled; without it, a row whose cmd is
// ["smarthome", …] refuses.
func (e *Executor) WithHome(h HomeCaller) *Executor {
	e.home = h
	return e
}

// Exec looks up name in the store and runs Cmd+args as argv (no shell).
// confirmed=true is the second turn of a destructive act (the user said "да");
// it bypasses the ErrNeedsConfirm gate. Non-enabled ⇒ ErrNotEnabled; a
// destructive tool with confirmed=false ⇒ ErrNeedsConfirm; an irreversible one
// ⇒ ErrNeedsAuthedSurface, confirmed or not.
//
// Exec IS the voice path. Nothing else calls it, which is why the tier check
// needs no surface argument: the authority it can offer a tool is a spoken
// "да", and TierIrreversible says that is not enough.
func (e *Executor) Exec(ctx context.Context, name string, args []string, confirmed bool) (string, error) {
	t, err := e.api.LookupTool(ctx, name)
	if errors.Is(err, ipc.ErrToolNotFound) {
		return "", ErrNotEnabled
	}
	if err != nil {
		return "", err
	}
	if t.Status != "enabled" {
		return "", ErrNotEnabled
	}
	// The tier decides, not the column (Vikunja #449). RiskOf reads the row and
	// answers the three questions the boolean never did: which acts are
	// destructive, whether a confirm sticks (it never does), and what an
	// unrecognised shape inherits (the confirm turn).
	policy := PolicyFor(RiskOf(t))
	if !policy.VoiceMayRun {
		return "", ErrNeedsAuthedSurface
	}
	if policy.Confirm && !confirmed {
		return "", ErrNeedsConfirm
	}
	// An MCP row is a call to a configured server, not a process. Everything
	// above still applied: it had to be enabled, and a mutating one had to be
	// confirmed. Only the dispatch differs.
	if server, remote, ok := mcp.ParseCmd(t.Cmd); ok {
		if e.mcp == nil {
			return "", fmt.Errorf("%w: %s is an MCP tool and no mcp block is configured", ErrNotConnected, name)
		}
		ctx, cancel := context.WithTimeout(ctx, e.timeout)
		defer cancel()
		return e.mcp.CallPositional(ctx, server, remote, args)
	}
	// A house row is a Home Assistant service call, not a process (Vikunja
	// #256). Same story: enabled, and confirmed — every control row is
	// destructive, because there is no read-only way to turn the heating off.
	// The spoken args are dropped on purpose: the entity and the service come
	// from the row Kami enabled, so a router that misheard can pick the wrong
	// row but can never compose a target of its own.
	if entityID, service, ok := smarthome.ParseCmd(t.Cmd); ok {
		if e.home == nil {
			return "", fmt.Errorf("%w: %s is a house tool and no smarthome block is configured", ErrNotConnected, name)
		}
		// The confirm turn on a house row is structural, not a column. The
		// proposal is written destructive=true, but /tools writes the checkbox
		// straight through on enable (destructive=excluded.destructive), so
		// unticking it once turned home_lock_front_door_unlock into a row that
		// ran on first hearing. Nothing any surface writes can remove the
		// second turn from a physical device.
		if !confirmed {
			return "", ErrNeedsConfirm
		}
		ctx, cancel := context.WithTimeout(ctx, e.timeout)
		defer cancel()
		return e.home.CallService(ctx, entityID, service)
	}
	argv := append(append([]string(nil), t.Cmd...), args...)
	if len(argv) == 0 {
		return "", ErrNotEnabled
	}
	ctx, cancel := context.WithTimeout(ctx, e.timeout)
	defer cancel()
	return e.run(ctx, argv)
}

func runProcess(ctx context.Context, argv []string) (string, error) {
	cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
	var buf bytes.Buffer
	cmd.Stdout = &buf
	cmd.Stderr = &buf
	err := cmd.Run()
	out := strings.TrimSpace(buf.String())
	if err != nil {
		return out, fmt.Errorf("run %v: %w", argv, err)
	}
	return out, nil
}

// Matcher — a router.ActMatcher whose allowlist is the live set of enabled
// tool names (one source of truth with the executor). Match delegates to the
// router's default prefix logic over the current names. The interface's Match
// has no ctx, so it queries with a background context — an in-process sqlite
// read on the daemon.
type Matcher struct{ api API }

// NewMatcher builds a store-backed act matcher.
func NewMatcher(api API) *Matcher { return &Matcher{api: api} }

func (m *Matcher) names() []string {
	ts, err := m.api.ListTools(context.Background(), "enabled")
	if err != nil {
		log.Printf("tool: list enabled tools: %v", err)
		return nil
	}
	names := make([]string, len(ts))
	for i, t := range ts {
		names[i] = t.Name
	}
	return names
}

// Allowlist — the enabled verbs (for stage-0 grammar wiring / introspection).
func (m *Matcher) Allowlist() []string { return m.names() }

// Match — longest-verb-first prefix match over the live enabled allowlist.
func (m *Matcher) Match(utterance string) (string, []string, bool) {
	return router.DefaultActMatcher{Fns: m.names()}.Match(utterance)
}
