package store

import (
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"time"
)

// Tool — one act in the allowlist. Scope namespaces tools (e.g. "homelab").
// Cmd is the fixed argv prefix run with the utterance's args appended (no
// shell). Status 'proposed' is a scaffold that drives nothing; 'enabled' is
// the human-flipped, runnable form.
type Tool struct {
	Name        string
	Scope       string
	Cmd         []string
	Destructive bool
	Status      string // proposed | enabled
	Utterance   string // provenance: the utterance that scaffolded a proposal
	CreatedTs   time.Time
	UpdatedTs   time.Time
}

var (
	// ErrToolNotFound — no tool row with this name.
	ErrToolNotFound = errors.New("store: tool not found")
	// ErrToolCmd — an enable supplied an empty argv (an enabled tool must run something).
	ErrToolCmd = errors.New("store: enabled tool needs a non-empty cmd")
)

// ProposeTool inserts a 'proposed' scaffold for name (provenance = utterance)
// if no row for name exists yet. Returns true when a new proposal was written,
// false when a row (proposed or enabled) already existed. maven calls this when
// she classifies an act whose verb isn't on the enabled allowlist — she drafts
// the registration; a human enables it. Never overwrites an enabled tool.
// scope defaults to "homelab" when empty.
func (s *Store) ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error) {
	if scope == "" {
		scope = "homelab"
	}
	res, err := s.db.ExecContext(ctx, `
		INSERT INTO tools (name, scope, cmd, destructive, status, utterance, created_ts, updated_ts)
		VALUES (?, ?, '[]', 0, 'proposed', ?, ?, ?)
		ON CONFLICT(name) DO NOTHING`,
		name, scope, utterance, ts.UnixMilli(), ts.UnixMilli())
	if err != nil {
		return false, fmt.Errorf("propose tool: %w", err)
	}
	n, err := res.RowsAffected()
	if err != nil {
		return false, fmt.Errorf("propose tool: rows affected: %w", err)
	}
	return n > 0, nil
}

// ProposeMCPTool is ProposeTool for a tool discovered on an MCP server
// (Vikunja #251): the proposal already knows what it would run, so cmd and
// destructive are written with it and Kami only has to press enable.
//
// It is still a PROPOSAL. Discovery cannot grant a capability — that is the
// whole reason a server can be configured without its tools becoming live.
// Like ProposeTool it never touches an existing row, so re-discovery on every
// restart is idempotent and cannot silently re-arm a tool that was disabled or
// change the cmd of one already enabled.
//
// The row is NOT what protects him, and it is worth being exact about that.
// cmd is ["mcp", server, tool]: a late-bound reference to a name the remote
// server owns. The tool it points at can be redefined on the far end without
// the row changing at all, so "the cmd cannot change" is true and beside the
// point. fingerprint is what closes that: it records the declared shape (name,
// description, input schema, readOnlyHint) at the time the proposal was
// written, and ReconcileMCPTool compares against it on every later discovery.
// Pass "" for a row with nothing to fingerprint (a Home Assistant device).
func (s *Store) ProposeMCPTool(ctx context.Context, name, scope string, cmd []string, destructive bool, utterance, fingerprint string, ts time.Time) (bool, error) {
	if len(cmd) == 0 {
		return false, ErrToolCmd
	}
	if scope == "" {
		scope = "homelab"
	}
	raw, err := json.Marshal(cmd)
	if err != nil {
		return false, fmt.Errorf("propose mcp tool: %w", err)
	}
	d := 0
	if destructive {
		d = 1
	}
	res, err := s.db.ExecContext(ctx, `
		INSERT INTO tools (name, scope, cmd, destructive, status, utterance, fingerprint, created_ts, updated_ts)
		VALUES (?, ?, ?, ?, 'proposed', ?, ?, ?, ?)
		ON CONFLICT(name) DO NOTHING`,
		name, scope, string(raw), d, utterance, fingerprint, ts.UnixMilli(), ts.UnixMilli())
	if err != nil {
		return false, fmt.Errorf("propose mcp tool: %w", err)
	}
	n, err := res.RowsAffected()
	if err != nil {
		return false, fmt.Errorf("propose mcp tool: rows affected: %w", err)
	}
	return n > 0, nil
}

// ProposeSmartHomeTool is ProposeTool for a controllable device discovered on
// the Home Assistant instance (Vikunja #256). Like ProposeMCPTool the proposal
// already knows what it would run, so cmd is written with it and Kami only has
// to press enable.
//
// It is still a PROPOSAL, and destructive is not a parameter: there is no
// read-only way to turn a lamp off, so every house row carries the confirm
// turn. Re-discovery on every refresh is idempotent — an existing row is never
// touched, so a device he disabled stays disabled.
func (s *Store) ProposeSmartHomeTool(ctx context.Context, name, scope string, cmd []string, utterance string, ts time.Time) (bool, error) {
	return s.ProposeMCPTool(ctx, name, scope, cmd, true, utterance, "", ts)
}

