// Package reader holds the nymdrop-reader engine: it runs an embedded
// nym-client, receives submissions over its WebSocket, decrypts them, and
// writes them to disk. It is deliberately transport/UI-agnostic — both the
// headless CLI (cmd/nymdrop-reader) and the Fyne GUI drive it through Hooks
// instead of writing to stdout directly, so the two front-ends can share one
// implementation instead of drifting apart.
package reader

import (
	"crypto/aes"
	"crypto/cipher"
	"crypto/ecdh"
	"crypto/rand"
	"crypto/sha256"
	"embed"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"io/fs"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"strings"
	"time"

	"golang.org/x/crypto/hkdf"

	"github.com/awnumar/memguard"
	"golang.org/x/net/websocket"

	"nymdrop/internal/nym"
)

//go:embed bin/nym-client-linux-amd64
var nymBin embed.FS

// Config holds everything needed to start a reader instance. Empty fields
// fall back to the historical defaults (~/.nymdrop-reader, ~/nymdrop-inbox)
// so existing deployments (e.g. the pietro systemd service) keep behaving
// identically when DataDir is left unset.
type Config struct {
	KeyFile string // path to X25519 private key file (hex); generated if missing
	InboxID string // nym-client identity id
	WSPort  int    // nym-client websocket port
	OutDir  string // directory to save decrypted submissions
	// DataDir, if non-empty, makes the whole instance self-contained under
	// this directory: the embedded nym-client's own config/state (normally
	// fixed to ~/.nym by nym-client itself) is redirected here by setting
	// HOME for the child process, and KeyFile/OutDir default inside it too.
	// This is what makes a USB-portable, zero-trace-on-host reader possible
	// without any cooperation from nym-client itself (verified: nym-client
	// reads $HOME like any other Rust `dirs`-crate consumer, nothing more
	// specific is exposed on its CLI).
	DataDir string
	Debug   bool
}

// Hooks lets a front-end observe reader activity without depending on how
// the engine is implemented. Any field left nil is a no-op.
type Hooks struct {
	Log         func(format string, args ...any)
	SelfAddress func(addr string)
	Submission  func(path, preview string)
	Attachment  func(path string, size int)
	Error       func(err error)
}

func (h *Hooks) logf(format string, args ...any) {
	if h.Log != nil {
		h.Log(format, args...)
	}
}

func (h *Hooks) errorf(err error) {
	if h.Error != nil {
		h.Error(err)
	}
}

// Instance is a running reader: an embedded nym-client subprocess plus the
// WebSocket connection to it.
type Instance struct {
	cfg        Config
	hooks      Hooks
	privKey    *ecdh.PrivateKey
	nymCmd     *exec.Cmd
	nymBinDir  string
	ws         *websocket.Conn
	SelfAddr   string
}

// resolveDefaults fills empty Config fields the same way the original
// nymdrop-reader main() did, anchoring everything under DataDir when set.
func resolveDefaults(cfg Config) (Config, error) {
	if cfg.InboxID == "" {
		cfg.InboxID = "nymdrop-inbox"
	}
	if cfg.WSPort == 0 {
		cfg.WSPort = 1977
	}

	base := cfg.DataDir
	if base == "" {
		home, err := os.UserHomeDir()
		if err != nil {
			return cfg, fmt.Errorf("home dir: %w", err)
		}
		base = home
	}

	if cfg.KeyFile == "" {
		cfg.KeyFile = filepath.Join(base, ".nymdrop-reader", "privkey.hex")
	}
	if cfg.OutDir == "" {
		cfg.OutDir = filepath.Join(base, "nymdrop-inbox")
	}
	return cfg, nil
}

