// mrktplaats — command-line interface to the marktplaats.nl API
//
// Designed for use by both humans and LLM agents.
// All output is JSON on stdout. Errors go to stderr with exit code 1.
//
// Token resolution order:
//  1. MRKTPLAATS_TOKEN environment variable
//  2. ~/.config/mrktplaats/token  (JSON: {accessToken, refreshToken, expiresIn})
//  3. ./.mrktplaats-token in the current directory
//
// After login the token is written to ~/.config/mrktplaats/token automatically.
// The token expires after ~12 hours; re-login when prompted.
package main

import (
	"bytes"
	"context"
	"encoding/json"
	"flag"
	"fmt"
	"math"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"time"

	"github.com/joren/mrktplaats"
)

// ── helpers ───────────────────────────────────────────────────────────────────

func fatalf(format string, args ...any) {
	fmt.Fprintf(os.Stderr, "error: "+format+"\n", args...)
	os.Exit(1)
}

func out(v any) {
	enc := json.NewEncoder(os.Stdout)
	enc.SetIndent("", "  ")
	if err := enc.Encode(v); err != nil {
		fatalf("encoding output: %v", err)
	}
}

func ctx() context.Context {
	c, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	// cancel is intentionally not deferred here — the CLI calls one operation
	// per invocation and exits immediately after, so the leak is benign.
	_ = cancel
	return c
}

// tokenPath returns the path to the persisted token file.
func tokenPath() string {
	if cfg, err := os.UserConfigDir(); err == nil {
		return filepath.Join(cfg, "mrktplaats", "token")
	}
	return filepath.Join(os.Getenv("HOME"), ".config", "mrktplaats", "token")
}

// savedToken is the JSON structure stored in the token file.
type savedToken struct {
	AccessToken  string `json:"accessToken"`
	RefreshToken string `json:"refreshToken"`
	ExpiresIn    string `json:"expiresIn"`
}

// loadTokenData reads the token file and returns parsed token data.
// Supports both the legacy plain-text format (access token only) and the
// current JSON format.
func loadTokenData() *savedToken {
	var data []byte
	var err error

	if t := os.Getenv("MRKTPLAATS_TOKEN"); t != "" {
		return &savedToken{AccessToken: strings.TrimSpace(t)}
	}
	for _, p := range []string{tokenPath(), ".mrktplaats-token"} {
		if data, err = os.ReadFile(p); err == nil {
			break
		}
	}
	if data == nil {
		return nil
	}

	// Try JSON first (current format).
	var tok savedToken
	if err := json.Unmarshal(bytes.TrimSpace(data), &tok); err == nil && tok.AccessToken != "" {
		return &tok
	}
	// Fall back to legacy plain-text format.
	if t := strings.TrimSpace(string(data)); t != "" {
		return &savedToken{AccessToken: t}
	}
	return nil
}

// saveToken persists the full token payload as JSON to the config file.
func saveToken(tok savedToken) error {
	p := tokenPath()
	if err := os.MkdirAll(filepath.Dir(p), 0700); err != nil {
		return err
	}
	b, err := json.Marshal(tok)
	if err != nil {
		return err
	}
	return os.WriteFile(p, append(b, '\n'), 0600)
}

// pendingPath returns the path used to persist a pending 2FA session between
// the two login calls (first call triggers SMS; second call submits the code).
func pendingPath() string {
	return filepath.Join(filepath.Dir(tokenPath()), "pending-2fa")
}

// pending2FA stores the requestId AND session parameters so the second CLI
// invocation (with --code) uses the exact same session as the first.
// The API requires matching session/gaClientId/magic_number/threatmetrix
// between the login and verify-code requests.
type pending2FA struct {
	RequestID             string `json:"requestId"`
	Session               string `json:"session"`
	GAClientID            string `json:"gaClientId"`
	MagicNumber           string `json:"magicNumber"`
	ThreatMetrixSessionID string `json:"threatMetrixSessionId"`
}

func savePending2FA(p pending2FA) error {
	path := pendingPath()
	if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
		return err
	}
	b, err := json.Marshal(p)
	if err != nil {
		return err
	}
	return os.WriteFile(path, append(b, '\n'), 0600)
}

func loadPending2FA() *pending2FA {
	data, err := os.ReadFile(pendingPath())
	if err != nil {
		return nil
	}
	data = bytes.TrimSpace(data)
	if len(data) == 0 {
		return nil
	}

	// Try JSON format (current).
	var p pending2FA
	if err := json.Unmarshal(data, &p); err == nil && p.RequestID != "" {
		return &p
	}

	// Fall back to legacy plain-text format (requestId only).
	if id := strings.TrimSpace(string(data)); id != "" {
		return &pending2FA{RequestID: id}
	}
	return nil
}

func clearPending2FA() {
	_ = os.Remove(pendingPath())
}

// authedClient returns a client with a valid token, auto-refreshing if expired.
func authedClient() *mrktplaats.Client {
	tok := loadTokenData()
	if tok == nil || tok.AccessToken == "" {
		fatalf("no token found — run 'mrktplaats login --email E --password P' first,\n" +
			"or set MRKTPLAATS_TOKEN in the environment")
	}

	// Check expiry and auto-refresh if we have a refresh token.
	if tok.ExpiresIn != "" {
		if exp, err := time.Parse(time.RFC3339, tok.ExpiresIn); err == nil && time.Now().After(exp) {
			if tok.RefreshToken == "" {
				fatalf("access token expired at %s — please re-login:\n"+
					"  mrktplaats login --email EMAIL --password PASS",
					exp.Local().Format("2006-01-02 15:04:05"))
			}
			// Attempt silent refresh.
			tmpClient := mrktplaats.NewClient()
			newTok, err := tmpClient.Auth.RefreshToken(ctx(), tok.RefreshToken)
			if err != nil {
				fatalf("token refresh failed (please re-login): %v", err)
			}
			updated := savedToken{
				AccessToken:  newTok.AccessToken,
				RefreshToken: newTok.RefreshToken,
				ExpiresIn:    newTok.ExpiresIn,
			}
			if err := saveToken(updated); err != nil {
				fmt.Fprintf(os.Stderr, "warning: could not save refreshed token: %v\n", err)
			}
			tok = &updated
		}
	}

	return mrktplaats.NewClient(mrktplaats.WithAccessToken(tok.AccessToken))
}

// eurosToC converts a euro string ("20", "20.50") to euro cents.
func eurosToC(s string) int {
	f, err := strconv.ParseFloat(s, 64)
	if err != nil {
		fatalf("invalid amount %q: must be a number in euros (e.g. 20 or 20.50)", s)
	}
	return int(math.Round(f * 100))
}

// ── commands ──────────────────────────────────────────────────────────────────

