package main

// smcli — KNELSecretsManager Go CLI (pure Go; replaces the upstream Rust bw binary).
// Speaks the Bitwarden/Vaultwarden API against the self-hosted vault.
//
// Commands:
//   login                          authenticate (SM_EMAIL/SM_PASSWORD/SM_SERVER env or flags)
//   status                         show auth/key state
//   list [pattern]                 list item names
//   get <name> [--field KEY]       print decrypted item (or one field / KEY=VALUE block)
//   env <name>                     print KEY=VALUE lines for `eval`/sourcing
//   set <name> [k=v ...]           create/update a secure-note item from --file or inline k=v
//   rm <name>                      delete item
//   folders                        list folders
//
// State: SM_STATE_DIR (default ~/.config/smcli), files 0600.

import (
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"net/http"
	"os"
	"path/filepath"
	"strings"
	"time"
)

const stateVersion = 1

type State struct {
	Version      int    `json:"version"`
	Server       string `json:"server"`
	Email        string `json:"email"`
	AccessToken  string `json:"access_token"`
	RefreshToken string `json:"refresh_token,omitempty"`
	KDFType      int    `json:"kdf_type"`
	KDFIter      uint32 `json:"kdf_iter"`
	KDFMemory    uint32 `json:"kdf_memory"`
	KDFParallel  uint32 `json:"kdf_parallel"`
	// MasterKey/StretchedKey/UserSymKey stored raw (hex) — file must be 0600.
	MasterKey    string `json:"master_key"`
	StretchedKey string `json:"stretched_key"`
	UserSymKey   string `json:"user_sym_key"`
}

func stateDir() string {
	if v := os.Getenv("SM_STATE_DIR"); v != "" {
		return v
	}
	home, _ := os.UserHomeDir()
	return filepath.Join(home, ".config", "smcli")
}

func statePath() string { return filepath.Join(stateDir(), "state.json") }

// persistTokens updates just the token pair in the existing state file
// after a successful refresh (called from api.go refresh()).
func persistTokens(c *Client) {
	s, err := loadState()
	if err != nil {
		return // no readable state; tokens stay in-memory for this run
	}
	s.AccessToken = c.AccessToken
	s.RefreshToken = c.RefreshToken
	_ = saveState(s)
}

func saveState(s *State) error {
	if err := os.MkdirAll(stateDir(), 0o700); err != nil {
		return err
	}
	b, err := json.Marshal(s)
	if err != nil {
		return err
	}
	return os.WriteFile(statePath(), b, 0o600)
}

func loadState() (*State, error) {
	b, err := os.ReadFile(statePath())
	if err != nil {
		return nil, err
	}
	var s State
	if err := json.Unmarshal(b, &s); err != nil {
		return nil, err
	}
	if s.Version != stateVersion {
		return nil, errors.New("state version mismatch; re-login")
	}
	return &s, nil
}

func newClientFromState(s *State) (*Client, error) {
	c := &Client{
		Server: s.Server, Email: s.Email,
		Password: os.Getenv("SM_PASSWORD"), TOTPSecret: os.Getenv("SM_TOTP_SECRET"),
		AccessToken: s.AccessToken, RefreshToken: s.RefreshToken,
		KDFType: s.KDFType, KDFIter: s.KDFIter, KDFMemory: s.KDFMemory, KDFParallel: s.KDFParallel,
		HTTP: &http.Client{Timeout: 30 * time.Second},
	}
	var err error
	if c.MasterKey, err = fromHex(s.MasterKey); err != nil {
		return nil, err
	}
	if c.StretchedKey, err = fromHex(s.StretchedKey); err != nil {
		return nil, err
	}
	if c.UserSymKey, err = fromHex(s.UserSymKey); err != nil {
		return nil, err
	}
	return c, nil
}