// Start loads/generates the decryption key, extracts and launches the
// embedded nym-client, and connects to its WebSocket. It returns once the
// connection is ready and the self-address has been queried; call
// ReceiveLoop afterwards (in its own goroutine if driving a GUI) and Close
// when done.
func Start(cfg Config, hooks Hooks) (*Instance, error) {
	cfg, err := resolveDefaults(cfg)
	if err != nil {
		return nil, err
	}
	if err := os.MkdirAll(cfg.OutDir, 0700); err != nil {
		return nil, fmt.Errorf("mkdir outDir: %w", err)
	}

	privKey, err := loadOrGenKey(cfg.KeyFile, &hooks)
	if err != nil {
		return nil, err
	}

	nymBinPath, nymBinDir, err := extractNymClient()
	if err != nil {
		return nil, err
	}

	// nym-client's own config directory is hardcoded to $HOME/.nym (no CLI
	// flag for it) — redirecting HOME for the child process is the only way
	// to keep a portable run from touching the operator's real home. See
	// Config.DataDir doc comment. Left empty (cfg.DataDir=="") this is a
	// no-op: the child inherits the real environment, identical to before.
	nymHome := cfg.DataDir

	if err := initNymClient(nymBinPath, cfg.InboxID, nymHome); err != nil {
		os.RemoveAll(nymBinDir)
		return nil, err
	}

	nymCmd, err := startNymClient(nymBinPath, cfg.InboxID, cfg.WSPort, nymHome)
	if err != nil {
		os.RemoveAll(nymBinDir)
		return nil, err
	}

	wsURL := fmt.Sprintf("ws://127.0.0.1:%d", cfg.WSPort)
	hooks.logf("Connecting to Nym mixnet")
	ws, err := dialWS(wsURL, &hooks)
	if err != nil {
		nymCmd.Process.Kill()
		os.RemoveAll(nymBinDir)
		return nil, err
	}

	in := &Instance{
		cfg:       cfg,
		hooks:     hooks,
		privKey:   privKey,
		nymCmd:    nymCmd,
		nymBinDir: nymBinDir,
		ws:        ws,
	}

	in.SelfAddr = in.querySelfAddress()
	if in.SelfAddr != "" && hooks.SelfAddress != nil {
		hooks.SelfAddress(in.SelfAddr)
	}

	return in, nil
}

// OutDir returns the directory submissions are written to.
func (in *Instance) OutDir() string { return in.cfg.OutDir }

// Close tears down the nym-client subprocess, its WebSocket, and its
// extracted binary's temp directory.
func (in *Instance) Close() {
	if in.ws != nil {
		in.ws.Close()
	}
	if in.nymCmd != nil && in.nymCmd.Process != nil {
		in.nymCmd.Process.Kill()
	}
	if in.nymBinDir != "" {
		os.RemoveAll(in.nymBinDir)
	}
	memguard.Purge()
}

// ── key management ───────────────────────────────────────────────────────────

func loadOrGenKey(keyFile string, hooks *Hooks) (*ecdh.PrivateKey, error) {
	data, err := os.ReadFile(keyFile)
	if err == nil {
		raw, err := hex.DecodeString(strings.TrimSpace(string(data)))
		if err != nil {
			return nil, fmt.Errorf("privkey decode: %w", err)
		}
		keyBuf := memguard.NewBufferFromBytes(raw)
		priv, err := ecdh.X25519().NewPrivateKey(keyBuf.Bytes())
		keyBuf.Destroy()
		if err != nil {
			return nil, fmt.Errorf("privkey load: %w", err)
		}
		hooks.logf("Loaded private key from %s", keyFile)
		hooks.logf("Public key (deploy in nymdrop-server): %s", hex.EncodeToString(priv.PublicKey().Bytes()))
		return priv, nil
	}

	rawPriv := make([]byte, 32)
	if _, err := rand.Read(rawPriv); err != nil {
		return nil, fmt.Errorf("keygen rand: %w", err)
	}
	keyBuf := memguard.NewBufferFromBytes(rawPriv)
	priv, err := ecdh.X25519().NewPrivateKey(keyBuf.Bytes())
	if err != nil {
		keyBuf.Destroy()
		return nil, fmt.Errorf("keygen: %w", err)
	}
	if err := os.MkdirAll(filepath.Dir(keyFile), 0700); err != nil {
		keyBuf.Destroy()
		return nil, fmt.Errorf("mkdir keydir: %w", err)
	}
	if err := os.WriteFile(keyFile, []byte(hex.EncodeToString(keyBuf.Bytes())+"\n"), 0600); err != nil {
		keyBuf.Destroy()
		return nil, fmt.Errorf("write privkey: %w", err)
	}
	keyBuf.Destroy()
	hooks.logf("Generated new private key -> %s", keyFile)
	hooks.logf("Public key (deploy in nymdrop-server): %s", hex.EncodeToString(priv.PublicKey().Bytes()))
	hooks.logf("  Set NYMDROP_PUBKEY_PLACEHOLDER to the public key above in static/crypto.js")
	hooks.logf("  and rebuild the server before accepting submissions.")
	return priv, nil
}