// login — authenticate and save token
//
//	mrktplaats login --email EMAIL --password PASS
func cmdLogin(args []string) {
	fs := flag.NewFlagSet("login", flag.ExitOnError)
	email := fs.String("email", "", "account e-mail address (required)")
	pass := fs.String("password", "", "account password (required)")
	code := fs.String("code", "", "SMS 2FA code (only needed when 2FA is triggered)")
	fs.Usage = func() {
		fmt.Fprintln(os.Stderr, `usage: mrktplaats login --email EMAIL --password PASS [--code 2FA_CODE]

Authenticate with marktplaats.nl and save the access token.

If the account has 2FA enabled the call will return with a "verification_required"
status. Re-run the command with --code CODE after receiving the SMS.

Flags:`)
		fs.PrintDefaults()
	}
	_ = fs.Parse(args)

	if *email == "" || *pass == "" {
		fs.Usage()
		os.Exit(1)
	}

	client := mrktplaats.NewClient()

	// If the user provided --code, check whether we saved a pending 2FA session
	// from the previous login call. If so, create the client with the SAME session
	// parameters and go straight to VerifyCode — calling Login again would generate
	// a new requestId, invalidating the SMS code the user just received.
	if *code != "" {
		pending := loadPending2FA()
		if pending == nil || pending.RequestID == "" {
			fatalf("no pending 2FA session — run login without --code first to trigger the SMS:\n" +
				"  mrktplaats login --email EMAIL --password PASS")
		}

		// Restore the session parameters from the login call so the API sees
		// the same session/gaClientId/magic_number/threatmetrix as before.
		var opts []mrktplaats.Option
		if pending.Session != "" {
			opts = append(opts, mrktplaats.WithSession(pending.Session))
		}
		if pending.GAClientID != "" {
			opts = append(opts, mrktplaats.WithGAClientID(pending.GAClientID))
		}
		if pending.MagicNumber != "" {
			opts = append(opts, mrktplaats.WithMagicNumber(pending.MagicNumber))
		}
		if pending.ThreatMetrixSessionID != "" {
			opts = append(opts, mrktplaats.WithThreatMetrixSessionID(pending.ThreatMetrixSessionID))
		}
		client = mrktplaats.NewClient(opts...)

		auth, err := client.Auth.VerifyCode(ctx(), pending.RequestID, *code)
		if err != nil {
			fatalf("2FA verification failed: %v", err)
		}
		clearPending2FA()
		if err := saveToken(savedToken{AccessToken: auth.Auth.AccessToken, RefreshToken: auth.Auth.RefreshToken, ExpiresIn: auth.Auth.ExpiresIn}); err != nil {
			fmt.Fprintf(os.Stderr, "warning: could not save token: %v\n", err)
		}
		out(map[string]any{
			"status":       "ok",
			"token_saved":  tokenPath(),
			"access_token": auth.Auth.AccessToken,
			"expires_in":   auth.Auth.ExpiresIn,
			"user":         auth.User,
		})
		return
	}

	login, err := client.Auth.Login(ctx(), *email, *pass)
	if err != nil {
		fatalf("login failed: %v", err)
	}

	// Direct auth — no 2FA
	if login.Auth != nil {
		clearPending2FA()
		if err := saveToken(savedToken{AccessToken: login.Auth.AccessToken, RefreshToken: login.Auth.RefreshToken, ExpiresIn: login.Auth.ExpiresIn}); err != nil {
			fmt.Fprintf(os.Stderr, "warning: could not save token: %v\n", err)
		}
		out(map[string]any{
			"status":       "ok",
			"token_saved":  tokenPath(),
			"access_token": login.Auth.AccessToken,
			"expires_in":   login.Auth.ExpiresIn,
		})
		return
	}

	// 2FA required
	if login.Verification == nil {
		fatalf("unexpected login response: no auth and no verification")
	}

	// Persist the requestId AND session parameters so the next invocation
	// (with --code) can recreate the client with identical session values.
	sp := client.SessionParams()
	if err := savePending2FA(pending2FA{
		RequestID:             login.Verification.RequestID,
		Session:               sp.Session,
		GAClientID:            sp.GAClientID,
		MagicNumber:           sp.MagicNumber,
		ThreatMetrixSessionID: sp.ThreatMetrixSessionID,
	}); err != nil {
		fmt.Fprintf(os.Stderr, "warning: could not save pending 2FA state: %v\n", err)
	}

	out(map[string]any{
		"status":    "verification_required",
		"method":    login.Verification.Method,
		"message":   login.Verification.Message,
		"next_step": "re-run with --code CODE after receiving the SMS",
	})
}

// search — search listings
//
//	mrktplaats search --query Q [--category ID] [--page N] [--size N] [--sort FIELD]
func cmdSearch(args []string) {
	fs := flag.NewFlagSet("search", flag.ExitOnError)
	query := fs.String("query", "", "search query (required)")
	category := fs.Int("category", 0, "category ID filter (0 = all categories)")
	page := fs.Int("page", 1, "page number (1-based)")
	size := fs.Int("size", 20, "results per page (max 100)")
	sort := fs.String("sort", "SORT_INDEX", "sort order: SORT_INDEX | DATE_DESC | PRICE_ASC | PRICE_DESC")
	sellerID := fs.String("seller", "", "filter by seller ID")
	fs.Usage = func() {
		fmt.Fprintln(os.Stderr, `usage: mrktplaats search --query Q [options]

Search listings. Does not require authentication.

Output: {"total": N, "listings": [{urn, title, price_cents, price_label, city, seller_name, url}]}

Flags:`)
		fs.PrintDefaults()
	}
	_ = fs.Parse(args)

	if *query == "" {
		fs.Usage()
		os.Exit(1)
	}

	client := mrktplaats.NewClient()
	req := &mrktplaats.SearchRequest{
		Query:                       *query,
		Page:                        *page,
		Size:                        *size,
		SortBy:                      *sort,
		AllowCorrection:             true,
		SearchOnTitleAndDescription: true,
		ShowListings:                true,
		SupportsReservedFlag:        true,
	}
	if *category != 0 {
		req.CategoryID = category
	}
	if *sellerID != "" {
		req.SellerID = *sellerID
	}

	resp, err := client.Search.Fetch(ctx(), req, nil)
	if err != nil {
		fatalf("search failed: %v", err)
	}

	type listingResult struct {
		URN        string `json:"urn"`
		Title      string `json:"title"`
		PriceCents int    `json:"price_cents"`
		PriceLabel string `json:"price_label"`
		PriceType  string `json:"price_type"`
		City       string `json:"city"`
		SellerName string `json:"seller_name"`
		URL        string `json:"url"`
	}
	seen := map[string]bool{}
	var listings []listingResult
	for _, item := range resp.Items {
		l := item.Listing()
		if l == nil || seen[l.AdCore.URN] {
			continue
		}
		seen[l.AdCore.URN] = true
		listings = append(listings, listingResult{
			URN:        l.AdCore.URN,
			Title:      l.AdCore.Title,
			PriceCents: l.AdCore.Price.PriceAmount,
			PriceLabel: l.AdCore.Price.PriceTypeLabel,
			PriceType:  l.AdCore.Price.PriceType,
			City:       l.AdCore.AdAddress.City,
			SellerName: l.SellerInformation.Name,
			URL:        l.URL,
		})
	}

	total := 0
	if resp.SearchHistograms != nil {
		total = resp.SearchHistograms.NumFound
	}
	out(map[string]any{
		"total":    total,
		"page":     *page,
		"size":     *size,
		"listings": listings,
	})
}