func cmdLogin(server, email, password string) error {
	c := &Client{Server: server, Email: email, Password: password, TOTPSecret: os.Getenv("SM_TOTP_SECRET"),
		HTTP: &http.Client{Timeout: 30 * time.Second}}
	if err := c.Login(); err != nil {
		return err
	}
	s := &State{
		Version: stateVersion, Server: server, Email: email,
		AccessToken:  c.AccessToken,
		RefreshToken: c.RefreshToken,
		KDFType:      c.KDFType, KDFIter: c.KDFIter, KDFMemory: c.KDFMemory, KDFParallel: c.KDFParallel,
		MasterKey:    toHex(c.MasterKey),
		StretchedKey: toHex(c.StretchedKey),
	}
	if err := saveState(s); err != nil {
		return err
	}
	// immediately decrypt the user sym key
	if err := c.Unlock(); err != nil {
		return err
	}
	s.UserSymKey = toHex(c.UserSymKey)
	return saveState(s)
}

func cmdList(pattern string) error {
	s, err := loadState()
	if err != nil {
		return err
	}
	c, err := newClientFromState(s)
	if err != nil {
		return err
	}
	raw, err := c.Sync()
	if err != nil {
		return err
	}
	var sync struct {
		Ciphers []json.RawMessage `json:"ciphers"`
	}
	if err := json.Unmarshal(raw, &sync); err != nil {
		return err
	}
	for _, r := range sync.Ciphers {
		pc, err := DecryptCipher(c.UserSymKey, r)
		if err != nil {
			continue
		}
		if pattern == "" || strings.Contains(strings.ToLower(pc.Name), strings.ToLower(pattern)) {
			fmt.Println(pc.Name)
		}
	}
	return nil
}

func findCipher(c *Client, userKey []byte, name string) (*PlainCipher, json.RawMessage, error) {
	raw, err := c.Sync()
	if err != nil {
		return nil, nil, err
	}
	var sync struct {
		Ciphers []json.RawMessage `json:"ciphers"`
	}
	if err := json.Unmarshal(raw, &sync); err != nil {
		return nil, nil, err
	}
	for _, r := range sync.Ciphers {
		pc, err := DecryptCipher(userKey, r)
		if err != nil || pc.Name != name {
			continue
		}
		return pc, r, nil
	}
	return nil, nil, errors.New("item not found: " + name)
}

func rawCipherByID(raw []byte, id string) (*cipherRaw, error) {
	var sync struct {
		Ciphers []json.RawMessage `json:"ciphers"`
	}
	if err := json.Unmarshal(raw, &sync); err != nil {
		return nil, err
	}
	for _, r := range sync.Ciphers {
		var cr cipherRaw
		if err := json.Unmarshal(r, &cr); err != nil {
			continue
		}
		if cr.ID == id {
			return &cr, nil
		}
	}
	return nil, errors.New("cipher id not found: " + id)
}

func cmdGet(name, field string) error {
	s, err := loadState()
	if err != nil {
		return err
	}
	c, err := newClientFromState(s)
	if err != nil {
		return err
	}
	pc, _, err := findCipher(c, c.UserSymKey, name)
	if err != nil {
		return err
	}
	if field != "" {
		for _, f := range pc.Fields {
			if f.Name == field {
				fmt.Println(f.Value)
				return nil
			}
		}
		if field == "password" {
			fmt.Println(pc.Login.Password)
			return nil
		}
		if field == "username" {
			fmt.Println(pc.Login.Username)
			return nil
		}
		if field == "notes" {
			fmt.Print(pc.Notes)
			return nil
		}
		return fmt.Errorf("field not found: %s", field)
	}
	out, _ := json.MarshalIndent(pc, "", "  ")
	fmt.Println(string(out))
	return nil
}

func cmdEnv(name string) error {
	s, err := loadState()
	if err != nil {
		return err
	}
	c, err := newClientFromState(s)
	if err != nil {
		return err
	}
	pc, _, err := findCipher(c, c.UserSymKey, name)
	if err != nil {
		return err
	}
	for _, f := range pc.Fields {
		v := strings.ReplaceAll(f.Value, "'", "'\\''")
		fmt.Printf("export %s='%s'\n", f.Name, v)
	}
	if len(pc.URIs) > 0 {
		fmt.Printf("export URI='%s'\n", pc.URIs[0])
	}
	if pc.Login.Username != "" {
		fmt.Printf("export USERNAME='%s'\n", pc.Login.Username)
	}
	if pc.Login.Password != "" {
		fmt.Printf("export PASSWORD='%s'\n", pc.Login.Password)
	}
	return nil
}