// ToolChange — what ReconcileMCPTool did to an existing row.
type ToolChange struct {
	// Changed — the discovered shape differs from the approved one.
	Changed bool
	// Demoted — the row was enabled and is now 'proposed' again, so the
	// capability is off until a human looks at it a second time.
	Demoted bool
	// Escalated — destructive went from 0 to 1. It never goes the other way.
	Escalated bool
}

// ReconcileMCPTool compares a freshly discovered tool against the row that was
// approved, and escalates when they disagree.
//
// The failure this exists for: day 1 the server offers list_tasks with
// readOnlyHint true, so the row is proposed non-destructive and Kami enables
// it. Day 30 the server is upgraded, or taken over, and list_tasks now writes.
// Insert-or-skip does nothing on that discovery — the row is still enabled,
// still destructive=0 — and the confirm turn never fires, because the flag was
// frozen against a claim the server has since withdrawn.
//
// So: a differing fingerprint drops the row back to 'proposed' and rewrites the
// provenance, and a tool that stopped claiming read-only gets destructive=1.
// destructive is only ever raised, never lowered: relaxing it on the say-so of
// the same server that changed underneath us would undo the point.
//
// A row with an empty stored fingerprint predates this and simply adopts the
// discovered one — an upgrade is not a redefinition.
func (s *Store) ReconcileMCPTool(ctx context.Context, name, fingerprint string, destructive bool, utterance string, ts time.Time) (ToolChange, error) {
	var (
		stored  string
		status  string
		wasDest int
	)
	err := s.db.QueryRowContext(ctx,
		`SELECT fingerprint, status, destructive FROM tools WHERE name = ?`, name).
		Scan(&stored, &status, &wasDest)
	if errors.Is(err, sql.ErrNoRows) {
		return ToolChange{}, ErrToolNotFound
	}
	if err != nil {
		return ToolChange{}, fmt.Errorf("reconcile mcp tool: %w", err)
	}
	var ch ToolChange
	if stored == "" {
		if _, err := s.db.ExecContext(ctx,
			`UPDATE tools SET fingerprint = ?, updated_ts = ? WHERE name = ?`,
			fingerprint, ts.UnixMilli(), name); err != nil {
			return ToolChange{}, fmt.Errorf("reconcile mcp tool: %w", err)
		}
		return ch, nil
	}
	if stored == fingerprint {
		return ch, nil
	}
	ch.Changed = true
	ch.Demoted = status == "enabled"
	d := wasDest
	if destructive && wasDest == 0 {
		d, ch.Escalated = 1, true
	}
	if _, err := s.db.ExecContext(ctx, `
		UPDATE tools
		SET fingerprint = ?, destructive = ?, status = 'proposed', utterance = ?, updated_ts = ?
		WHERE name = ?`,
		fingerprint, d, utterance, ts.UnixMilli(), name); err != nil {
		return ToolChange{}, fmt.Errorf("reconcile mcp tool: %w", err)
	}
	return ch, nil
}

// WithdrawTool disarms a row whose remote tool no longer exists: it drops back
// to 'proposed' and its provenance says why.
//
// Nothing else retracted a proposal, so a tool a server stopped offering kept
// its row forever, and an ENABLED one stayed enabled and failed at call time
// with an internal string the act path does not match. /tools is where he would
// go to find out and it was the one place that did not say. Returns whether the
// row was still enabled.
func (s *Store) WithdrawTool(ctx context.Context, name, utterance string, ts time.Time) (bool, error) {
	res, err := s.db.ExecContext(ctx, `
		UPDATE tools SET status = 'proposed', utterance = ?, updated_ts = ?
		WHERE name = ? AND status = 'enabled'`,
		utterance, ts.UnixMilli(), name)
	if err != nil {
		return false, fmt.Errorf("withdraw tool: %w", err)
	}
	n, err := res.RowsAffected()
	if err != nil {
		return false, fmt.Errorf("withdraw tool: rows affected: %w", err)
	}
	if n > 0 {
		return true, nil
	}
	// Not enabled: still refresh the provenance so the proposed row says it.
	_, err = s.db.ExecContext(ctx,
		`UPDATE tools SET utterance = ?, updated_ts = ? WHERE name = ?`,
		utterance, ts.UnixMilli(), name)
	if err != nil {
		return false, fmt.Errorf("withdraw tool: %w", err)
	}
	return false, nil
}