// listing — get full listing details
//
//	mrktplaats listing --urn URN
func cmdListing(args []string) {
	fs := flag.NewFlagSet("listing", flag.ExitOnError)
	urn := fs.String("urn", "", "listing URN, e.g. m2372861012 (required)")
	fs.Usage = func() {
		fmt.Fprintln(os.Stderr, `usage: mrktplaats listing --urn URN

Fetch full details for a listing: title, description, price, seller, pictures,
attributes, bids. Requires authentication (some fields are auth-only).

Output: full ListingDetail object

Flags:`)
		fs.PrintDefaults()
	}
	_ = fs.Parse(args)
	if *urn == "" {
		fs.Usage()
		os.Exit(1)
	}

	client := authedClient()
	detail, err := client.Listings.Get(ctx(), *urn)
	if err != nil {
		fatalf("listing %s: %v", *urn, err)
	}
	out(detail)
}

// conversations — list all conversations
//
//	mrktplaats conversations [--limit N]
func cmdConversations(args []string) {
	fs := flag.NewFlagSet("conversations", flag.ExitOnError)
	limit := fs.Int("limit", 50, "max conversations to return")
	fs.Usage = func() {
		fmt.Fprintln(os.Stderr, `usage: mrktplaats conversations [--limit N]

List all messaging conversations for the authenticated user.

Output: {"total": N, "unread": N, "conversations": [{id, item_urn, title, other_party, unread, latest_message}]}

Flags:`)
		fs.PrintDefaults()
	}
	_ = fs.Parse(args)

	client := authedClient()
	resp, err := client.Messaging.Conversations(ctx(), &mrktplaats.ConversationsOptions{Limit: *limit})
	if err != nil {
		fatalf("conversations: %v", err)
	}

	type convResult struct {
		ID            string `json:"id"`
		ItemURN       string `json:"item_urn"`
		Title         string `json:"title"`
		OtherParty    string `json:"other_party"`
		OtherPartyID  int    `json:"other_party_id"`
		Unread        int    `json:"unread"`
		LatestMessage string `json:"latest_message"`
	}
	var convs []convResult
	for _, c := range resp.Conversations {
		latest := ""
		if lm := c.LatestMessage(); lm != nil {
			latest = lm.Text
		}
		convs = append(convs, convResult{
			ID:            c.ID,
			ItemURN:       c.ItemID,
			Title:         c.Title,
			OtherParty:    c.OtherParticipant.Name,
			OtherPartyID:  c.OtherParticipant.ID,
			Unread:        c.UnreadMessagesCount,
			LatestMessage: latest,
		})
	}
	out(map[string]any{
		"total":         resp.TotalCount,
		"unread":        resp.UnreadMessagesCount,
		"conversations": convs,
	})
}

// messages — read messages in a conversation
//
//	mrktplaats messages --conv CONV_ID [--limit N] [--offset N]
func cmdMessages(args []string) {
	fs := flag.NewFlagSet("messages", flag.ExitOnError)
	convID := fs.String("conv", "", "conversation ID, e.g. 1cgx:5qgvx3h:2p1gxt95d (required)")
	limit := fs.Int("limit", 50, "max messages to return")
	offset := fs.Int("offset", 0, "skip first N messages")
	fs.Usage = func() {
		fmt.Fprintln(os.Stderr, `usage: mrktplaats messages --conv CONV_ID [--limit N] [--offset N]

Read messages in a conversation. Use 'mrktplaats conversations' to find conversation IDs.

Output: {"total": N, "messages": [{id, sender_id, text, date, type, read}]}

Flags:`)
		fs.PrintDefaults()
	}
	_ = fs.Parse(args)
	if *convID == "" {
		fs.Usage()
		os.Exit(1)
	}

	client := authedClient()
	resp, err := client.Messaging.Messages(ctx(), *convID, *offset, *limit)
	if err != nil {
		fatalf("messages: %v", err)
	}

	type msgResult struct {
		ID       string    `json:"id"`
		SenderID int       `json:"sender_id"`
		Text     string    `json:"text"`
		Date     time.Time `json:"date"`
		Type     string    `json:"type"`
		Read     bool      `json:"read"`
	}
	var msgs []msgResult
	for _, m := range resp.Messages {
		msgs = append(msgs, msgResult{
			ID:       m.ID,
			SenderID: m.SenderID,
			Text:     m.Text,
			Date:     m.ReceivedDate,
			Type:     m.MessageType,
			Read:     m.IsRead,
		})
	}
	out(map[string]any{
		"total":    resp.TotalCount,
		"messages": msgs,
	})
}

// send — start a new conversation about a listing
//
//	mrktplaats send --urn URN --text TEXT
func cmdSend(args []string) {
	fs := flag.NewFlagSet("send", flag.ExitOnError)
	urn := fs.String("urn", "", "listing URN to message the seller about (required)")
	text := fs.String("text", "", "message text (required)")
	fs.Usage = func() {
		fmt.Fprintln(os.Stderr, `usage: mrktplaats send --urn URN --text TEXT

Start a new conversation with the seller of a listing.
To reply to an existing conversation use 'mrktplaats reply'.

Output: {"conversation_id": "...", "status": "sent"}

Flags:`)
		fs.PrintDefaults()
	}
	_ = fs.Parse(args)
	if *urn == "" || *text == "" {
		fs.Usage()
		os.Exit(1)
	}

	client := authedClient()

	// Fetch the listing to get the seller ID required by the enquiry endpoint.
	detail, err := client.Listings.Get(ctx(), *urn)
	if err != nil {
		fatalf("send: fetching listing %s: %v", *urn, err)
	}
	sellerID := fmt.Sprintf("%d", detail.SellerInformation.ID)

	// Use the ASQ (Ask Seller a Question) enquiry endpoint.
	// This works for both first contact and repeat messages — the API always returns 204.
	if err := client.Enquiry.AskQuestion(ctx(), *urn, sellerID, *text); err != nil {
		fatalf("send: %v", err)
	}
	out(map[string]any{
		"status":      "sent",
		"item_urn":    *urn,
		"seller_id":   sellerID,
		"seller_name": detail.SellerInformation.Name,
	})
}

// reply — send a message in an existing conversation
//
//	mrktplaats reply --conv CONV_ID --text TEXT
func cmdReply(args []string) {
	fs := flag.NewFlagSet("reply", flag.ExitOnError)
	convID := fs.String("conv", "", "conversation ID (required)")
	text := fs.String("text", "", "message text (required)")
	fs.Usage = func() {
		fmt.Fprintln(os.Stderr, `usage: mrktplaats reply --conv CONV_ID --text TEXT

Send a message in an existing conversation.
Use 'mrktplaats conversations' to find conversation IDs.

Output: {"message_id": "...", "conversation_id": "...", "status": "sent"}

Flags:`)
		fs.PrintDefaults()
	}
	_ = fs.Parse(args)
	if *convID == "" || *text == "" {
		fs.Usage()
		os.Exit(1)
	}

	client := authedClient()
	msgID, err := client.Messaging.SendMessage(ctx(), *convID, *text)
	if err != nil {
		fatalf("reply: %v", err)
	}
	out(map[string]any{
		"message_id":      msgID,
		"conversation_id": *convID,
		"status":          "sent",
	})
}

