// 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 orchestrates `veans init`. It chains together the steps
// outlined in the plan: probe /info, acquire the human's transient token,
// pick or create a project, designate a Kanban view, bootstrap canonical
// buckets, create the bot user, share the project with the bot, mint the
// bot's API token, and write .veans.yml.
//
// The flow is split into small functions so e2e tests can drive it with
// scripted answers without going through the cobra command surface.
package bootstrap

import (
	"context"
	"errors"
	"fmt"
	"io"
	"os"
	"regexp"
	"sort"
	"strconv"
	"strings"

	"code.vikunja.io/veans/internal/auth"
	"code.vikunja.io/veans/internal/client"
	"code.vikunja.io/veans/internal/config"
	"code.vikunja.io/veans/internal/credentials"
	"code.vikunja.io/veans/internal/output"
	"code.vikunja.io/veans/internal/status"
)

// Options configures Init. All fields are optional unless noted; missing
// values are filled in interactively from Prompter.
type Options struct {
	// ConfigPath is where .veans.yml will be written. Required.
	ConfigPath string

	// Server is the Vikunja base URL (e.g. https://vikunja.example.com).
	// If empty, the prompter asks.
	Server string

	// HumanToken short-circuits all auth when set.
	HumanToken string
	// HumanUsePassword forces POST /login instead of the default OAuth flow.
	HumanUsePassword bool
	// HumanUsername / HumanPassword feed POST /login (used when set).
	HumanUsername string
	HumanPassword string
	HumanTOTP     string

	// BotUsername overrides the bot-<reponame> default. The "bot-" prefix is
	// auto-prepended if missing — Vikunja will reject otherwise.
	BotUsername string

	// ProjectID, when non-zero, skips the interactive project picker.
	ProjectID int64

	// ViewID, when non-zero, skips the interactive view picker.
	ViewID int64

	// Bucket bootstrap behavior:
	//   AutoApproveBuckets — skip the prompt, create missing canonical buckets.
	//   SkipBucketBootstrap — neither prompt nor create.
	AutoApproveBuckets  bool
	SkipBucketBootstrap bool

	// Agent hook installation. If neither flag is set, the user is prompted
	// per-agent at the end of init. NoHooks skips the offering entirely
	// and falls back to printing the snippets.
	InstallClaudeCode bool
	InstallOpenCode   bool
	ClaudeCodeFlagSet bool
	OpenCodeFlagSet   bool
	NoHooks           bool

	// Out is where progress is written.
	Out io.Writer

	// RepoRoot, if empty, is detected via git rev-parse from cwd.
	RepoRoot string

	// Prompter is the seam tests use to script prompt answers. Defaults
	// to auth.NewStdPrompter() (reads stdin, writes prompts to stderr)
	// when nil.
	Prompter auth.Prompter

	// OverwriteExistingConfig, when true, allows Init to clobber an
	// existing .veans.yml without prompting. Mostly for tests; the
	// interactive flow asks the user.
	OverwriteExistingConfig bool
}

// Result is returned on success — just the bits printPostInitSummary reads.
type Result struct {
	Config       *config.Config
	BotUser      *client.BotUser
	AgentChoices AgentHookChoice
}