// ── nym-client lifecycle ─────────────────────────────────────────────────────

func extractNymClient() (binPath, tmpDir string, err error) {
	tmpDir, err = os.MkdirTemp("", "nymdrop-reader-*")
	if err != nil {
		return "", "", fmt.Errorf("mktemp: %w", err)
	}
	binName := "nym-client"
	if runtime.GOOS == "windows" {
		binName += ".exe"
	}
	binPath = filepath.Join(tmpDir, binName)

	data, err := fs.ReadFile(nymBin, "bin/nym-client-linux-amd64")
	if err != nil {
		os.RemoveAll(tmpDir)
		return "", "", fmt.Errorf("embedded nym-client not found: %w", err)
	}
	if err := os.WriteFile(binPath, data, 0700); err != nil {
		os.RemoveAll(tmpDir)
		return "", "", fmt.Errorf("extract nym-client: %w", err)
	}
	return binPath, tmpDir, nil
}

// childEnv returns the environment for the embedded nym-client subprocess,
// overriding HOME (and USERPROFILE, for a future Windows build of
// nym-client) to nymHome when it is non-empty. Verified against the real
// binary: nym-client happily initialises its ~/.nym tree under a redirected
// HOME with no other flag needed.
func childEnv(nymHome string) []string {
	if nymHome == "" {
		return nil // inherit parent's environment unchanged
	}
	env := os.Environ()
	filtered := env[:0]
	for _, kv := range env {
		if strings.HasPrefix(kv, "HOME=") || strings.HasPrefix(kv, "USERPROFILE=") {
			continue
		}
		filtered = append(filtered, kv)
	}
	filtered = append(filtered, "HOME="+nymHome, "USERPROFILE="+nymHome)
	return filtered
}

func initNymClient(binPath, id, nymHome string) error {
	cmd := exec.Command(binPath, "init", "--id", id)
	cmd.Env = childEnv(nymHome)
	cmd.Stdout = io.Discard
	cmd.Stderr = io.Discard
	_ = cmd.Run() // best-effort, same as before: a pre-existing id is fine
	return nil
}

func startNymClient(binPath, id string, wsPort int, nymHome string) (*exec.Cmd, error) {
	cmd := exec.Command(binPath, "run",
		"--id", id,
		"--port", fmt.Sprintf("%d", wsPort),
	)
	cmd.Env = childEnv(nymHome)
	cmd.Stdout = io.Discard
	cmd.Stderr = io.Discard
	if err := cmd.Start(); err != nil {
		return nil, fmt.Errorf("nym-client start: %w", err)
	}
	return cmd, nil
}

func dialWS(wsURL string, hooks *Hooks) (*websocket.Conn, error) {
	for i := 0; i < 120; i++ {
		ws, err := websocket.Dial(wsURL, "", "http://localhost/")
		if err == nil {
			hooks.logf("Nym mixnet connection ready.")
			return ws, nil
		}
		time.Sleep(500 * time.Millisecond)
	}
	return nil, fmt.Errorf("nym-client websocket did not start in time")
}

// ── websocket message types ───────────────────────────────────────────────────

type wsReceived struct {
	Type      string          `json:"type"`
	Message   json.RawMessage `json:"message"`
	SenderTag string          `json:"senderTag"`
}