// bid — place a bid on a listing
//
//	mrktplaats bid --urn URN --amount EUROS [--message TEXT]
func cmdBid(args []string) {
	fs := flag.NewFlagSet("bid", flag.ExitOnError)
	urn := fs.String("urn", "", "listing URN (required)")
	amount := fs.String("amount", "", "bid amount in euros, e.g. 20 or 20.50 (required)")
	message := fs.String("message", "", "optional personal message to the seller")
	fs.Usage = func() {
		fmt.Fprintln(os.Stderr, `usage: mrktplaats bid --urn URN --amount EUROS [--message TEXT]

Place a bid on a listing. The listing must have priceType MIN_BID or FAST_BID.
Amount is in euros (e.g. --amount 20 for €20.00).

Output: {"bids": [...], "current_minimum_bid_cents": N, "current_minimum_bid_euros": N}

Flags:`)
		fs.PrintDefaults()
	}
	_ = fs.Parse(args)
	if *urn == "" || *amount == "" {
		fs.Usage()
		os.Exit(1)
	}

	cents := eurosToC(*amount)
	client := authedClient()
	resp, err := client.Enquiry.PlaceBid(ctx(), *urn, &mrktplaats.PlaceBidRequest{
		Value:           cents,
		PersonalMessage: *message,
	})
	if err != nil {
		fatalf("bid: %v", err)
	}
	out(map[string]any{
		"bids":                      resp.Bids,
		"current_minimum_bid_cents": resp.CurrentMinimumBid,
		"current_minimum_bid_euros": float64(resp.CurrentMinimumBid) / 100,
	})
}

// remove-bid — remove an existing bid
//
//	mrktplaats remove-bid --id BID_ID
func cmdRemoveBid(args []string) {
	fs := flag.NewFlagSet("remove-bid", flag.ExitOnError)
	id := fs.Int("id", 0, "bid ID to remove (required; find it in the 'bids' array from 'bid' or 'listing')")
	fs.Usage = func() {
		fmt.Fprintln(os.Stderr, `usage: mrktplaats remove-bid --id BID_ID

Remove a bid you previously placed.

Output: {"bids": [...], "current_minimum_bid_euros": N}

Flags:`)
		fs.PrintDefaults()
	}
	_ = fs.Parse(args)
	if *id == 0 {
		fs.Usage()
		os.Exit(1)
	}

	client := authedClient()
	resp, err := client.Enquiry.RemoveBid(ctx(), *id)
	if err != nil {
		fatalf("remove-bid: %v", err)
	}
	out(map[string]any{
		"bids":                      resp.Bids,
		"current_minimum_bid_cents": resp.CurrentMinimumBid,
		"current_minimum_bid_euros": float64(resp.CurrentMinimumBid) / 100,
	})
}

// favorites — list saved listings
//
//	mrktplaats favorites [--limit N]
func cmdFavorites(args []string) {
	fs := flag.NewFlagSet("favorites", flag.ExitOnError)
	limit := fs.Int("limit", 100, "max favorites to return")
	fs.Usage = func() {
		fmt.Fprintln(os.Stderr, `usage: mrktplaats favorites [--limit N]

List the authenticated user's saved/favourite listings.

Output: {"total": N, "items": [{item_urn, saved_at}]}

Flags:`)
		fs.PrintDefaults()
	}
	_ = fs.Parse(args)

	client := authedClient()
	resp, err := client.Favorites.List(ctx(), *limit)
	if err != nil {
		fatalf("favorites: %v", err)
	}
	type favResult struct {
		ItemURN string `json:"item_urn"`
		SavedAt string `json:"saved_at"`
	}
	var items []favResult
	for _, f := range resp.Items {
		items = append(items, favResult{ItemURN: f.ItemID, SavedAt: f.CreationDate})
	}
	out(map[string]any{
		"total": resp.Total,
		"more":  resp.MoreItemsAvailable,
		"items": items,
	})
}

// add-favorite — save a listing to favourites
//
//	mrktplaats add-favorite --urn URN
func cmdAddFavorite(args []string) {
	fs := flag.NewFlagSet("add-favorite", flag.ExitOnError)
	urn := fs.String("urn", "", "listing URN (required)")
	fs.Usage = func() {
		fmt.Fprintln(os.Stderr, `usage: mrktplaats add-favorite --urn URN

Add a listing to the authenticated user's favourites.

Output: {"status": "added", "item_urn": "..."}

Flags:`)
		fs.PrintDefaults()
	}
	_ = fs.Parse(args)
	if *urn == "" {
		fs.Usage()
		os.Exit(1)
	}

	client := authedClient()
	if _, err := client.Favorites.Add(ctx(), *urn); err != nil {
		fatalf("add-favorite: %v", err)
	}
	out(map[string]any{"status": "added", "item_urn": *urn})
}

// remove-favorite — remove a listing from favourites
//
//	mrktplaats remove-favorite --urn URN
func cmdRemoveFavorite(args []string) {
	fs := flag.NewFlagSet("remove-favorite", flag.ExitOnError)
	urn := fs.String("urn", "", "listing URN (required)")
	fs.Usage = func() {
		fmt.Fprintln(os.Stderr, `usage: mrktplaats remove-favorite --urn URN

Remove a listing from the authenticated user's favourites.

Output: {"status": "removed", "item_urn": "..."}

Flags:`)
		fs.PrintDefaults()
	}
	_ = fs.Parse(args)
	if *urn == "" {
		fs.Usage()
		os.Exit(1)
	}

	client := authedClient()
	if err := client.Favorites.Remove(ctx(), *urn); err != nil {
		fatalf("remove-favorite: %v", err)
	}
	out(map[string]any{"status": "removed", "item_urn": *urn})
}

// me — show the authenticated user's profile
//
//	mrktplaats me
func cmdMe(args []string) {
	_ = args // no flags
	client := authedClient()
	user, err := client.Users.Me(ctx())
	if err != nil {
		fatalf("me: %v", err)
	}
	out(user)
}