// Init runs the full onboarding flow. Steps are deliberately sequential and
// each prints a one-line progress note to opts.Out; failures are wrapped
// with output.Error codes so cobra's error handler renders them cleanly.
func Init(ctx context.Context, opts *Options) (*Result, error) {
	if opts == nil {
		opts = &Options{}
	}
	if opts.Out == nil {
		opts.Out = io.Discard
	}
	if opts.ConfigPath == "" {
		return nil, output.New(output.CodeValidation, "ConfigPath is required")
	}

	prompter := opts.Prompter
	if prompter == nil {
		prompter = auth.NewStdPrompter()
	}
	store := credentials.Default()

	if err := confirmOverwriteExistingConfig(opts, prompter); err != nil {
		return nil, err
	}

	// Validate the bot-username override (if any) against server-side
	// rules now, so we fail fast before steps 4–7 do real work that
	// we'd then have to undo. SuggestedBotUsername's output is
	// always valid, so we only need to validate user input.
	if opts.BotUsername != "" {
		if err := validateBotUsername(normalizeBotUsername(opts.BotUsername, "")); err != nil {
			return nil, err
		}
	}

	// 1. Repo root + suggested bot username.
	repoRoot := opts.RepoRoot
	if repoRoot == "" {
		var err error
		repoRoot, err = config.RepoRoot(ctx, "")
		if err != nil {
			return nil, output.Wrap(output.CodeUnknown, err, "detect repo root: %v", err)
		}
	}
	suggested := config.SuggestedBotUsername(repoRoot)
	botUsername := normalizeBotUsername(opts.BotUsername, suggested)
	progress(opts.Out, "Bot username will be %q", botUsername)

	// 2. Server URL.
	if opts.Server == "" {
		v, err := prompter.ReadLine("Vikunja server URL: ")
		if err != nil {
			return nil, err
		}
		opts.Server = strings.TrimSpace(v)
	}

	// 3. Discover the actual API URL: the user might have typed bare
	// "vikunja.example.com", or pasted the URL with /api/v1 already in
	// it, or be on a default-port localhost install. DiscoverServer
	// probes the plausible variants and returns the canonical base.
	canonical, info, err := client.DiscoverServer(ctx, opts.Server)
	if err != nil {
		return nil, err
	}
	opts.Server = canonical
	human := client.New(canonical, "")
	progress(opts.Out, "Connected to Vikunja %s at %s", info.Version, canonical)

	// 4. Acquire human JWT (transient — used until step 11). Default is the
	// OAuth flow; --token / --use-password / --username+--password override.
	tok, err := auth.AcquireHumanToken(ctx, human, auth.LoginOptions{
		Token:       opts.HumanToken,
		UsePassword: opts.HumanUsePassword,
		Username:    opts.HumanUsername,
		Password:    opts.HumanPassword,
		TOTP:        opts.HumanTOTP,
		Out:         opts.Out,
	}, prompter)
	if err != nil {
		return nil, err
	}
	human.Token = tok

	// 5. Pick (or accept passed) project.
	project, err := pickProject(ctx, human, opts.ProjectID, prompter, opts.Out)
	if err != nil {
		return nil, err
	}
	progress(opts.Out, "Using project #%d %q (identifier=%q)", project.ID, project.Title, project.Identifier)

	// 6. Pick (or accept passed) Kanban view.
	view, err := pickKanbanView(ctx, human, project.ID, opts.ViewID, prompter, opts.Out)
	if err != nil {
		return nil, err
	}
	progress(opts.Out, "Using view #%d %q", view.ID, view.Title)

	// 7. Bucket bootstrap (with strict-with-override prompt).
	buckets, err := bootstrapBuckets(ctx, human, project.ID, view.ID, opts, prompter)
	if err != nil {
		return nil, err
	}

	// 8. Resolve the bot user: reuse one we already own if the name is
	// taken by us, prompt for a fresh name (with a petname suggestion)
	// if the name is taken by someone else, otherwise create new.
	bot, err := resolveBotUser(ctx, human, botUsername, project.Title, prompter, opts.Out)
	if err != nil {
		return nil, err
	}

	// 9. Share the project with the bot. 409 ("user already has access")
	// is the expected response when reusing a bot that was set up by a
	// previous init run — treat it as a soft-success.
	_, shareErr := human.ShareProjectWithUser(ctx, project.ID, &client.ProjectUser{
		Username:   bot.Username,
		Permission: client.PermissionReadWrite,
	})
	switch {
	case shareErr == nil:
		progress(opts.Out, "Shared project with %q (read+write)", bot.Username)
	case isConflictErr(shareErr):
		progress(opts.Out, "Project already shared with %q", bot.Username)
	default:
		return nil, output.Wrap(output.CodeUnknown, shareErr, "share project with bot: %v", shareErr)
	}

	// 10. Discover available API permission scopes, mint the bot's token.
	routes, err := human.Routes(ctx)
	if err != nil {
		return nil, output.Wrap(output.CodeUnknown, err, "fetch /routes: %v", err)
	}
	perms := client.PermissionsForBot(routes)
	if len(perms) == 0 {
		return nil, output.New(output.CodeUnknown, "no API token permissions available — Vikunja /routes returned no matching groups")
	}
	mintedToken, err := human.CreateToken(ctx, &client.APIToken{
		Title:       "veans for " + project.Title,
		Permissions: perms,
		ExpiresAt:   client.FarFuture,
		OwnerID:     bot.ID,
	})
	if err != nil {
		return nil, output.Wrap(output.CodeUnknown, err, "mint bot token: %v", err)
	}
	if mintedToken.Token == "" {
		return nil, output.New(output.CodeUnknown, "PUT /tokens did not return a token plaintext — cannot continue")
	}

	// 11. Persist credentials. Discard human JWT immediately after.
	if err := store.Set(opts.Server, bot.Username, mintedToken.Token); err != nil {
		return nil, output.Wrap(output.CodeUnknown, err, "store bot token: %v", err)
	}
	human.Token = ""

	// 12. Write .veans.yml.
	cfg := &config.Config{
		Server:            opts.Server,
		ProjectID:         project.ID,
		ProjectIdentifier: project.Identifier,
		ViewID:            view.ID,
		Buckets:           buckets,
		Bot: config.Bot{
			Username: bot.Username,
			UserID:   bot.ID,
		},
	}
	if err := cfg.SaveAs(opts.ConfigPath); err != nil {
		return nil, output.Wrap(output.CodeUnknown, err, "write %s: %v", opts.ConfigPath, err)
	}
	progress(opts.Out, "Wrote %s", opts.ConfigPath)

	// 13. Offer to install agent hooks. Pre-seeded from flags; the rest
	// is prompted unless --no-hooks. Failures here are non-fatal — the
	// repo is already configured; the user can install hooks by hand.
	choices := AgentHookChoice{
		ClaudeCode: opts.InstallClaudeCode,
		OpenCode:   opts.InstallOpenCode,
	}
	choices, err = offerAgentHooks(prompter, opts.Out, choices,
		opts.ClaudeCodeFlagSet, opts.OpenCodeFlagSet, opts.NoHooks)
	if err != nil {
		return nil, err
	}
	if err := installAgentHooks(repoRoot, choices, opts.Out); err != nil {
		// Log but don't abort — the repo is configured.
		fmt.Fprintf(opts.Out, "  ! hook install failed: %v (you can paste the snippets manually)\n", err)
	}

	return &Result{
		Config:       cfg,
		BotUser:      bot,
		AgentChoices: choices,
	}, nil
}