func cmdSet(name, file string, kv []string, folderID string) error {
	s, err := loadState()
	if err != nil {
		return err
	}
	c, err := newClientFromState(s)
	if err != nil {
		return err
	}
	var fields []PlainField
	if file != "" {
		b, err := os.ReadFile(file)
		if err != nil {
			return err
		}
		fields = parseEnvFields(string(b))
	}
	for _, kv := range kv {
		eq := strings.Index(kv, "=")
		if eq <= 0 {
			return fmt.Errorf("bad k=v: %s", kv)
		}
		fields = append(fields, PlainField{Type: 1, Name: kv[:eq], Value: kv[eq+1:]})
	}
	raw, err := c.Sync()
	if err != nil {
		return err
	}
	pc, _, err := findCipher(c, c.UserSymKey, name)
	exists := err == nil
	var payload map[string]any
	if exists {
		var cr *cipherRaw
		cr, err = rawCipherByID(raw, pc.ID)
		if err != nil {
			return err
		}
		payload, err = BuildSecureNoteJSON(c.UserSymKey, name, folderID, pc.Notes, fields, cr)
		if err != nil {
			return err
		}
		_, err = c.EditCipher(pc.ID, payload)
	} else {
		payload, err = BuildSecureNoteJSON(c.UserSymKey, name, folderID, "", fields, nil)
		if err != nil {
			return err
		}
		_, err = c.CreateCipher(payload)
	}
	if err != nil {
		return err
	}
	fmt.Println("ok:", name)
	return nil
}

func cmdRm(name string) error {
	s, err := loadState()
	if err != nil {
		return err
	}
	c, err := newClientFromState(s)
	if err != nil {
		return err
	}
	pc, raw, err := findCipher(c, c.UserSymKey, name)
	if err != nil {
		return err
	}
	var parsed map[string]any
	_ = json.Unmarshal(raw, &parsed)
	id, _ := parsed["id"].(string)
	_ = pc
	fmt.Println("deleted:", name)
	return c.DeleteCipher(id)
}

func main() {
	if len(os.Args) < 2 {
		usage()
		os.Exit(1)
	}
	var err error
	switch os.Args[1] {
	case "login":
		fs := flag.NewFlagSet("login", flag.ExitOnError)
		server := fs.String("server", envOr("SM_SERVER", "https://pwvault.turnsys.com"), "vault server")
		email := fs.String("email", os.Getenv("SM_EMAIL"), "account email")
		password := fs.String("password", os.Getenv("SM_PASSWORD"), "master password (prefer env)")
		_ = fs.Parse(os.Args[2:])
		if *email == "" || *password == "" {
			fatal("login needs SM_EMAIL and SM_PASSWORD (or -email/-password)")
		}
		err = cmdLogin(*server, *email, *password)
	case "status":
		s, e := loadState()
		if e != nil {
			fmt.Println("locked/absent:", e)
			return
		}
		fmt.Printf("server=%s email=%s unlocked=%v\n", s.Server, s.Email, s.UserSymKey != "")
	case "list":
		pat := ""
		if len(os.Args) > 2 {
			pat = os.Args[2]
		}
		err = cmdList(pat)
	case "get":
		if len(os.Args) < 3 {
			fatal("get <name> [--field KEY]")
		}
		field := ""
		for i, a := range os.Args {
			if a == "--field" && i+1 < len(os.Args) {
				field = os.Args[i+1]
			}
		}
		err = cmdGet(os.Args[2], field)
	case "env":
		if len(os.Args) < 3 {
			fatal("env <name>")
		}
		err = cmdEnv(os.Args[2])
	case "set":
		fs := flag.NewFlagSet("set", flag.ExitOnError)
		file := fs.String("file", "", "env file with KEY=VALUE lines")
		folder := fs.String("folder", "", "folder id")
		_ = fs.Parse(os.Args[2:])
		rest := fs.Args()
		if len(rest) < 1 {
			fatal("set <name> [--file F] [k=v ...]")
		}
		err = cmdSet(rest[0], *file, rest[1:], *folder)
	case "rm":
		if len(os.Args) < 3 {
			fatal("rm <name>")
		}
		err = cmdRm(os.Args[2])
	case "folders":
		s, e := loadState()
		if e != nil {
			fatal(e)
		}
		c, e := newClientFromState(s)
		if e != nil {
			fatal(e)
		}
		raw, e := c.Sync()
		if e != nil {
			fatal(e)
		}
		var sync struct {
			Folders []struct {
				ID   string `json:"id"`
				Name string `json:"name"`
			} `json:"folders"`
		}
		_ = json.Unmarshal(raw, &sync)
		for _, f := range sync.Folders {
			fmt.Println(f.ID, f.Name)
		}
	case "setfield":
		if len(os.Args) < 5 {
			fatal("setfield <name> <key> <value>")
		}
		err = cmdSetField(os.Args[2], os.Args[3], os.Args[4])
	case "convert":
		if len(os.Args) < 3 {
			fatal("convert <name>")
		}
		err = cmdConvert(os.Args[2])
	default:
		usage()
		os.Exit(1)
	}
	if err != nil {
		fatal(err)
	}
}