// my-ads — list the authenticated user's own listings
//
//	mrktplaats my-ads [--status active|inactive|sold] [--limit N]
func cmdMyAds(args []string) {
	fs := flag.NewFlagSet("my-ads", flag.ExitOnError)
	status := fs.String("status", "active", "filter by status: active | inactive | sold")
	limit := fs.Int("limit", 50, "max listings to return")
	fs.Usage = func() {
		fmt.Fprintln(os.Stderr, `usage: mrktplaats my-ads [--status STATUS] [--limit N]

List the authenticated user's own listings.

Output: {"total": N, "ads": [{item_id, title, status, price_cents, price_euros}]}

Flags:`)
		fs.PrintDefaults()
	}
	_ = fs.Parse(args)

	client := authedClient()
	resp, err := client.MyAccount.MyAds(ctx(), &mrktplaats.MyAdsOptions{
		Status: *status,
		Limit:  *limit,
		Counts: true,
	})
	if err != nil {
		fatalf("my-ads: %v", err)
	}

	type adResult struct {
		ItemID     string  `json:"item_id"`
		Title      string  `json:"title"`
		Status     string  `json:"status"`
		PriceCents int     `json:"price_cents"`
		PriceEuros float64 `json:"price_euros"`
	}
	var ads []adResult
	for _, a := range resp.MyAds {
		ads = append(ads, adResult{
			ItemID:     a.ItemID,
			Title:      a.Title,
			Status:     a.Status,
			PriceCents: a.PriceInCents,
			PriceEuros: float64(a.PriceInCents) / 100,
		})
	}
	out(map[string]any{
		"total": resp.MyAdsTotalCount,
		"ads":   ads,
	})
}

// delete-ad — delete one of the authenticated user's listings
//
//	mrktplaats delete-ad --urn URN [--reason 1|2|3|4]
func cmdDeleteAd(args []string) {
	fs := flag.NewFlagSet("delete-ad", flag.ExitOnError)
	urn := fs.String("urn", "", "listing URN to delete (required)")
	reason := fs.Int("reason", 4, "delete reason: 1=sold on marktplaats, 2=sold elsewhere, 3=no longer selling, 4=other")
	fs.Usage = func() {
		fmt.Fprintln(os.Stderr, `usage: mrktplaats delete-ad --urn URN [--reason N]

Delete one of the authenticated user's own listings.

Reasons:
  1 — sold on marktplaats.nl
  2 — sold elsewhere
  3 — no longer selling
  4 — other (default)

Output: {"status": "deleted", "item_urn": "..."}

Flags:`)
		fs.PrintDefaults()
	}
	_ = fs.Parse(args)
	if *urn == "" {
		fs.Usage()
		os.Exit(1)
	}

	client := authedClient()
	if err := client.MyAccount.DeleteAds(ctx(), []string{*urn}, mrktplaats.DeleteAdReason(*reason)); err != nil {
		fatalf("delete-ad: %v", err)
	}
	out(map[string]any{"status": "deleted", "item_urn": *urn})
}

// seller-listings — all listings by a seller
//
//	mrktplaats seller-listings --seller SELLER_ID [--size N] [--page N]
//	mrktplaats seller-listings --urn URN           [--size N] [--page N]
func cmdSellerListings(args []string) {
	fs := flag.NewFlagSet("seller-listings", flag.ExitOnError)
	sellerID := fs.String("seller", "", "numeric seller ID (e.g. 57506580)")
	urn := fs.String("urn", "", "any listing URN from this seller — seller ID is resolved automatically")
	size := fs.Int("size", 50, "max results per page")
	page := fs.Int("page", 1, "page number")
	fs.Usage = func() {
		fmt.Fprintln(os.Stderr, `usage: mrktplaats seller-listings --seller ID [--size N] [--page N]
       mrktplaats seller-listings --urn URN   [--size N] [--page N]

List all active listings by a seller. Provide either the seller's numeric ID
directly (--seller) or any listing URN belonging to that seller (--urn) and the
seller ID will be resolved automatically from the listing details.

Output: {"seller_id": "...", "seller_name": "...", "total": N, "listings": [...]}

Flags:`)
		fs.PrintDefaults()
	}
	_ = fs.Parse(args)

	if *sellerID == "" && *urn == "" {
		fs.Usage()
		os.Exit(1)
	}

	client := authedClient()
	resolvedName := ""

	if *sellerID == "" {
		detail, err := client.Listings.Get(ctx(), *urn)
		if err != nil {
			fatalf("seller-listings: fetching listing %s: %v", *urn, err)
		}
		*sellerID = fmt.Sprintf("%d", detail.SellerInformation.ID)
		resolvedName = detail.SellerInformation.Name
	}

	resp, err := client.Search.Fetch(ctx(), &mrktplaats.SearchRequest{
		Query:                       "",
		SellerID:                    *sellerID,
		Page:                        *page,
		Size:                        *size,
		SortBy:                      "DATE_DESC",
		ShowListings:                true,
		SupportsReservedFlag:        true,
		SearchOnTitleAndDescription: true,
	}, nil)
	if err != nil {
		fatalf("seller-listings: %v", err)
	}

	type listingResult struct {
		URN        string `json:"urn"`
		Title      string `json:"title"`
		PriceCents int    `json:"price_cents"`
		PriceLabel string `json:"price_label"`
		PriceType  string `json:"price_type"`
		City       string `json:"city"`
	}
	seen := map[string]bool{}
	var listings []listingResult
	for _, item := range resp.Items {
		l := item.Listing()
		if l == nil || seen[l.AdCore.URN] {
			continue
		}
		seen[l.AdCore.URN] = true
		if resolvedName == "" {
			resolvedName = l.SellerInformation.Name
		}
		listings = append(listings, listingResult{
			URN:        l.AdCore.URN,
			Title:      l.AdCore.Title,
			PriceCents: l.AdCore.Price.PriceAmount,
			PriceLabel: l.AdCore.Price.PriceTypeLabel,
			PriceType:  l.AdCore.Price.PriceType,
			City:       l.AdCore.AdAddress.City,
		})
	}

	total := 0
	if resp.SearchHistograms != nil {
		total = resp.SearchHistograms.NumFound
	}
	out(map[string]any{
		"seller_id":   *sellerID,
		"seller_name": resolvedName,
		"total":       total,
		"page":        *page,
		"listings":    listings,
	})
}

