// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program.  If not, see <https://www.gnu.org/licenses/>.

package bootstrap

import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"

	"code.vikunja.io/veans/internal/auth"
	"code.vikunja.io/veans/internal/output"
)

// veansPrimeCommand is the literal command line every hook ends up invoking.
// Centralising it here keeps the install logic and the duplicate-detection
// reading the same string.
const veansPrimeCommand = "veans prime"

// ClaudeCodeHookEvents enumerates every Claude Code lifecycle event that
// veans wires `veans prime` into. Both the auto-installer in this file and
// the manual-install snippet rendered by the `init` command iterate this
// list so the two paths can never drift.
var ClaudeCodeHookEvents = []string{"SessionStart", "PreCompact"}

// ClaudeCodeSettingsRelPath is the per-repo Claude Code settings file that
// installClaudeCodeHook merges into. Exported so the `init` command can name
// it in the manual-install blurb without re-typing the literal.
const ClaudeCodeSettingsRelPath = ".claude/settings.json"

// OpenCodePluginRelPath is the per-repo path the OpenCode plugin is written
// to. Same rationale as ClaudeCodeSettingsRelPath.
const OpenCodePluginRelPath = ".opencode/plugin/veans-prime.ts"

// OpenCodePluginSnippet is the exact TypeScript plugin written to disk by
// installOpenCodeHook. Re-exported verbatim for the manual-install path so a
// copy-pasted snippet is byte-for-byte what the installer would have
// produced.
const OpenCodePluginSnippet = `// Auto-generated by 'veans init'. Re-emits the veans agent prompt at the
// start of every OpenCode session and before every compaction. See
// https://github.com/go-vikunja/vikunja/tree/main/veans for context.
export const VeansPrime = {
	event: ["session.start", "compact.before"],
	handler: async ({ exec }: { exec: (cmd: string) => Promise<unknown> }) =>
		exec("veans prime"),
}
`

// ClaudeCodeHookSnippet renders the JSON fragment a user would paste into
// .claude/settings.json to get the same wiring the auto-installer performs.
// Generated from ClaudeCodeHookEvents + veansPrimeCommand so a new event
// added to the install list automatically shows up in the manual snippet
// too.
func ClaudeCodeHookSnippet() string {
	hooks := map[string]any{}
	for _, event := range ClaudeCodeHookEvents {
		hooks[event] = []any{
			map[string]any{
				"hooks": []any{
					map[string]any{"type": "command", "command": veansPrimeCommand},
				},
			},
		}
	}
	buf, err := json.MarshalIndent(map[string]any{"hooks": hooks}, "", "  ")
	if err != nil {
		// MarshalIndent on a hand-built map[string]any can't realistically
		// fail; fall back so callers never see an empty snippet.
		return fmt.Sprintf(`{"hooks": {"SessionStart": [{"hooks": [{"type": "command", "command": %q}]}]}}`, veansPrimeCommand)
	}
	return string(buf)
}

// AgentHookChoice captures the user's per-agent install decision so the
// orchestration in bootstrap.Init can hand the per-repo set of choices
// off to the install routines below.
type AgentHookChoice struct {
	ClaudeCode bool
	OpenCode   bool
}

// offerAgentHooks asks the user — one yes/no per agent — which integrations
// they want veans to wire up. Callers pre-populate `choices` from CLI flags
// (--install-claude / --install-opencode); only the unset slots get
// prompted. When `noHooks` is true we skip everything and return the empty
// choice, mirroring the old "just print the snippets" behaviour.
func offerAgentHooks(p auth.Prompter, w io.Writer, choices AgentHookChoice, claudeFlagSet, opencodeFlagSet, noHooks bool) (AgentHookChoice, error) {
	if noHooks {
		return AgentHookChoice{}, nil
	}
	if !claudeFlagSet {
		yes, err := promptYesNo(p, w,
			"Wire `veans prime` into Claude Code (.claude/settings.json)?", true)
		if err != nil {
			return choices, err
		}
		choices.ClaudeCode = yes
	}
	if !opencodeFlagSet {
		yes, err := promptYesNo(p, w,
			"Wire `veans prime` into OpenCode (.opencode/plugin/veans-prime.ts)?", false)
		if err != nil {
			return choices, err
		}
		choices.OpenCode = yes
	}
	return choices, nil
}

// installAgentHooks writes the requested integrations to disk relative to
// repoRoot. Each install is idempotent: if the hook entry is already there,
// it's left alone; if the settings file is missing, it's created with a
// fresh skeleton.
func installAgentHooks(repoRoot string, choices AgentHookChoice, w io.Writer) error {
	if choices.ClaudeCode {
		path, action, err := installClaudeCodeHook(repoRoot)
		if err != nil {
			return output.Wrap(output.CodeUnknown, err, "install Claude Code hook: %v", err)
		}
		progress(w, "%s Claude Code hook in %s", action, path)
	}
	if choices.OpenCode {
		path, action, err := installOpenCodeHook(repoRoot)
		if err != nil {
			return output.Wrap(output.CodeUnknown, err, "install OpenCode hook: %v", err)
		}
		progress(w, "%s OpenCode hook in %s", action, path)
	}
	return nil
}