// querySelfAddress queries the inbox address using nym-client's binary
// protocol rather than JSON. This is deliberate, not cosmetic: nym-client
// picks text-vs-binary for every later push ("received" events) based on
// the format of the *last* request it saw on this connection (see
// clients/native/src/websocket/handler.rs, ReceivedResponseType). Submission
// payloads are raw binary crypto material with no base64 wrapping (see
// Client.Send in internal/nym) — if this connection stayed in JSON/text
// mode, nym-client would run String::from_utf8_lossy over that binary data
// before handing it to us, silently corrupting it. Querying the self
// address in binary form here locks the connection into binary mode for
// the lifetime of ReceiveLoop below.
func (in *Instance) querySelfAddress() string {
	if err := websocket.Message.Send(in.ws, nym.EncodeSelfAddressRequest()); err != nil {
		in.hooks.errorf(fmt.Errorf("selfAddress send: %w", err))
		return ""
	}
	var frame []byte
	if err := websocket.Message.Receive(in.ws, &frame); err != nil {
		in.hooks.errorf(fmt.Errorf("selfAddress recv: %w", err))
		return ""
	}
	addr, err := nym.DecodeSelfAddressResponse(frame)
	if err != nil {
		in.hooks.errorf(fmt.Errorf("selfAddress decode: %w", err))
		return ""
	}
	return addr
}

// ── receive loop ──────────────────────────────────────────────────────────────

// ReceiveLoop blocks, decrypting and saving submissions as they arrive.
// Call it in its own goroutine when driving a GUI; it returns when the
// WebSocket is closed (e.g. by Close).
func (in *Instance) ReceiveLoop() {
	for {
		var frame []byte
		if err := websocket.Message.Receive(in.ws, &frame); err != nil {
			if err == io.EOF || strings.Contains(err.Error(), "use of closed network connection") {
				return
			}
			in.hooks.errorf(fmt.Errorf("ws recv: %w", err))
			time.Sleep(500 * time.Millisecond)
			continue
		}
		if len(frame) == 0 {
			continue
		}
		if in.cfg.Debug {
			preview := frame
			if len(preview) > 200 {
				preview = preview[:200]
			}
			in.hooks.logf("[debug] frame: %d bytes, lead=0x%02x: %q", len(frame), frame[0], preview)
		}

		raw, ok := extractMessage(frame)
		if !ok {
			continue
		}

		plaintext, err := decryptMessage(raw, in.privKey)
		if err != nil {
			in.hooks.errorf(fmt.Errorf("decrypt failed: %w", err))
			continue
		}

		in.saveSubmission(plaintext)
	}
}

// extractMessage pulls the raw submission payload out of a nym-client
// "received" frame, tolerating both encodings nym-client can use: binary
// (what this connection actually runs in, see querySelfAddress) and
// text/JSON (kept as a defensive fallback only — should never fire here).
func extractMessage(frame []byte) ([]byte, bool) {
	if frame[0] == nym.RespReceived {
		return nym.DecodeReceived(frame)
	}

	if frame[0] == '{' {
		var msg wsReceived
		if err := json.Unmarshal(frame, &msg); err != nil || msg.Type != "received" {
			return nil, false
		}
		var s string
		if err := json.Unmarshal(msg.Message, &s); err != nil {
			return nil, false
		}
		raw, err := base64.StdEncoding.DecodeString(s)
		if err != nil {
			return nil, false
		}
		return raw, true
	}

	return nil, false
}

// ── decryption ────────────────────────────────────────────────────────────────