// reviews — view reviews for a seller
//
//	mrktplaats reviews --seller SELLER_ID
//	mrktplaats reviews --urn URN
func cmdReviews(args []string) {
	fs := flag.NewFlagSet("reviews", flag.ExitOnError)
	sellerID := fs.Int("seller", 0, "numeric seller ID (e.g. 57506580)")
	urn := fs.String("urn", "", "any listing URN — seller ID is resolved automatically")
	role := fs.String("role", "reviewee", "filter: reviewee (received) or all")
	fs.Usage = func() {
		fmt.Fprintln(os.Stderr, `usage: mrktplaats reviews --seller ID [--role reviewee|all]
       mrktplaats reviews --urn URN   [--role reviewee|all]

View reviews for a seller. Provide either the seller's numeric ID directly
(--seller) or any listing URN belonging to that seller (--urn) and the seller
ID will be resolved automatically from the listing details.

Output: {"seller_id": N, "summary": {average_score, count}, "reviews": [...]}

Flags:`)
		fs.PrintDefaults()
	}
	_ = fs.Parse(args)

	if *sellerID == 0 && *urn == "" {
		fs.Usage()
		os.Exit(1)
	}

	client := authedClient()

	if *sellerID == 0 {
		detail, err := client.Listings.Get(ctx(), *urn)
		if err != nil {
			fatalf("reviews: fetching listing %s: %v", *urn, err)
		}
		*sellerID = detail.SellerInformation.ID
	}

	var reviewRole mrktplaats.ReviewRole
	switch *role {
	case "reviewee":
		reviewRole = mrktplaats.ReviewRoleReviewee
	case "all", "":
		reviewRole = mrktplaats.ReviewRoleAll
	default:
		fatalf("invalid --role %q: must be 'reviewee' or 'all'", *role)
	}

	resp, err := client.Users.Reviews(ctx(), *sellerID, reviewRole)
	if err != nil {
		fatalf("reviews: %v", err)
	}

	type reviewResult struct {
		ID           int            `json:"id"`
		Score        int            `json:"score"`
		ReviewerName string         `json:"reviewer_name"`
		ReviewerID   int            `json:"reviewer_id"`
		Subject      string         `json:"subject"`
		Direction    string         `json:"direction"`
		ItemTitle    string         `json:"item_title"`
		ItemID       string         `json:"item_id"`
		Date         string         `json:"date"`
		Details      []detailResult `json:"details,omitempty"`
	}
	type summaryResult struct {
		AverageScore float64 `json:"average_score"`
		Count        int     `json:"count"`
	}

	var reviews []reviewResult
	for _, r := range resp.Reviews {
		var details []detailResult
		for _, d := range r.Details {
			var chars []string
			for _, c := range d.Characteristics {
				prefix := "+"
				if !c.IsPositive {
					prefix = "-"
				}
				chars = append(chars, prefix+c.Text)
			}
			details = append(details, detailResult{
				Category: d.Category,
				Score:    d.Score,
				Traits:   chars,
			})
		}
		reviews = append(reviews, reviewResult{
			ID:           r.ID,
			Score:        r.Score,
			ReviewerName: r.Reviewer.Nickname,
			ReviewerID:   r.Reviewer.ID,
			Subject:      r.Content.Subject,
			Direction:    r.Direction,
			ItemTitle:    r.Advertisement.Title,
			ItemID:       r.Advertisement.ID,
			Date:         r.CreationDate.Time.Format("2006-01-02"),
			Details:      details,
		})
	}

	out(map[string]any{
		"seller_id": *sellerID,
		"summary": summaryResult{
			AverageScore: resp.Summary.AverageScore,
			Count:        resp.Summary.NumberOfReviews,
		},
		"reviews": reviews,
	})
}

type detailResult struct {
	Category string   `json:"category"`
	Score    int      `json:"score"`
	Traits   []string `json:"traits,omitempty"`
}

// categories — list full category tree
//
//	mrktplaats categories
func cmdCategories(args []string) {
	fs := flag.NewFlagSet("categories", flag.ExitOnError)
	fs.Usage = func() {
		fmt.Fprintln(os.Stderr, `usage: mrktplaats categories

List the full category tree. Does not require authentication.

Output: {"categories": [{category_id, name, children: [...], place_ad_allowed}]}

Flags:`)
		fs.PrintDefaults()
	}
	_ = fs.Parse(args)

	client := mrktplaats.NewClient()
	resp, err := client.Categories.All(ctx())
	if err != nil {
		fatalf("categories: %v", err)
	}
	out(resp)
}

// similar — find similar/related listings
//
//	mrktplaats similar --urn URN [--seller SELLER_ID] [--category CATEGORY_ID]
func cmdSimilar(args []string) {
	fs := flag.NewFlagSet("similar", flag.ExitOnError)
	urn := fs.String("urn", "", "listing URN (required)")
	sellerID := fs.String("seller", "", "seller ID (resolved from listing if omitted)")
	categoryID := fs.Int("category", 0, "category ID (resolved from listing if omitted)")
	fs.Usage = func() {
		fmt.Fprintln(os.Stderr, `usage: mrktplaats similar --urn URN [--seller ID] [--category ID]

Find similar/related listings for a given listing. If --seller and --category
are omitted they are resolved automatically from the listing details.

Output: {"items": [{id, title, price, page_location}], "result_type": "..."}

Flags:`)
		fs.PrintDefaults()
	}
	_ = fs.Parse(args)
	if *urn == "" {
		fs.Usage()
		os.Exit(1)
	}

	client := authedClient()

	// Resolve seller and category from the listing if not provided.
	if *sellerID == "" || *categoryID == 0 {
		detail, err := client.Listings.Get(ctx(), *urn)
		if err != nil {
			fatalf("similar: fetching listing %s: %v", *urn, err)
		}
		if *sellerID == "" {
			*sellerID = fmt.Sprintf("%d", detail.SellerInformation.ID)
		}
		if *categoryID == 0 {
			*categoryID = detail.AdCore.CategoryID
		}
	}

	// The Relevant.Get API uses the numeric listing ID, not the full URN.
	// Strip the "m" prefix if present (e.g. "m2372861012" -> "2372861012").
	listingID := strings.TrimPrefix(*urn, "m")

	resp, err := client.Relevant.Get(ctx(), listingID, *sellerID, *categoryID)
	if err != nil {
		fatalf("similar: %v", err)
	}
	out(resp)
}

// suggest — search autocomplete suggestions
//
//	mrktplaats suggest --prefix TEXT [--category ID]
func cmdSuggest(args []string) {
	fs := flag.NewFlagSet("suggest", flag.ExitOnError)
	prefix := fs.String("prefix", "", "search prefix (required)")
	category := fs.Int("category", 0, "category ID (0 = all)")
	fs.Usage = func() {
		fmt.Fprintln(os.Stderr, `usage: mrktplaats suggest --prefix TEXT [--category ID]

Get search autocomplete suggestions. Does not require authentication.

Output: {"keyword": "...", "category": N, "suggestions": ["...", ...]}

Flags:`)
		fs.PrintDefaults()
	}
	_ = fs.Parse(args)
	if *prefix == "" {
		fs.Usage()
		os.Exit(1)
	}

	client := mrktplaats.NewClient()
	resp, err := client.Search.KeywordSuggestions(ctx(), *prefix, *category)
	if err != nil {
		fatalf("suggest: %v", err)
	}
	out(resp)
}

// notifications — unread notification count
//
//	mrktplaats notifications
func cmdNotifications(args []string) {
	fs := flag.NewFlagSet("notifications", flag.ExitOnError)
	fs.Usage = func() {
		fmt.Fprintln(os.Stderr, `usage: mrktplaats notifications

Show unread notification count. Requires authentication.

Output: {"unread": N}

Flags:`)
		fs.PrintDefaults()
	}
	_ = fs.Parse(args)

	client := authedClient()
	resp, err := client.Notifications.UnreadCount(ctx())
	if err != nil {
		fatalf("notifications: %v", err)
	}
	out(map[string]any{
		"unread": resp.UnreadNotificationsCount,
	})
}