// installClaudeCodeHook merges (or creates) `<repoRoot>/.claude/settings.json`
// so SessionStart and PreCompact invoke `veans prime`. Returns the path,
// a human verb describing what happened ("Wrote", "Updated", "Already
// configured"), and any error.
func installClaudeCodeHook(repoRoot string) (string, string, error) {
	path := filepath.Join(repoRoot, filepath.FromSlash(ClaudeCodeSettingsRelPath))
	settings, existed, err := readJSONOrEmpty(path)
	if err != nil {
		return path, "", err
	}
	changed := false
	for _, event := range ClaudeCodeHookEvents {
		if ensureClaudeHook(settings, event) {
			changed = true
		}
	}
	if !changed {
		return path, "Already configured", nil
	}
	if err := writeJSON(path, settings); err != nil {
		return path, "", err
	}
	if existed {
		return path, "Updated", nil
	}
	return path, "Wrote", nil
}

// ensureClaudeHook walks the settings object and appends a `veans prime`
// command entry under hooks.<event> if one isn't already present. Returns
// true iff the structure was modified.
//
// Claude Code's settings shape:
//
//	{
//	  "hooks": {
//	    "SessionStart": [
//	      { "hooks": [ { "type": "command", "command": "veans prime" } ] }
//	    ]
//	  }
//	}
func ensureClaudeHook(settings map[string]any, event string) bool {
	hooks := mapAt(settings, "hooks")
	entries, _ := hooks[event].([]any)

	for _, entry := range entries {
		entryMap, ok := entry.(map[string]any)
		if !ok {
			continue
		}
		inner, _ := entryMap["hooks"].([]any)
		for _, h := range inner {
			hmap, ok := h.(map[string]any)
			if !ok {
				continue
			}
			if str(hmap, "type") == "command" && str(hmap, "command") == veansPrimeCommand {
				return false
			}
		}
	}

	entries = append(entries, map[string]any{
		"hooks": []any{
			map[string]any{"type": "command", "command": veansPrimeCommand},
		},
	})
	hooks[event] = entries
	settings["hooks"] = hooks
	return true
}

// installOpenCodeHook writes `<repoRoot>/.opencode/plugin/veans-prime.ts`
// if missing. Existing files are left alone (TypeScript merging is out of
// scope; the user can edit by hand).
func installOpenCodeHook(repoRoot string) (string, string, error) {
	path := filepath.Join(repoRoot, filepath.FromSlash(OpenCodePluginRelPath))
	if _, err := os.Stat(path); err == nil {
		return path, "Already configured", nil
	} else if !errors.Is(err, os.ErrNotExist) {
		return path, "", err
	}
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		return path, "", err
	}
	if err := os.WriteFile(path, []byte(OpenCodePluginSnippet), 0o644); err != nil {
		return path, "", err
	}
	return path, "Wrote", nil
}

// readJSONOrEmpty reads `path` as JSON or returns an empty object if the
// file doesn't exist. The `existed` flag tells the caller whether the
// resulting object was loaded from disk (so it can decide between
// "Wrote" and "Updated").
func readJSONOrEmpty(path string) (out map[string]any, existed bool, err error) {
	buf, err := os.ReadFile(path)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return map[string]any{}, false, nil
		}
		return nil, false, err
	}
	out = map[string]any{}
	if len(buf) == 0 {
		return out, true, nil
	}
	if err := json.Unmarshal(buf, &out); err != nil {
		return nil, true, fmt.Errorf("parse %s: %w", path, err)
	}
	return out, true, nil
}

// writeJSON encodes `data` with two-space indent (Claude Code's house
// style) and a trailing newline, creating parent directories as needed.
// Settings files written here may end up holding provider API keys, so we
// default new files to 0o600 and preserve the existing mode on update so a
// user who has tightened the file (e.g. to 0o600 explicitly, or chmod'd it
// further) doesn't see their permissions widened on the next write.
func writeJSON(path string, data map[string]any) error {
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		return err
	}
	buf, err := json.MarshalIndent(data, "", "  ")
	if err != nil {
		return err
	}
	buf = append(buf, '\n')
	mode := os.FileMode(0o600)
	if info, err := os.Stat(path); err == nil {
		mode = info.Mode().Perm()
	}
	return os.WriteFile(path, buf, mode)
}

// mapAt returns the map at key `k` on `m`, creating it if missing or if
// the existing value is the wrong type. Lets ensureClaudeHook treat the
// JSON object tree as if it were always well-shaped.
func mapAt(m map[string]any, k string) map[string]any {
	if v, ok := m[k].(map[string]any); ok {
		return v
	}
	v := map[string]any{}
	m[k] = v
	return v
}

func str(m map[string]any, k string) string {
	s, _ := m[k].(string)
	return s
}

// promptYesNo reads a Y/n (or y/N) answer with the given default.
func promptYesNo(p auth.Prompter, w io.Writer, question string, defaultYes bool) (bool, error) {
	tag := "[Y/n]"
	if !defaultYes {
		tag = "[y/N]"
	}
	fmt.Fprintln(w, question)
	ans, err := p.ReadLine(tag + " ")
	if err != nil {
		return defaultYes, err
	}
	switch strings.ToLower(strings.TrimSpace(ans)) {
	case "":
		return defaultYes, nil
	case "y", "yes":
		return true, nil
	case "n", "no":
		return false, nil
	}
	return defaultYes, nil
}