// confirmOverwriteExistingConfig refuses to silently clobber an existing
// .veans.yml. The bot token in the credentials store is keyed on
// (server, bot-username); a blind re-init can swap the project under
// the agent's feet AND stomp the previous token in the keyring.
func confirmOverwriteExistingConfig(opts *Options, p auth.Prompter) error {
	if opts.OverwriteExistingConfig {
		return nil
	}
	if _, err := os.Stat(opts.ConfigPath); err != nil {
		// File doesn't exist (or we can't stat it — let SaveAs surface
		// that error later). Either way, no overwrite confirmation is
		// needed.
		return nil //nolint:nilerr // intentional: any Stat error means "no existing file to overwrite"
	}
	ans, err := p.ReadLine(fmt.Sprintf(
		"%s already exists. Overwrite (token + project + view get replaced)? [y/N]: ",
		opts.ConfigPath))
	if err != nil {
		return output.Wrap(output.CodeUnknown, err, "read overwrite confirmation: %v", err)
	}
	switch strings.ToLower(strings.TrimSpace(ans)) {
	case "y", "yes":
		return nil
	}
	return output.New(output.CodeConflict,
		"refusing to overwrite %s without confirmation (delete the file to re-init)",
		opts.ConfigPath)
}

func normalizeBotUsername(override, suggested string) string {
	if override == "" {
		return suggested
	}
	if !strings.HasPrefix(override, "bot-") {
		return "bot-" + override
	}
	return override
}

// botUsernamePattern mirrors the server's username regex closely enough
// to catch the rejections that would otherwise blow up steps 4–7 mid-init.
// The server allows lowercase letters, digits, hyphens, underscores, and
// dots; we additionally require the `bot-` prefix and forbid the
// `link-share-N` shape Vikunja reserves for share-links.
var botUsernamePattern = regexp.MustCompile(`^bot-[a-z0-9][a-z0-9._-]*$`)

var linkShareSuffix = regexp.MustCompile(`^bot-link-share-\d+$`)

// validateBotUsername mirrors the server-side rules so a bad
// `--bot-username` override (or interactive prompt answer) fails fast
// instead of dying with a 400 deep in step 8.
func validateBotUsername(name string) error {
	if !botUsernamePattern.MatchString(name) {
		return output.New(output.CodeValidation,
			"invalid bot username %q: must start with `bot-` and contain only lowercase letters, digits, hyphens, underscores, and dots",
			name)
	}
	if linkShareSuffix.MatchString(name) {
		return output.New(output.CodeValidation,
			"invalid bot username %q: `link-share-N` is reserved by Vikunja for share-link users",
			name)
	}
	return nil
}