// create-ad — publish a new listing
//
//	mrktplaats create-ad --category ID --title T --description D --price EUROS [options]
func cmdCreateAd(args []string) {
	fs := flag.NewFlagSet("create-ad", flag.ExitOnError)
	categoryID := fs.Int("category", 0, "category ID (required — use 'syi-form' to find)")
	title := fs.String("title", "", "listing title (required)")
	description := fs.String("description", "", "listing description (required)")
	price := fs.String("price", "", "price in euros, e.g. 25 or 25.50 (required)")
	priceType := fs.String("price-type", "FIXED", "price type: FIXED | NEGOTIABLE | MIN_BID | FAST_BID | FREE | SEE_DESCRIPTION")
	delivery := fs.String("delivery", "PICKUP", "delivery method: PICKUP | SHIP | PICKUP_AND_SHIP")
	sellerName := fs.String("seller-name", "", "seller display name (required)")
	postcode := fs.String("postcode", "", "postcode (required)")
	pictureIDs := fs.String("pictures", "", "comma-separated picture IDs from 'upload-image'")
	bidding := fs.Bool("bidding", false, "enable bidding")
	fs.Usage = func() {
		fmt.Fprintln(os.Stderr, `usage: mrktplaats create-ad --category ID --title T --description D --price EUROS [options]

Publish a new listing. Requires authentication.

Use 'mrktplaats syi-form --category ID' to see required attributes for a category.
Use 'mrktplaats upload-image --file PATH' to upload images first.

Output: full CreateAdResponse with URN of the new listing

Flags:`)
		fs.PrintDefaults()
	}
	_ = fs.Parse(args)
	if *categoryID == 0 || *title == "" || *description == "" || *price == "" || *sellerName == "" || *postcode == "" {
		fs.Usage()
		os.Exit(1)
	}

	var picIDs []string
	if *pictureIDs != "" {
		picIDs = strings.Split(*pictureIDs, ",")
	}

	req := &mrktplaats.CreateAdRequest{
		CategoryID: *categoryID,
		Translations: []mrktplaats.Translation{{
			Locale:      "nl",
			Title:       *title,
			Description: *description,
		}},
		PriceInCents:   eurosToC(*price),
		PriceType:      *priceType,
		BiddingEnabled: *bidding,
		DeliveryMethod: *delivery,
		SellerName:     *sellerName,
		Postcode:       *postcode,
		PictureIDs:     picIDs,
		Attributes:     []any{},
		FeatureTypes:   []any{},
		ShippingConfig: mrktplaats.ShippingConfig{Carriers: []string{}},
		SelectedBundle: "FREE",
	}

	client := authedClient()
	resp, err := client.SYI.Create(ctx(), req)
	if err != nil {
		fatalf("create-ad: %v", err)
	}
	out(map[string]any{
		"status": "created",
		"urn":    resp.URN(),
		"ad":     resp.Ad,
	})
}

// upload-image — upload an image for a listing
//
//	mrktplaats upload-image --file PATH
func cmdUploadImage(args []string) {
	fs := flag.NewFlagSet("upload-image", flag.ExitOnError)
	filePath := fs.String("file", "", "path to image file (required)")
	fs.Usage = func() {
		fmt.Fprintln(os.Stderr, `usage: mrktplaats upload-image --file PATH

Upload an image for use in a listing. Returns a picture ID to pass to 'create-ad --pictures'.
Requires authentication. Large images are automatically compressed.

Output: {"picture_id": "..."}

Flags:`)
		fs.PrintDefaults()
	}
	_ = fs.Parse(args)
	if *filePath == "" {
		fs.Usage()
		os.Exit(1)
	}

	f, err := os.Open(*filePath)
	if err != nil {
		fatalf("upload-image: opening file: %v", err)
	}
	defer f.Close()

	filename := filepath.Base(*filePath)
	client := authedClient()
	picID, err := client.SYI.UploadImage(ctx(), f, filename)
	if err != nil {
		fatalf("upload-image: %v", err)
	}
	out(map[string]any{
		"picture_id": picID,
	})
}

// syi-form — show required attributes for a category
//
//	mrktplaats syi-form --category ID
func cmdSYIForm(args []string) {
	fs := flag.NewFlagSet("syi-form", flag.ExitOnError)
	categoryID := fs.Int("category", 0, "category ID (required)")
	fs.Usage = func() {
		fmt.Fprintln(os.Stderr, `usage: mrktplaats syi-form --category ID

Show the listing creation form (required/optional attributes) for a category.
Requires authentication.

Output: {"category_id": N, "attributes": [{id, key, label, type, mandatory, values: [...]}]}

Flags:`)
		fs.PrintDefaults()
	}
	_ = fs.Parse(args)
	if *categoryID == 0 {
		fs.Usage()
		os.Exit(1)
	}

	client := authedClient()
	resp, err := client.SYI.Form(ctx(), *categoryID)
	if err != nil {
		fatalf("syi-form: %v", err)
	}

	type attrResult struct {
		ID        string `json:"id"`
		Key       string `json:"key"`
		Label     string `json:"label"`
		Type      string `json:"type"`
		Mandatory bool   `json:"mandatory"`
		Values    []struct {
			ID    string `json:"id"`
			Key   string `json:"key"`
			Label string `json:"label"`
		} `json:"values,omitempty"`
	}
	var attrs []attrResult
	for _, a := range resp.SYIAttributes {
		ar := attrResult{
			ID:        a.ID,
			Key:       a.Key,
			Label:     a.Label,
			Type:      a.AttributeType,
			Mandatory: a.Mandatory,
		}
		for _, v := range a.SupportedValues {
			ar.Values = append(ar.Values, struct {
				ID    string `json:"id"`
				Key   string `json:"key"`
				Label string `json:"label"`
			}{ID: v.ID, Key: v.Key, Label: v.Label})
		}
		attrs = append(attrs, ar)
	}
	out(map[string]any{
		"category_id": resp.CategoryID,
		"attributes":  attrs,
	})
}

// price-suggestion — get suggested price ranges
//
//	mrktplaats price-suggestion --category ID --title TEXT
func cmdPriceSuggestion(args []string) {
	fs := flag.NewFlagSet("price-suggestion", flag.ExitOnError)
	categoryID := fs.Int("category", 0, "category ID (required)")
	title := fs.String("title", "", "listing title (required)")
	fs.Usage = func() {
		fmt.Fprintln(os.Stderr, `usage: mrktplaats price-suggestion --category ID --title TEXT

Get suggested price ranges for a listing based on similar items.
Does not require authentication.

Output: {"segments": [{title, min_price_cents, max_price_cents, min_euros, max_euros, similar_count}]}

Flags:`)
		fs.PrintDefaults()
	}
	_ = fs.Parse(args)
	if *categoryID == 0 || *title == "" {
		fs.Usage()
		os.Exit(1)
	}

	client := mrktplaats.NewClient()
	resp, err := client.SYI.PriceSuggestion(ctx(), *categoryID, *title)
	if err != nil {
		fatalf("price-suggestion: %v", err)
	}

	type segResult struct {
		Title         string  `json:"title"`
		MinPriceCents int     `json:"min_price_cents"`
		MaxPriceCents int     `json:"max_price_cents"`
		MinEuros      float64 `json:"min_euros"`
		MaxEuros      float64 `json:"max_euros"`
		SimilarCount  int     `json:"similar_count"`
	}
	var segs []segResult
	for _, s := range resp.Segments {
		segs = append(segs, segResult{
			Title:         s.Title,
			MinPriceCents: s.MinPrice,
			MaxPriceCents: s.MaxPrice,
			MinEuros:      float64(s.MinPrice) / 100,
			MaxEuros:      float64(s.MaxPrice) / 100,
			SimilarCount:  s.TotalSimilarAdsCount,
		})
	}
	out(map[string]any{
		"segments": segs,
	})
}