func envOr(k, def string) string {
	if v := os.Getenv(k); v != "" {
		return v
	}
	return def
}

func fatal(v any) {
	fmt.Fprintln(os.Stderr, "smcli:", v)
	os.Exit(1)
}

func usage() {
	fmt.Fprint(os.Stderr, `smcli — KNELSecretsManager Go CLI (Bitwarden/Vaultwarden)
  login                        authenticate (SM_EMAIL/SM_PASSWORD/SM_SERVER)
  status                       auth/key state
  list [pattern]               list item names
  get <name> [--field KEY]     decrypt item / field
  env <name>                   print export KEY='VALUE' lines (sourcable)
  set <name> [--file F] k=v    create/update secure note with hidden fields
  rm <name>                    delete item
  folders                      list folders
`)
}

// classifyCreds maps lifted env fields to login-item structure.
func classifyCreds(fields []PlainField) (username, password string, uris []string, rest []PlainField) {
	used := map[int]bool{}
	pick := func(res []string) (PlainField, bool) {
		for i, f := range fields {
			if used[i] {
				continue
			}
			up := strings.ToUpper(f.Name)
			for _, re := range res {
				if strings.Contains(up, re) && f.Value != "" {
					used[i] = true
					return f, true
				}
			}
		}
		return PlainField{}, false
	}
	if f, ok := pick([]string{"USERNAME", "USER", "EMAIL", "LOGIN", "AUTH_ID"}); ok {
		username = f.Value
	}
	if f, ok := pick([]string{"PASSWORD", "PASS", "TOKEN", "SECRET", "APIKEY", "API_KEY", "KEY", "HASH", "REFRESH"}); ok {
		password = f.Value
	}
	for i, f := range fields {
		up := strings.ToUpper(f.Name)
		if !used[i] && (strings.HasSuffix(up, "URL") || strings.HasSuffix(up, "URI") || strings.HasSuffix(up, "DASH") || strings.Contains(up, "ENDPOINT")) && f.Value != "" {
			used[i] = true
			uris = append(uris, f.Value)
		}
	}
	for i, f := range fields {
		if used[i] || f.Value == "" {
			continue
		}
		up := strings.ToUpper(f.Name)
		hidden := strings.Contains(up, "PASS") || strings.Contains(up, "TOKEN") || strings.Contains(up, "SECRET") || strings.Contains(up, "KEY") || strings.Contains(up, "HASH")
		t := 0
		if hidden {
			t = 1
		}
		rest = append(rest, PlainField{Type: t, Name: f.Name, Value: f.Value})
	}
	return
}