// EnableTool fills cmd + destructive and flips status to 'enabled'. This is the
// human "enable" act (the authed surface calls it); it upserts so enabling a
// name that was never proposed still works. An empty cmd is refused — an
// enabled tool that runs nothing is a footgun, not a tool.
// scope defaults to "homelab" when empty.
func (s *Store) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error {
	if len(cmd) == 0 {
		return ErrToolCmd
	}
	if scope == "" {
		scope = "homelab"
	}
	raw, err := json.Marshal(cmd)
	if err != nil {
		return fmt.Errorf("enable tool: %w", err)
	}
	d := 0
	if destructive {
		d = 1
	}
	_, err = s.db.ExecContext(ctx, `
		INSERT INTO tools (name, scope, cmd, destructive, status, utterance, created_ts, updated_ts)
		VALUES (?, ?, ?, ?, 'enabled', '', ?, ?)
		ON CONFLICT(name) DO UPDATE SET scope=excluded.scope, cmd=excluded.cmd, destructive=excluded.destructive,
			status='enabled', updated_ts=excluded.updated_ts`,
		name, scope, string(raw), d, ts.UnixMilli(), ts.UnixMilli())
	if err != nil {
		return fmt.Errorf("enable tool: %w", err)
	}
	return nil
}

// DeleteTool permanently removes a tool row. Used for "dismiss" on proposed
// tools — there's no dismissed status, the proposal is simply gone and maven
// can re-propose it later if the same gap is encountered. Idempotent: deleting
// a tool that doesn't exist is a no-op.
func (s *Store) DeleteTool(ctx context.Context, name string) error {
	_, err := s.db.ExecContext(ctx, `DELETE FROM tools WHERE name = ?`, name)
	return err
}

// DisableTool sets a tool's status from 'enabled' back to 'proposed'. This is
// the "disable" act on the authed surface — the tool stays in the store (its
// provenance preserved) but won't run until re-enabled. Idempotent: disabling
// a tool that is already proposed or doesn't exist is a no-op.
func (s *Store) DisableTool(ctx context.Context, name string) error {
	_, err := s.db.ExecContext(ctx,
		`UPDATE tools SET status = 'proposed', updated_ts = ? WHERE name = ? AND status = 'enabled'`,
		time.Now().UnixMilli(), name)
	if err != nil {
		return fmt.Errorf("disable tool: %w", err)
	}
	return nil
}

// LookupTool returns the tool by name. ErrToolNotFound when absent.
func (s *Store) LookupTool(ctx context.Context, name string) (Tool, error) {
	row := s.db.QueryRowContext(ctx, `
		SELECT name, scope, cmd, destructive, status, utterance, created_ts, updated_ts
		  FROM tools WHERE name = ?`, name)
	t, err := scanTool(row)
	if errors.Is(err, sql.ErrNoRows) {
		return Tool{}, ErrToolNotFound
	}
	return t, err
}

// ListTools returns tools filtered by status ("" ⇒ all), name-sorted.
func (s *Store) ListTools(ctx context.Context, status string) ([]Tool, error) {
	q := `SELECT name, scope, cmd, destructive, status, utterance, created_ts, updated_ts FROM tools`
	var args []any
	if status != "" {
		q += ` WHERE status = ?`
		args = append(args, status)
	}
	q += ` ORDER BY name ASC`
	rows, err := s.db.QueryContext(ctx, q, args...)
	if err != nil {
		return nil, fmt.Errorf("list tools: %w", err)
	}
	defer rows.Close()
	var out []Tool
	for rows.Next() {
		t, err := scanTool(rows)
		if err != nil {
			return nil, err
		}
		out = append(out, t)
	}
	return out, rows.Err()
}

// scanner is the shared shape of *sql.Row and *sql.Rows.
type scanner interface{ Scan(...any) error }

func scanTool(sc scanner) (Tool, error) {
	var t Tool
	var cmdJSON string
	var d int
	var created, updated int64
	if err := sc.Scan(&t.Name, &t.Scope, &cmdJSON, &d, &t.Status, &t.Utterance, &created, &updated); err != nil {
		return Tool{}, err
	}
	if err := json.Unmarshal([]byte(cmdJSON), &t.Cmd); err != nil {
		return Tool{}, fmt.Errorf("scan tool %q cmd: %w", t.Name, err)
	}
	t.Destructive = d != 0
	t.CreatedTs = time.UnixMilli(created).UTC()
	t.UpdatedTs = time.UnixMilli(updated).UTC()
	return t, nil
}