// recognize — auto-detect category from a photo
//
//	mrktplaats recognize --file PATH
func cmdRecognize(args []string) {
	fs := flag.NewFlagSet("recognize", flag.ExitOnError)
	filePath := fs.String("file", "", "path to image file (required)")
	fs.Usage = func() {
		fmt.Fprintln(os.Stderr, `usage: mrktplaats recognize --file PATH

Upload an image and get category/attribute predictions via image recognition.
Does not require authentication. Large images are automatically compressed.

Output: {"predictions": [{category_id, category_name, confidence, attributes}]}

Flags:`)
		fs.PrintDefaults()
	}
	_ = fs.Parse(args)
	if *filePath == "" {
		fs.Usage()
		os.Exit(1)
	}

	f, err := os.Open(*filePath)
	if err != nil {
		fatalf("recognize: opening file: %v", err)
	}
	defer f.Close()

	filename := filepath.Base(*filePath)
	client := mrktplaats.NewClient()
	resp, err := client.Saleability.Recognize(ctx(), f, filename)
	if err != nil {
		fatalf("recognize: %v", err)
	}
	out(resp)
}

// ── usage ─────────────────────────────────────────────────────────────────────

func printUsage() {
	fmt.Fprint(os.Stderr, `mrktplaats — marktplaats.nl command-line client

USAGE
  mrktplaats <command> [flags]

AUTHENTICATION
  Token resolution order:
    1. MRKTPLAATS_TOKEN environment variable
    2. ~/.config/mrktplaats/token (written automatically after login)
    3. ./.mrktplaats-token in the current directory

  mrktplaats login --email EMAIL --password PASS [--code 2FA_CODE]

COMMANDS
  Authentication
    login            Authenticate and save access token

  Browsing (no auth required)
    search           Search listings             --query Q [--category ID] [--page N] [--size N] [--sort FIELD]
    listing          Get listing details         --urn URN
    seller-listings  All listings by a seller    --seller ID  or  --urn URN
    reviews          View seller reviews         --seller ID  or  --urn URN  [--role reviewee|all]
    categories       List full category tree
    similar          Find similar listings       --urn URN [--seller ID] [--category ID]
    suggest          Search autocomplete         --prefix TEXT [--category ID]

  Messaging
    conversations    List conversations          [--limit N]
    messages         Read conversation messages  --conv CONV_ID [--limit N]
    send             Start a new conversation    --urn URN --text TEXT
    reply            Reply in a conversation     --conv CONV_ID --text TEXT

  Bidding
    bid              Place a bid                 --urn URN --amount EUROS [--message TEXT]
    remove-bid       Remove a bid                --id BID_ID

  Favourites
    favorites        List saved listings         [--limit N]
    add-favorite     Save a listing              --urn URN
    remove-favorite  Unsave a listing            --urn URN

  My Account
    me               Show own profile
    my-ads           List own listings           [--status active|inactive|sold] [--limit N]
    delete-ad        Delete a listing            --urn URN [--reason 1-4]
    notifications    Unread notification count

  Selling (SYI)
    syi-form         Show category form attrs    --category ID
    create-ad        Publish a new listing       --category ID --title T --description D --price EUROS
    upload-image     Upload image for listing    --file PATH
    price-suggestion Suggested price ranges      --category ID --title TEXT
    recognize        Auto-detect category        --file PATH (image recognition)

Run 'mrktplaats <command> --help' for flag details.

OUTPUT
  All commands output pretty-printed JSON on stdout.
  Errors are printed on stderr with exit code 1.

EXAMPLES
  # Find a watch listing
  mrktplaats search --query "seiko" --category 1831 --size 5

  # Get full details including bid info
  mrktplaats listing --urn m2372861012

  # Message the seller
  mrktplaats send --urn m2372861012 --text "Is this still available?"

  # Reply to an existing conversation
  mrktplaats conversations
  mrktplaats reply --conv 1cgx:5qgvx3h:2p1gxt95d --text "Thanks, I will pick it up."

  # Place a €25 bid
  mrktplaats bid --urn m2372861012 --amount 25

  # Save and remove a favourite
  mrktplaats add-favorite --urn m2372861012
  mrktplaats remove-favorite --urn m2372861012

  # Sell an item
  mrktplaats recognize --file photo.jpg
  mrktplaats syi-form --category 1831
  mrktplaats price-suggestion --category 1831 --title "Seiko SKX007"
  mrktplaats upload-image --file photo.jpg
  mrktplaats create-ad --category 1831 --title "Seiko SKX007" --description "Great watch" --price 150 --seller-name "Jan" --postcode "1000"
`)
}

// ── main ──────────────────────────────────────────────────────────────────────

func main() {
	if len(os.Args) < 2 {
		printUsage()
		os.Exit(1)
	}

	cmd := os.Args[1]
	args := os.Args[2:]

	switch cmd {
	case "login":
		cmdLogin(args)
	case "search":
		cmdSearch(args)
	case "listing":
		cmdListing(args)
	case "conversations":
		cmdConversations(args)
	case "messages":
		cmdMessages(args)
	case "send":
		cmdSend(args)
	case "reply":
		cmdReply(args)
	case "bid":
		cmdBid(args)
	case "remove-bid":
		cmdRemoveBid(args)
	case "favorites":
		cmdFavorites(args)
	case "add-favorite":
		cmdAddFavorite(args)
	case "remove-favorite":
		cmdRemoveFavorite(args)
	case "me":
		cmdMe(args)
	case "my-ads":
		cmdMyAds(args)
	case "delete-ad":
		cmdDeleteAd(args)
	case "seller-listings":
		cmdSellerListings(args)
	case "reviews":
		cmdReviews(args)
	case "categories":
		cmdCategories(args)
	case "similar":
		cmdSimilar(args)
	case "suggest":
		cmdSuggest(args)
	case "notifications":
		cmdNotifications(args)
	case "create-ad":
		cmdCreateAd(args)
	case "upload-image":
		cmdUploadImage(args)
	case "syi-form":
		cmdSYIForm(args)
	case "price-suggestion":
		cmdPriceSuggestion(args)
	case "recognize":
		cmdRecognize(args)
	case "help", "--help", "-h":
		printUsage()
	default:
		fmt.Fprintf(os.Stderr, "error: unknown command %q\n\n", cmd)
		printUsage()
		os.Exit(1)
	}
}