// decryptMessage decrypts a raw packet from the relay.
//
// Wire format from relay:
//
//	nonce_hex(32 ASCII chars) + ":" + ephemeral_x25519_pub(32) + iv(12) + ciphertext+tag
//
// The nonce prefix is stripped; it exists only to make identical submissions
// look different on the Nym wire. It is not used in decryption.
func decryptMessage(raw []byte, privKey *ecdh.PrivateKey) (string, error) {
	const noncePrefix = 32 + 1 // "deadbeef...:" = 33 bytes
	if len(raw) < noncePrefix+32+12+1 {
		return "", fmt.Errorf("packet too short (%d bytes)", len(raw))
	}
	raw = raw[noncePrefix:]

	ephPubBytes := raw[:32]
	iv := raw[32:44]
	ciphertext := raw[44:]

	ephPub, err := ecdh.X25519().NewPublicKey(ephPubBytes)
	if err != nil {
		return "", fmt.Errorf("ephemeral pubkey: %w", err)
	}

	sharedSecret, err := privKey.ECDH(ephPub)
	if err != nil {
		return "", fmt.Errorf("ecdh: %w", err)
	}
	sharedBuf := memguard.NewBufferFromBytes(sharedSecret)
	defer sharedBuf.Destroy()

	aesKeyRaw := make([]byte, 32)
	hkdfR := hkdf.New(sha256.New, sharedBuf.Bytes(), make([]byte, 32), []byte("nymdrop-v1"))
	if _, err := io.ReadFull(hkdfR, aesKeyRaw); err != nil {
		return "", fmt.Errorf("hkdf: %w", err)
	}
	aesBuf := memguard.NewBufferFromBytes(aesKeyRaw)
	defer aesBuf.Destroy()

	block, err := aes.NewCipher(aesBuf.Bytes())
	if err != nil {
		return "", fmt.Errorf("aes: %w", err)
	}
	gcm, err := cipher.NewGCM(block)
	if err != nil {
		return "", fmt.Errorf("gcm: %w", err)
	}
	plaintext, err := gcm.Open(nil, iv, ciphertext, nil)
	if err != nil {
		return "", fmt.Errorf("gcm open: %w", err)
	}

	return string(plaintext), nil
}

// ── storage ───────────────────────────────────────────────────────────────────

func (in *Instance) saveSubmission(plaintext string) {
	ts := time.Now().UTC().Format("20060102-150405")
	path := filepath.Join(in.cfg.OutDir, ts+".txt")

	for i := 1; ; i++ {
		if _, err := os.Stat(path); os.IsNotExist(err) {
			break
		}
		path = filepath.Join(in.cfg.OutDir, fmt.Sprintf("%s-%d.txt", ts, i))
	}

	body := plaintext
	fileSection := ""
	if idx := strings.Index(plaintext, "\n---FILE:"); idx != -1 {
		body = plaintext[:idx]
		fileSection = plaintext[idx+1:]
	}

	if err := os.WriteFile(path, []byte(body), 0600); err != nil {
		in.hooks.errorf(fmt.Errorf("save submission: %w", err))
		return
	}
	preview := body
	if len(preview) > 200 {
		preview = preview[:200] + "..."
	}
	if in.hooks.Submission != nil {
		in.hooks.Submission(path, strings.TrimSpace(preview))
	}

	if fileSection != "" {
		in.saveAttachments(ts, fileSection)
	}
}

func (in *Instance) saveAttachments(ts, fileSection string) {
	lines := strings.Split(fileSection, "\n")
	var currentName string
	var currentData strings.Builder

	flush := func() {
		if currentName == "" {
			return
		}
		raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(currentData.String()))
		if err != nil {
			in.hooks.errorf(fmt.Errorf("attachment decode %q: %w", currentName, err))
			return
		}
		safeName := filepath.Base(filepath.Clean(currentName))
		attPath := filepath.Join(in.cfg.OutDir, ts+"-"+safeName)
		if err := os.WriteFile(attPath, raw, 0600); err != nil {
			in.hooks.errorf(fmt.Errorf("save attachment %q: %w", currentName, err))
			return
		}
		if in.hooks.Attachment != nil {
			in.hooks.Attachment(attPath, len(raw))
		}
		currentName = ""
		currentData.Reset()
	}

	for _, line := range lines {
		if strings.HasPrefix(line, "---FILE:") && strings.HasSuffix(line, "---") {
			flush()
			currentName = strings.TrimSuffix(strings.TrimPrefix(line, "---FILE:"), "---")
		} else if currentName != "" {
			currentData.WriteString(line)
		}
	}
	flush()
}