func pickProject(ctx context.Context, c *client.Client, id int64, p auth.Prompter, out io.Writer) (*client.Project, error) {
	if id != 0 {
		return c.GetProject(ctx, id)
	}
	projects, err := c.ListProjects(ctx)
	if err != nil {
		return nil, err
	}
	// Filter out archived projects to keep the list short.
	var active []*client.Project
	for _, pr := range projects {
		if pr.IsArchived {
			continue
		}
		active = append(active, pr)
	}
	sort.Slice(active, func(i, j int) bool { return active[i].Title < active[j].Title })

	// The "create a new project" option sits at len(active)+1 in the menu;
	// when the user has nothing to pick from, it's the only choice.
	createIdx := len(active) + 1

	if len(active) == 0 {
		fmt.Fprintln(out, "No projects yet — let's create one.")
		return createProject(ctx, c, p, out)
	}

	fmt.Fprintln(out, "Available projects:")
	for i, pr := range active {
		ident := pr.Identifier
		if ident == "" {
			ident = "(no identifier)"
		}
		fmt.Fprintf(out, "  [%d] #%d %s — %s\n", i+1, pr.ID, pr.Title, ident)
	}
	fmt.Fprintf(out, "  [%d] Create a new project\n", createIdx)

	choice, err := p.ReadLine("Pick a project [1]: ")
	if err != nil {
		return nil, err
	}
	choice = strings.TrimSpace(choice)
	idx := 1
	if choice != "" {
		v, err := strconv.Atoi(choice)
		if err != nil || v < 1 || v > createIdx {
			return nil, output.New(output.CodeValidation, "invalid project choice %q", choice)
		}
		idx = v
	}
	if idx == createIdx {
		return createProject(ctx, c, p, out)
	}
	return active[idx-1], nil
}

// createProject prompts for the new project's title and identifier and
// PUTs it. Title is required; identifier is optional (Vikunja caps it at
// 10 chars). The fresh project comes with the default views — including
// the Kanban view pickKanbanView is about to grab.
func createProject(ctx context.Context, c *client.Client, p auth.Prompter, out io.Writer) (*client.Project, error) {
	title, err := p.ReadLine("New project title: ")
	if err != nil {
		return nil, err
	}
	title = strings.TrimSpace(title)
	if title == "" {
		return nil, output.New(output.CodeValidation, "project title is required")
	}
	ident, err := p.ReadLine("Identifier (optional, ≤10 letters/digits, used for task IDs like PROJ-NN): ")
	if err != nil {
		return nil, err
	}
	ident = strings.TrimSpace(ident)

	created, err := c.CreateProject(ctx, &client.Project{Title: title, Identifier: ident})
	if err != nil {
		return nil, output.Wrap(output.CodeUnknown, err, "create project %q: %v", title, err)
	}
	progress(out, "Created project #%d %q", created.ID, created.Title)
	return created, nil
}

func pickKanbanView(ctx context.Context, c *client.Client, projectID int64, viewID int64, p auth.Prompter, out io.Writer) (*client.ProjectView, error) {
	views, err := c.ListProjectViews(ctx, projectID)
	if err != nil {
		return nil, err
	}
	var kanban []*client.ProjectView
	for _, v := range views {
		if v.ViewKind == client.ViewKindKanban {
			kanban = append(kanban, v)
		}
	}
	if len(kanban) == 0 {
		return nil, output.New(output.CodeNotFound, "no Kanban views on this project — create one in the Vikunja UI first")
	}
	if viewID != 0 {
		for _, v := range kanban {
			if v.ID == viewID {
				return v, nil
			}
		}
		return nil, output.New(output.CodeNotFound, "view %d is not a Kanban view on this project", viewID)
	}
	if len(kanban) == 1 {
		return kanban[0], nil
	}
	fmt.Fprintln(out, "Available Kanban views:")
	for i, v := range kanban {
		fmt.Fprintf(out, "  [%d] #%d %s\n", i+1, v.ID, v.Title)
	}
	choice, err := p.ReadLine("Pick a view [1]: ")
	if err != nil {
		return nil, err
	}
	choice = strings.TrimSpace(choice)
	idx := 1
	if choice != "" {
		v, err := strconv.Atoi(choice)
		if err != nil || v < 1 || v > len(kanban) {
			return nil, output.New(output.CodeValidation, "invalid view choice %q", choice)
		}
		idx = v
	}
	return kanban[idx-1], nil
}