func cmdConvert(name string) error {
	s, err := loadState()
	if err != nil {
		return err
	}
	c, err := newClientFromState(s)
	if err != nil {
		return err
	}
	pc, raw, err := findCipher(c, c.UserSymKey, name)
	if err != nil {
		return err
	}
	all := append([]PlainField{}, pc.Fields...)
	var folderID string
	var parsed map[string]any
	_ = json.Unmarshal(raw, &parsed)
	if v, ok := parsed["folderId"].(string); ok {
		folderID = v
	}
	username, password, uris, rest := classifyCreds(all)
	payload, err := BuildLoginJSON(c.UserSymKey, name, folderID, username, password, uris, rest, nil)
	if err != nil {
		return err
	}
	if _, err := c.CreateCipher(payload); err != nil {
		return err
	}
	// remove the old note
	var parsedOld map[string]any
	_ = json.Unmarshal(raw, &parsedOld)
	if id, ok := parsedOld["id"].(string); ok {
		_ = c.DeleteCipher(id)
	}
	fmt.Printf("converted: %s (user=%v pass=%v uris=%d fields=%d)\n", name, username != "", password != "", len(uris), len(rest))
	return nil
}

func urisOf(pc *PlainCipher) []string { return pc.URIs }

// cmdSetField surgically updates one key on an existing item: login.password,
// login.username, an existing custom field, or appends a new hidden field.
func cmdSetField(name, key, value string) error {
	s, err := loadState()
	if err != nil {
		return err
	}
	c, err := newClientFromState(s)
	if err != nil {
		return err
	}
	raw, err := c.Sync()
	if err != nil {
		return err
	}
	var sync struct {
		Ciphers []json.RawMessage `json:"ciphers"`
	}
	if err := json.Unmarshal(raw, &sync); err != nil {
		return err
	}
	var cr *cipherRaw
	for _, r := range sync.Ciphers {
		var x cipherRaw
		if json.Unmarshal(r, &x) == nil && x.ID != "" {
			pc2, derr := DecryptCipher(c.UserSymKey, r)
			if derr == nil && pc2.Name == name {
				cr = &x
				break
			}
		}
	}
	if cr == nil {
		return fmt.Errorf("item not found: %s", name)
	}
	ck, err := cipherKeyFor(c.UserSymKey, cr.Key)
	if err != nil {
		return err
	}
	switch strings.ToLower(key) {
	case "password":
		p, err := encStr(c.UserSymKey, value)
		if err != nil {
			return err
		}
		if cr.Login == nil {
			cr.Login = &CipherLogin{}
		}
		cr.Login.Password = &p
	case "username":
		p, err := encStr(c.UserSymKey, value)
		if err != nil {
			return err
		}
		if cr.Login == nil {
			cr.Login = &CipherLogin{}
		}
		cr.Login.Username = &p
	default:
		found := false
		for i := range cr.Fields {
			fn, derr := decStr(ck, ck, cr.Fields[i].Name)
			if derr == nil && fn == key {
				fv, err := encStr(c.UserSymKey, value)
				if err != nil {
					return err
				}
				cr.Fields[i].Value = &fv
				found = true
				break
			}
		}
		if !found {
			fn, _ := encStr(c.UserSymKey, key)
			fv, _ := encStr(c.UserSymKey, value)
			cr.Fields = append(cr.Fields, CipherField{Type: 1, Name: &fn, Value: &fv})
		}
	}
	payload := map[string]any{"type": cr.Type, "name": cr.Name}
	if cr.Login != nil {
		payload["login"] = cr.Login
	}
	if cr.Fields != nil {
		payload["fields"] = cr.Fields
	}
	if cr.Notes != nil {
		payload["notes"] = *cr.Notes
	}
	if _, err := c.EditCipher(cr.ID, payload); err != nil {
		return err
	}
	fmt.Println("updated:", name, key)
	return nil
}