func bootstrapBuckets(ctx context.Context, c *client.Client, projectID, viewID int64, opts *Options, p auth.Prompter) (config.Buckets, error) {
	existing, err := c.ListBuckets(ctx, projectID, viewID)
	if err != nil {
		return config.Buckets{}, err
	}

	// Resolve canonical statuses to existing buckets via the alias table.
	// Vikunja's default Kanban view ships with "To-Do" / "Doing" / "Done";
	// matching them as Todo / InProgress / Done avoids creating a parallel
	// set of buckets every time veans runs against a vanilla project.
	matched := map[status.Status]*client.Bucket{}
	for _, s := range status.All() {
		for _, b := range existing {
			if b == nil {
				continue
			}
			if status.MatchBucketTitle(s, b.Title) {
				matched[s] = b
				break
			}
		}
	}

	var missing []string
	for _, s := range status.All() {
		if _, ok := matched[s]; !ok {
			missing = append(missing, s.BucketTitle())
		}
	}

	if len(missing) > 0 && !opts.SkipBucketBootstrap {
		approve := opts.AutoApproveBuckets
		if !approve {
			fmt.Fprintf(opts.Out, "Missing canonical buckets: %s\n", strings.Join(missing, ", "))
			const maxUnknownAnswers = 5
			prompt := "Bootstrap missing buckets? [Y/n/abort]: "
			unknown := 0
		promptLoop:
			for {
				ans, err := p.ReadLine(prompt)
				if err != nil {
					return config.Buckets{}, err
				}
				normalized := strings.ToLower(strings.TrimSpace(ans))
				switch normalized {
				case "", "y", "yes":
					approve = true
					break promptLoop
				case "n", "no":
					return config.Buckets{}, output.New(output.CodeValidation,
						"canonical buckets missing — either re-run `veans init` and answer Y to let veans bootstrap them, "+
							"or create the missing buckets (%s) manually in Vikunja's UI and re-run `veans init`",
						strings.Join(missing, ", "))
				case "a", "abort":
					return config.Buckets{}, output.New(output.CodeValidation, "user aborted bucket bootstrap")
				default:
					unknown++
					if unknown > maxUnknownAnswers {
						return config.Buckets{}, output.New(output.CodeValidation,
							"could not understand bucket bootstrap answer after %d attempts — aborting; "+
								"re-run `veans init` and answer y, n, or abort",
							maxUnknownAnswers)
					}
					fmt.Fprintf(opts.Out, "didn't understand %q, please answer y or n (or abort)\n", ans)
				}
			}
		}
		if approve {
			for _, s := range status.All() {
				if _, ok := matched[s]; ok {
					continue
				}
				title := s.BucketTitle()
				b, err := c.CreateBucket(ctx, projectID, viewID, &client.Bucket{Title: title})
				if err != nil {
					return config.Buckets{}, output.Wrap(output.CodeUnknown, err, "create bucket %q: %v", title, err)
				}
				matched[s] = b
				progress(opts.Out, "Created bucket %q (id=%d)", title, b.ID)
			}
		}
	}

	for _, s := range status.All() {
		if b, ok := matched[s]; ok && b != nil && b.Title != s.BucketTitle() {
			progress(opts.Out, "Reusing existing bucket %q as %s (id=%d)", b.Title, s.BucketTitle(), b.ID)
		}
	}

	out := config.Buckets{
		Todo:       bucketID(matched, status.Todo),
		InProgress: bucketID(matched, status.InProgress),
		InReview:   bucketID(matched, status.InReview),
		Done:       bucketID(matched, status.Completed),
		Scrapped:   bucketID(matched, status.Scrapped),
	}
	if out.Todo == 0 || out.InProgress == 0 || out.InReview == 0 || out.Done == 0 || out.Scrapped == 0 {
		return config.Buckets{}, output.New(output.CodeValidation,
			"canonical buckets missing — either re-run `veans init` and let veans bootstrap them, "+
				"or create the missing canonical buckets (Todo / In Progress / In Review / Done / Scrapped) manually in Vikunja's UI and re-run `veans init`")
	}
	return out, nil
}

func bucketID(m map[status.Status]*client.Bucket, s status.Status) int64 {
	if b, ok := m[s]; ok && b != nil {
		return b.ID
	}
	return 0
}

func progress(w io.Writer, format string, args ...any) {
	if w == nil {
		return
	}
	fmt.Fprintf(w, "  → "+format+"\n", args...)
}

// isConflictErr reports whether the wrapped HTTP error is a 409 — used by
// init's "share project with bot" step, which legitimately gets one when
// the bot is being reused from an earlier run.
func isConflictErr(err error) bool {
	var oe *output.Error
	return errors.As(err, &oe) && oe.Code == output.CodeConflict
}
