// app.go wires the application service stack together and exposes
// frontend-callable methods through Wails bindings.
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"image"
	"log/slog"
	"os"
	"path/filepath"
	"sync"
	"sync/atomic"
	"time"

	wruntime "github.com/wailsapp/wails/v2/pkg/runtime"

	"github.com/mmmy/snapgo/internal/application"
	"github.com/mmmy/snapgo/internal/domain"
	"github.com/mmmy/snapgo/internal/infrastructure/clipboard"
	"github.com/mmmy/snapgo/internal/infrastructure/config"
	"github.com/mmmy/snapgo/internal/infrastructure/display"
	"github.com/mmmy/snapgo/internal/infrastructure/hotkey"
	llmpkg "github.com/mmmy/snapgo/internal/infrastructure/llm"
	ocrpkg "github.com/mmmy/snapgo/internal/infrastructure/ocr"
	"github.com/mmmy/snapgo/internal/infrastructure/oss"
	"github.com/mmmy/snapgo/internal/infrastructure/screencapture"
	sshpkg "github.com/mmmy/snapgo/internal/infrastructure/ssh"
)

// App is the struct exposed to the Wails frontend through Bind.
//
// We deliberately keep this struct small: it owns long-lived collaborators
// (config store, hotkey manager, capturer, clipboard) but delegates the
// real work to the application service constructed on demand once an OSS
// provider is configured.
type App struct {
	ctx context.Context

	mu         sync.RWMutex
	cfg        domain.AppConfig
	configFile *config.FileStore
	hotkeyMgr  *hotkey.Manager
	capturer   screencapture.Capturer
	clip       clipboard.Writer

	// capturing prevents re-entrant capture sessions when the user mashes
	// the hotkey while a previous one is still running. It stays true for
	// the entire lifecycle of one capture: from overlay start until the
	// overlay is either confirmed (ConfirmRegion) or discarded (CancelRegion).
	capturing atomic.Bool

	// pendingMu guards `pending`. We keep this separate from `mu` so that
	// frontend-triggered RPC handlers (which may run concurrently with the
	// capture goroutine) can take it cheaply.
	pendingMu sync.Mutex
	pending   *pendingCapture
}

// pendingCapture holds the OSS provider chosen when capture started.
// Keeping the provider here prevents a config edit
// between "snap" and "confirm" from silently retargeting the upload.
type pendingCapture struct {
	Provider domain.OSSProvider
	Display  display.Info
}

// OverlayPayload is the JSON the Go side emits so the WebView can build the
// Snipaste-style overlay. It deliberately does not contain screenshot bytes:
// the window is transparent and the mask sits over the live desktop.
type OverlayPayload struct {
	CSSWidth  int     `json:"cssWidth"`
	CSSHeight int     `json:"cssHeight"`
	Scale     float64 `json:"scale"`
}

// RegionRect is the CSS-pixel selection rectangle the WebView reports.
// Origin is the top-left of the primary display.
type RegionRect struct {
	X int `json:"x"`
	Y int `json:"y"`
	W int `json:"w"`
	H int `json:"h"`
}

// CaptureResult is returned by the overlay with the final selection and any
// annotations drawn inside the selected area.
type CaptureResult struct {
	Rect        RegionRect               `json:"rect"`
	Annotations []application.Annotation `json:"annotations"`
}

// CaptureActionResult tells the frontend whether an action was completed or
// the user cancelled a secondary choice such as the save destination dialog.
type CaptureActionResult struct {
	Completed bool   `json:"completed"`
	Path      string `json:"path,omitempty"`
}

// OperationStatusPayload drives the frontend status HUD and mirrors the
// native macOS HUD state.
type OperationStatusPayload struct {
	Operation string `json:"operation"`
	Phase     string `json:"phase"`
	Message   string `json:"message"`
	State     string `json:"state"`
}

// NewApp creates a new App with collaborators already initialised.
func NewApp() *App {
	store, err := config.NewFileStore()
	if err != nil {
		slog.Error("config store init", "err", err)
	}
	cfg := domain.DefaultAppConfig()
	if store != nil {
		if loaded, lerr := store.Load(); lerr == nil {
			cfg = loaded
		} else {
			slog.Warn("config load failed, using defaults", "err", lerr)
		}
	}
	cfg.Normalize()
	return &App{
		cfg:        cfg,
		configFile: store,
		hotkeyMgr:  hotkey.NewManager(),
		capturer:   screencapture.New(),
		clip:       clipboard.New(),
	}
}

// startup is invoked by Wails once the runtime is available.
func (a *App) startup(ctx context.Context) {
	a.ctx = ctx
	if err := a.registerHotkey(a.cfg.Hotkey); err != nil {
		slog.Warn("register hotkey failed", "err", err)
		wruntime.EventsEmit(a.ctx, "hotkey:error", err.Error())
	} else {
		wruntime.EventsEmit(a.ctx, "hotkey:ready", a.cfg.Hotkey)
	}
}

// shutdown releases OS resources.
func (a *App) shutdown(_ context.Context) {
	a.hotkeyMgr.Unregister()
}

// ---------------------------------------------------------------------------
// Hotkey management
// ---------------------------------------------------------------------------

func (a *App) registerHotkey(spec string) error {
	if spec == "" {
		spec = "cmd+shift+a"
	}
	return a.hotkeyMgr.Register(spec, func() {
		a.runInteractiveCapture()
	})
}

// runInteractiveCapture is the unified entry point for the global hotkey
// and the in-app "Capture now" button. The new flow is:
//
//  1. Hide the main window so it never flashes over the desktop.
//  2. Re-show it as a transparent, high-level overlay that paints only a
//     dimming mask plus selection chrome over the live screen.
//  3. Wait — the user finishes selecting in the WebView, which calls
//     ConfirmRegion(rect) or CancelRegion() back into us.
//
// We deliberately do NOT block the goroutine on a channel here: the
// "wait" is implicit. capturing stays true until the frontend resolves it.
func (a *App) runInteractiveCapture() {
	if a.ctx == nil {
		return
	}
	if !a.capturing.CompareAndSwap(false, true) {
		return
	}
	releaseOnExit := true
	defer func() {
		if releaseOnExit {
			a.capturing.Store(false)
		}
	}()

	a.mu.RLock()
	cfg := a.cfg
	a.mu.RUnlock()

	var provider domain.OSSProvider
	if cfg.IsS3Configured() {
		var err error
		provider, err = oss.NewS3Provider(cfg.S3)
		if err != nil {
			wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
		}
	}

	// Hide the settings window before transforming it into an overlay, so
	// the user only sees the final transparent mask state.
	wruntime.WindowHide(a.ctx)
	hideDockIcon()
	flushFrame()
	wruntime.EventsEmit(a.ctx, "capture:start", nil)

	info := display.Primary()

	// Park the provider and morph the window into a transparent overlay.
	a.pendingMu.Lock()
	a.pending = &pendingCapture{
		Provider: provider,
		Display:  info,
	}
	a.pendingMu.Unlock()

	if showNativeCaptureOverlay(a, info) {
		releaseOnExit = false // ownership passes to the native overlay callbacks
		return
	}

	payload := OverlayPayload{
		CSSWidth:  info.CSSWidth,
		CSSHeight: info.CSSHeight,
		Scale:     info.Scale,
	}
	a.showOverlayWindow(info)
	wruntime.EventsEmit(a.ctx, "capture:overlay", payload)
	flushFrame()
	wruntime.WindowShow(a.ctx)
	configureOverlayWindow()

	releaseOnExit = false // ownership of `capturing` passes to the overlay lifecycle
}

// ---------------------------------------------------------------------------
// Overlay window helpers
// ---------------------------------------------------------------------------

const (
	settingsWidth  = 1000
	settingsHeight = 820
)

// showOverlayWindow prepares the hidden main window as a borderless,
// always-on-top, transparent full-primary-display window. The caller emits
// the overlay event and only then shows it, avoiding an opaque grey flash.
func (a *App) showOverlayWindow(info display.Info) {
	if a.ctx == nil {
		return
	}
	wruntime.WindowSetBackgroundColour(a.ctx, 0, 0, 0, 0)
	wruntime.WindowSetAlwaysOnTop(a.ctx, true)
	wruntime.WindowSetSize(a.ctx, info.CSSWidth, info.CSSHeight)
	wruntime.WindowSetPosition(a.ctx, 0, 0)
	configureOverlayWindow()
}

// restoreSettingsWindow flips the window back from overlay form to its
// normal Settings size and unpins it. We do NOT call Show here — callers
// decide whether the user actually wanted Settings to appear.
func (a *App) restoreSettingsWindow() {
	if a.ctx == nil {
		return
	}
	restoreOverlayWindow()
	wruntime.WindowSetBackgroundColour(a.ctx, 246, 247, 250, 255)
	wruntime.WindowSetAlwaysOnTop(a.ctx, false)
	wruntime.WindowSetSize(a.ctx, settingsWidth, settingsHeight)
	wruntime.WindowCenter(a.ctx)
}

// surfaceWindow brings the main window back from a hidden state regardless
// of which mechanism we used to suppress it.
func (a *App) surfaceWindow() {
	if a.ctx == nil {
		return
	}
	showDockIcon(true)
	a.restoreSettingsWindow()
	wruntime.WindowUnminimise(a.ctx)
	wruntime.WindowShow(a.ctx)
}

// dismissOverlay hides the overlay window and shrinks/centres it back to
// the Settings dimensions so a future ShowWindow lands in the right place.
func (a *App) dismissOverlay() {
	if a.ctx == nil {
		return
	}
	wruntime.WindowHide(a.ctx)
	hideDockIcon()
	a.restoreSettingsWindow()
}

// runUploadPipeline is the post-capture half of the workflow. Shared by
// the hotkey path and the explicit "Upload now" RPC.
func (a *App) runUploadPipeline(provider domain.OSSProvider, pngBytes []byte) error {
	a.mu.RLock()
	cfg := a.cfg
	a.mu.RUnlock()

	svc := &application.CaptureAndUploadService{
		Capturer:    a.capturer,
		Provider:    provider,
		Clipboard:   a.clip,
		Notifier:    &runtimeNotifier{ctx: a.ctx},
		FallbackDir: filepath.Join(userPicturesDir(), "SnapGo"),
		PathPrefix:  cfg.S3.PathPrefix,
	}
	a.emitOperationStatus("upload", "上传中", "正在上传截图到 S3", "running")
	if err := svc.ExecuteWithBytes(a.ctx, pngBytes); err != nil {
		a.emitOperationStatus("upload", "上传失败", err.Error(), "error")
		return err
	}
	a.emitOperationStatus("upload", "上传完成", "链接已复制到剪贴板", "success")
	return nil
}

func (a *App) runCopyImagePipeline(pngBytes []byte) error {
	svc := &application.CaptureActionsService{
		Clipboard: a.clip,
	}
	a.emitOperationStatus("copy", "复制中", "正在把截图复制到剪贴板", "running")
	if err := svc.CopyImage(a.ctx, pngBytes); err != nil {
		a.emitOperationStatus("copy", "复制失败", err.Error(), "error")
		wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
		return err
	}
	a.emitOperationStatus("copy", "复制完成", "截图已复制到剪贴板", "success")
	wruntime.EventsEmit(a.ctx, "upload:success", "image copied to clipboard")
	return nil
}

func (a *App) runSaveImagePipeline(pngBytes []byte, dir string) (string, error) {
	svc := &application.CaptureActionsService{
		Clipboard: a.clip,
	}
	a.emitOperationStatus("save", "保存中", "正在保存截图到本地", "running")
	path, err := svc.SaveImage(a.ctx, pngBytes, dir)
	if err != nil {
		a.emitOperationStatus("save", "保存失败", err.Error(), "error")
		wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
		return "", err
	}
	a.emitOperationStatus("save", "保存完成", "文件路径已复制到剪贴板："+path, "success")
	wruntime.EventsEmit(a.ctx, "upload:success", path)
	return path, nil
}

// runSaveRemotePipeline uploads the captured PNG to the configured SSH host
// via SCP and copies a sharable reference (URL or "user@host:~/path") to
// the clipboard.
//
// Why a dedicated method (vs. extending CaptureAndUploadService): SSH and
// S3 have different config + clipboard semantics, so keeping them separate
// avoids leaking provider-specific branches into the OSS pipeline.
func (a *App) runSaveRemotePipeline(pngBytes []byte) error {
	a.mu.RLock()
	cfg := a.cfg
	a.mu.RUnlock()

	if !cfg.IsSSHConfigured() {
		err := fmt.Errorf("SSH host/user is not configured")
		slog.Warn("save-remote rejected: ssh not configured",
			"host", cfg.SSH.Host, "user", cfg.SSH.User)
		a.emitOperationStatus("save-remote", "需要配置 SSH", err.Error(), "error")
		wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
		return err
	}
	slog.Info("save-remote dispatch",
		"host", cfg.SSH.Host, "user", cfg.SSH.User, "port", cfg.SSH.Port,
		"auth_method", cfg.SSH.AuthMethod, "png_size", len(pngBytes))

	// Select the uploader by auth method: Kerberos delegates to the system
	// ssh/scp binaries (reusing the kinit credential cache), while builtin
	// uses the in-process Go SSH client.
	var uploader application.SSHUploader
	if cfg.SSH.IsKerberos() {
		uploader = sshpkg.NewKerberosUploader(cfg.SSH)
	} else {
		uploader = sshpkg.NewUploader(cfg.SSH)
	}

	svc := &application.CaptureAndSSHService{
		Uploader:  uploader,
		Clipboard: a.clip,
		Notifier:  &runtimeNotifier{ctx: a.ctx},
		Cfg:       cfg.SSH,
	}
	a.emitOperationStatus("save-remote", "保存中", "正在保存截图到远端 SSH", "running")
	if err := svc.ExecuteWithBytes(a.ctx, pngBytes); err != nil {
		a.emitOperationStatus("save-remote", "保存失败", err.Error(), "error")
		return err
	}
	a.emitOperationStatus("save-remote", "保存完成", "远端路径已复制到剪贴板", "success")
	return nil
}

func (a *App) runSummaryPipeline(pngBytes []byte) error {
	a.mu.RLock()
	cfg := a.cfg
	a.mu.RUnlock()

	if !cfg.IsLLMConfigured() {
		err := fmt.Errorf("请先在 LLM 配置页填写 API Key 和模型")
		a.emitOperationStatus("summary", "需要配置 LLM", err.Error(), "error")
		wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
		return err
	}

	providerID, llmCfg, _ := cfg.ActiveLLMProvider()
	visionClient, err := llmpkg.NewVisionClient(providerID, llmCfg)
	if err != nil {
		a.emitOperationStatus("summary", "LLM 配置错误", err.Error(), "error")
		wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
		return err
	}

	svc := &application.CaptureSummaryService{
		Summarizer:     visionClient,
		Clipboard:      a.clip,
		Prompt:         cfg.LLM.Prompt,
		PathPrefix:     cfg.S3.PathPrefix,
		MaxInlineBytes: llmCfg.MaxInlineBytes,
	}

	// Preferred path: send the screenshot inline as a base64 data URL so the
	// summary never depends on S3 being configured or publicly reachable.
	if svc.CanInline(pngBytes) {
		a.emitOperationStatus("summary", "识别中", "正在请求多模态模型（本地内联图片）", "running")
		summary, err := svc.Summarize(a.ctx, svc.InlineDataURL(pngBytes))
		if err != nil {
			a.emitOperationStatus("summary", "识别失败", err.Error(), "error")
			wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
			return err
		}
		return a.finishSummary(svc, summary)
	}

	// Oversized image: fall back to uploading to S3 and passing a public URL.
	if !cfg.IsS3Configured() {
		err := fmt.Errorf("截图尺寸超过内联上限（%d 字节），请缩小截图区域，或在 S3 配置页填写公网可达的对象存储后重试", llmCfg.MaxInlineBytes)
		a.emitOperationStatus("summary", "尺寸超限", err.Error(), "error")
		wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
		return err
	}

	s3Provider, err := oss.NewS3Provider(cfg.S3)
	if err != nil {
		a.emitOperationStatus("summary", "S3 配置错误", err.Error(), "error")
		wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
		return err
	}
	svc.Provider = s3Provider

	a.emitOperationStatus("summary", "上传中", "截图较大，正在上传供模型读取", "running")
	uploaded, err := svc.UploadImage(a.ctx, pngBytes)
	if err != nil {
		a.emitOperationStatus("summary", "上传失败", err.Error(), "error")
		wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
		return err
	}

	// The remote model fetches the URL itself, so verify it is reachable and
	// clean up the temporary object once we are done with it either way.
	defer func() {
		if derr := svc.DeleteUploaded(a.ctx, uploaded.Key); derr != nil {
			slog.Warn("summary: failed to delete temporary object", "key", uploaded.Key, "err", derr)
		}
	}()

	if err := svc.EnsureReachable(a.ctx, uploaded.URL); err != nil {
		a.emitOperationStatus("summary", "链接不可达", err.Error(), "error")
		wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
		return err
	}

	a.emitOperationStatus("summary", "识别中", "图片已上传，正在请求多模态模型", "running")
	summary, err := svc.Summarize(a.ctx, uploaded.URL)
	if err != nil {
		a.emitOperationStatus("summary", "识别失败", err.Error(), "error")
		wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
		return err
	}
	return a.finishSummary(svc, summary)
}

// finishSummary copies the summary to the clipboard and emits the terminal
// success/failure UI state shared by the inline and S3 paths.
func (a *App) finishSummary(svc *application.CaptureSummaryService, summary string) error {
	if err := svc.CopySummary(a.ctx, summary); err != nil {
		a.emitOperationStatus("summary", "复制失败", err.Error(), "error")
		wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
		return err
	}
	a.emitOperationStatus("summary", "识别完成", "总结已复制到剪贴板", "success")
	wruntime.EventsEmit(a.ctx, "upload:success", "summary copied to clipboard")
	return nil
}

func (a *App) runOCRPipeline(pngBytes []byte) error {
	a.mu.RLock()
	cfg := a.cfg
	a.mu.RUnlock()

	if !cfg.IsOCRConfigured() {
		err := fmt.Errorf("请先在文字提取配置页填写 OCR Provider 的 AccessKey ID 和 AccessKey Secret")
		a.emitOperationStatus("ocr", "需要配置 OCR", err.Error(), "error")
		wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
		return err
	}

	providerID, ocrCfg, _ := cfg.ActiveOCRProvider()
	recognizer, err := ocrpkg.NewClient(providerID, ocrCfg)
	if err != nil {
		a.emitOperationStatus("ocr", "OCR 配置错误", err.Error(), "error")
		wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
		return err
	}

	svc := &application.CaptureOCRService{
		Recognizer: recognizer,
		Clipboard:  a.clip,
	}

	a.emitOperationStatus("ocr", "识别中", "正在提取截图文字", "running")
	text, err := svc.Recognize(a.ctx, pngBytes)
	if err != nil {
		a.emitOperationStatus("ocr", "识别失败", err.Error(), "error")
		wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
		return err
	}
	if err := svc.CopyText(a.ctx, text); err != nil {
		a.emitOperationStatus("ocr", "复制失败", err.Error(), "error")
		wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
		return err
	}
	a.emitOperationStatus("ocr", "提取完成", "文字已复制到剪贴板", "success")
	wruntime.EventsEmit(a.ctx, "upload:success", "ocr text copied to clipboard")
	return nil
}

func (a *App) consumePendingCapture() (*pendingCapture, error) {
	a.pendingMu.Lock()
	pc := a.pending
	a.pending = nil
	a.pendingMu.Unlock()

	if pc == nil {
		return nil, fmt.Errorf("no pending capture")
	}
	return pc, nil
}

func (a *App) captureSelectedPNG(result CaptureResult, pc *pendingCapture) ([]byte, error) {
	rect := result.Rect
	captureRect := image.Rect(rect.X, rect.Y, rect.X+rect.W, rect.Y+rect.H)
	cropped, err := a.capturer.CaptureRegion(captureRect)
	if err != nil {
		return nil, err
	}
	if len(result.Annotations) > 0 {
		cropped, err = application.ApplyAnnotations(cropped, result.Annotations, pc.Display.Scale)
		if err != nil {
			return nil, err
		}
	}
	return cropped, nil
}

func (a *App) chooseSaveDirectory() (string, error) {
	return wruntime.OpenDirectoryDialog(a.ctx, wruntime.OpenDialogOptions{
		Title:                "Save screenshot to folder",
		DefaultDirectory:     userPicturesDir(),
		CanCreateDirectories: true,
	})
}

// ---------------------------------------------------------------------------
// Bound methods (called from the frontend via Wails)
// ---------------------------------------------------------------------------

// GetConfig returns the current persisted configuration.
func (a *App) GetConfig() domain.AppConfig {
	a.mu.RLock()
	defer a.mu.RUnlock()
	return a.cfg
}

// SaveConfig persists the supplied configuration and re-registers the hotkey
// if it changed.
func (a *App) SaveConfig(cfg domain.AppConfig) error {
	cfg.Normalize()
	a.mu.Lock()
	prev := a.cfg
	a.cfg = cfg
	a.mu.Unlock()

	if a.configFile != nil {
		if err := a.configFile.Save(cfg); err != nil {
			return err
		}
	}
	if cfg.Hotkey != prev.Hotkey {
		if err := a.registerHotkey(cfg.Hotkey); err != nil {
			wruntime.EventsEmit(a.ctx, "hotkey:error", err.Error())
			return fmt.Errorf("register new hotkey: %w", err)
		}
		wruntime.EventsEmit(a.ctx, "hotkey:ready", cfg.Hotkey)
	}
	return nil
}

// RetryRegisterHotkey re-registers the configured hotkey on demand.
func (a *App) RetryRegisterHotkey() error {
	a.mu.RLock()
	spec := a.cfg.Hotkey
	a.mu.RUnlock()
	if err := a.registerHotkey(spec); err != nil {
		wruntime.EventsEmit(a.ctx, "hotkey:error", err.Error())
		return err
	}
	wruntime.EventsEmit(a.ctx, "hotkey:ready", spec)
	return nil
}

// TestConnection performs a put+delete probe against the supplied S3 config.
func (a *App) TestConnection(cfg domain.S3Config) error {
	provider, err := oss.NewS3Provider(cfg)
	if err != nil {
		return err
	}
	return provider.TestConnection(a.ctx)
}

// TestSSHConnection performs a minimal SSH handshake probe against the
// supplied configuration so the SettingsView's "Test connection" button
// can give the user immediate feedback.
func (a *App) TestSSHConnection(cfg domain.SSHConfig) error {
	slog.Info("RPC TestSSHConnection",
		"host", cfg.Host, "user", cfg.User, "port", cfg.Port,
		"auth_method", cfg.AuthMethod,
		"strict_host_key", cfg.StrictHostKey, "has_password", cfg.Password != "")
	// Kerberos auth delegates to the system ssh binary (see KerberosUploader)
	// so its probe path differs from the in-process client.
	if cfg.IsKerberos() {
		if err := sshpkg.TestKerberosConnection(a.ctx, cfg); err != nil {
			slog.Error("RPC TestSSHConnection (kerberos) failed", "err", err)
			return err
		}
		return nil
	}
	if err := sshpkg.TestConnection(a.ctx, cfg); err != nil {
		slog.Error("RPC TestSSHConnection failed", "err", err)
		return err
	}
	return nil
}

// CaptureNow is the in-app trigger.
func (a *App) CaptureNow() {
	go a.runInteractiveCapture()
}

// ConfirmRegion is invoked by the overlay UI when the user clicks
// "Upload & copy". The overlay is transparent and sits over the live
// desktop, so we hide it first, then ask the OS to capture the selected
// logical screen rectangle directly.
//
// Returning the error to the frontend lets the overlay decide whether to
// keep showing the screenshot (e.g. for a retry) — currently it just
// dismisses regardless and surfaces the error via the upload:failure toast.
func (a *App) ConfirmRegion(result CaptureResult) error {
	pc, err := a.consumePendingCapture()
	if err != nil {
		return err
	}
	defer func() {
		a.capturing.Store(false)
		a.dismissOverlay()
	}()

	a.dismissOverlay()
	flushFrame()

	cropped, err := a.captureSelectedPNG(result, pc)
	if err != nil {
		wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
		return err
	}
	if err := a.runUploadPipeline(pc.Provider, cropped); err != nil {
		return err
	}
	return nil
}

// CopyRegionImage is invoked by the fallback Wails overlay when the user
// double-clicks the selected area.
func (a *App) CopyRegionImage(result CaptureResult) error {
	pc, err := a.consumePendingCapture()
	if err != nil {
		return err
	}
	defer func() {
		a.capturing.Store(false)
		a.dismissOverlay()
	}()

	a.dismissOverlay()
	flushFrame()

	cropped, err := a.captureSelectedPNG(result, pc)
	if err != nil {
		wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
		return err
	}
	return a.runCopyImagePipeline(cropped)
}

// SaveRegionImage lets the fallback Wails overlay pick a native destination
// folder and writes the selected screenshot PNG there.
func (a *App) SaveRegionImage(result CaptureResult) (CaptureActionResult, error) {
	pc, err := a.consumePendingCapture()
	if err != nil {
		return CaptureActionResult{}, err
	}

	a.dismissOverlay()
	flushFrame()

	cropped, err := a.captureSelectedPNG(result, pc)
	if err != nil {
		a.capturing.Store(false)
		wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
		return CaptureActionResult{}, err
	}

	dir, err := a.chooseSaveDirectory()
	if err != nil {
		a.capturing.Store(false)
		wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
		return CaptureActionResult{}, err
	}
	if dir == "" {
		a.capturing.Store(false)
		a.dismissOverlay()
		return CaptureActionResult{Completed: false}, nil
	}

	path, err := a.runSaveImagePipeline(cropped, dir)
	a.capturing.Store(false)
	a.dismissOverlay()
	if err != nil {
		return CaptureActionResult{}, err
	}
	return CaptureActionResult{Completed: true, Path: path}, nil
}

// ConfirmNativeRegion mirrors ConfirmRegion for the macOS native overlay.
// The native AppKit panel has already been closed by the time this method is
// called, so we must not dismiss/restore the Wails overlay window here.
func (a *App) ConfirmNativeRegion(result CaptureResult) error {
	pc, err := a.consumePendingCapture()
	if err != nil {
		return err
	}
	defer func() {
		a.capturing.Store(false)
		hideDockIcon()
	}()

	flushFrame()
	cropped, err := a.captureSelectedPNG(result, pc)
	if err != nil {
		wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
		return err
	}
	return a.runUploadPipeline(pc.Provider, cropped)
}

func (a *App) CopyNativeRegionImage(result CaptureResult) error {
	pc, err := a.consumePendingCapture()
	if err != nil {
		return err
	}
	defer func() {
		a.capturing.Store(false)
		hideDockIcon()
	}()

	flushFrame()
	cropped, err := a.captureSelectedPNG(result, pc)
	if err != nil {
		wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
		return err
	}
	return a.runCopyImagePipeline(cropped)
}

func (a *App) SaveNativeRegionImage(result CaptureResult) (CaptureActionResult, error) {
	pc, err := a.consumePendingCapture()
	if err != nil {
		return CaptureActionResult{}, err
	}
	defer func() {
		a.capturing.Store(false)
		hideDockIcon()
	}()

	flushFrame()
	cropped, err := a.captureSelectedPNG(result, pc)
	if err != nil {
		wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
		return CaptureActionResult{}, err
	}
	dir, err := a.chooseSaveDirectory()
	if err != nil {
		wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
		return CaptureActionResult{}, err
	}
	if dir == "" {
		return CaptureActionResult{Completed: false}, nil
	}
	path, err := a.runSaveImagePipeline(cropped, dir)
	if err != nil {
		return CaptureActionResult{}, err
	}
	return CaptureActionResult{Completed: true, Path: path}, nil
}

func (a *App) SaveNativeRegionImageToDir(result CaptureResult, dir string) (CaptureActionResult, error) {
	pc, err := a.consumePendingCapture()
	if err != nil {
		return CaptureActionResult{}, err
	}
	defer func() {
		a.capturing.Store(false)
		hideDockIcon()
	}()

	if dir == "" {
		return CaptureActionResult{Completed: false}, nil
	}
	flushFrame()
	cropped, err := a.captureSelectedPNG(result, pc)
	if err != nil {
		wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
		return CaptureActionResult{}, err
	}
	path, err := a.runSaveImagePipeline(cropped, dir)
	if err != nil {
		return CaptureActionResult{}, err
	}
	return CaptureActionResult{Completed: true, Path: path}, nil
}

// SaveRegionToRemote uploads the user-selected region via SCP to the
// configured SSH host. Driven by the Wails overlay's "save remote" button.
//
// The flow mirrors ConfirmRegion (S3 upload) but routes through
// runSaveRemotePipeline instead. We dismiss the overlay first so the
// upload progress (and any error toast) shows over the regular settings UI.
func (a *App) SaveRegionToRemote(result CaptureResult) error {
	pc, err := a.consumePendingCapture()
	if err != nil {
		slog.Warn("SaveRegionToRemote: no pending capture", "err", err)
		return err
	}
	defer func() {
		a.capturing.Store(false)
		a.dismissOverlay()
	}()

	a.dismissOverlay()
	flushFrame()

	slog.Info("SaveRegionToRemote: capturing region",
		"x", result.Rect.X, "y", result.Rect.Y,
		"w", result.Rect.W, "h", result.Rect.H,
		"annotations", len(result.Annotations))
	cropped, err := a.captureSelectedPNG(result, pc)
	if err != nil {
		slog.Error("SaveRegionToRemote: capture failed", "err", err)
		wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
		return err
	}
	slog.Debug("SaveRegionToRemote: capture ok", "png_bytes", len(cropped))
	return a.runSaveRemotePipeline(cropped)
}

// SummarizeRegion uploads the selected screenshot to S3, sends the public URL
// to the configured multimodal LLM, and copies the resulting summary.
func (a *App) SummarizeRegion(result CaptureResult) error {
	pc, err := a.consumePendingCapture()
	if err != nil {
		slog.Warn("SummarizeRegion: no pending capture", "err", err)
		return err
	}
	defer func() {
		a.capturing.Store(false)
		a.dismissOverlay()
	}()

	a.dismissOverlay()
	flushFrame()

	slog.Info("SummarizeRegion: capturing region",
		"x", result.Rect.X, "y", result.Rect.Y,
		"w", result.Rect.W, "h", result.Rect.H,
		"annotations", len(result.Annotations))
	cropped, err := a.captureSelectedPNG(result, pc)
	if err != nil {
		slog.Error("SummarizeRegion: capture failed", "err", err)
		wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
		return err
	}
	return a.runSummaryPipeline(cropped)
}

// ExtractTextRegion sends the selected screenshot to the configured OCR
// provider and copies the extracted text to the clipboard.
func (a *App) ExtractTextRegion(result CaptureResult) error {
	pc, err := a.consumePendingCapture()
	if err != nil {
		slog.Warn("ExtractTextRegion: no pending capture", "err", err)
		return err
	}
	defer func() {
		a.capturing.Store(false)
		a.dismissOverlay()
	}()

	a.dismissOverlay()
	flushFrame()

	slog.Info("ExtractTextRegion: capturing region",
		"x", result.Rect.X, "y", result.Rect.Y,
		"w", result.Rect.W, "h", result.Rect.H,
		"annotations", len(result.Annotations))
	cropped, err := a.captureSelectedPNG(result, pc)
	if err != nil {
		slog.Error("ExtractTextRegion: capture failed", "err", err)
		wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
		return err
	}
	return a.runOCRPipeline(cropped)
}

// SaveNativeRegionToRemote is the macOS-native overlay equivalent of
// SaveRegionToRemote. The AppKit panel is already closed by the time this
// runs (see saveRemoteSelection in native_overlay_darwin.go), so we only
// have to release the dock icon afterwards.
func (a *App) SaveNativeRegionToRemote(result CaptureResult) error {
	pc, err := a.consumePendingCapture()
	if err != nil {
		slog.Warn("SaveNativeRegionToRemote: no pending capture", "err", err)
		return err
	}
	defer func() {
		a.capturing.Store(false)
		hideDockIcon()
	}()

	flushFrame()
	slog.Info("SaveNativeRegionToRemote: capturing region",
		"x", result.Rect.X, "y", result.Rect.Y,
		"w", result.Rect.W, "h", result.Rect.H,
		"annotations", len(result.Annotations))
	cropped, err := a.captureSelectedPNG(result, pc)
	if err != nil {
		slog.Error("SaveNativeRegionToRemote: capture failed", "err", err)
		wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
		return err
	}
	slog.Debug("SaveNativeRegionToRemote: capture ok", "png_bytes", len(cropped))
	return a.runSaveRemotePipeline(cropped)
}

// SummarizeNativeRegion is the macOS-native overlay equivalent of
// SummarizeRegion. The AppKit panel is already closed before this runs.
func (a *App) SummarizeNativeRegion(result CaptureResult) error {
	pc, err := a.consumePendingCapture()
	if err != nil {
		slog.Warn("SummarizeNativeRegion: no pending capture", "err", err)
		return err
	}
	defer func() {
		a.capturing.Store(false)
		hideDockIcon()
	}()

	flushFrame()
	slog.Info("SummarizeNativeRegion: capturing region",
		"x", result.Rect.X, "y", result.Rect.Y,
		"w", result.Rect.W, "h", result.Rect.H,
		"annotations", len(result.Annotations))
	cropped, err := a.captureSelectedPNG(result, pc)
	if err != nil {
		slog.Error("SummarizeNativeRegion: capture failed", "err", err)
		wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
		return err
	}
	return a.runSummaryPipeline(cropped)
}

// ExtractTextNativeRegion is the macOS-native overlay equivalent of
// ExtractTextRegion. The AppKit panel is already closed before this runs.
func (a *App) ExtractTextNativeRegion(result CaptureResult) error {
	pc, err := a.consumePendingCapture()
	if err != nil {
		slog.Warn("ExtractTextNativeRegion: no pending capture", "err", err)
		return err
	}
	defer func() {
		a.capturing.Store(false)
		hideDockIcon()
	}()

	flushFrame()
	slog.Info("ExtractTextNativeRegion: capturing region",
		"x", result.Rect.X, "y", result.Rect.Y,
		"w", result.Rect.W, "h", result.Rect.H,
		"annotations", len(result.Annotations))
	cropped, err := a.captureSelectedPNG(result, pc)
	if err != nil {
		slog.Error("ExtractTextNativeRegion: capture failed", "err", err)
		wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
		return err
	}
	return a.runOCRPipeline(cropped)
}

func parseNativeAnnotations(raw string) []application.Annotation {
	if raw == "" {
		return nil
	}
	var annotations []application.Annotation
	if err := json.Unmarshal([]byte(raw), &annotations); err != nil {
		slog.Warn("parse native annotations failed", "err", err)
		return nil
	}
	return annotations
}

// CancelRegion is invoked when the user dismisses the overlay (Esc /
// Cancel button / right-click). Frees the pending PNG, releases the
// capture lock, and hides the overlay.
func (a *App) CancelRegion() {
	a.pendingMu.Lock()
	a.pending = nil
	a.pendingMu.Unlock()
	a.capturing.Store(false)
	a.dismissOverlay()
	a.emitOperationStatus("capture", "已取消", "本次截图已取消", "success")
}

// CancelNativeRegion releases native-overlay state without touching the
// hidden Wails settings window.
func (a *App) CancelNativeRegion() {
	a.pendingMu.Lock()
	a.pending = nil
	a.pendingMu.Unlock()
	a.capturing.Store(false)
	hideDockIcon()
	a.emitOperationStatus("capture", "已取消", "本次截图已取消", "success")
}

// ShowWindow brings the main window back to the foreground in its normal
// Settings shape. Used by the tray "Settings…" menu item.
func (a *App) ShowWindow() {
	a.surfaceWindow()
	time.Sleep(20 * time.Millisecond)
}

// QuitApp terminates the entire process. Wired to the tray "Quit" menu.
func (a *App) QuitApp() {
	if a.ctx != nil {
		wruntime.Quit(a.ctx)
		return
	}
	os.Exit(0)
}

func (a *App) emitOperationStatus(operation, phase, message, state string) {
	if a.ctx != nil {
		wruntime.EventsEmit(a.ctx, "operation:status", OperationStatusPayload{
			Operation: operation,
			Phase:     phase,
			Message:   message,
			State:     state,
		})
	}
	switch state {
	case "success":
		showOperationStatus(phase, message, operationStatusSuccess)
		hideOperationStatusAfter(1400 * time.Millisecond)
	case "error":
		showOperationStatus(phase, message, operationStatusError)
		hideOperationStatusAfter(2600 * time.Millisecond)
	default:
		showOperationStatus(phase, message, operationStatusRunning)
	}
}

// runtimeNotifier emits success / failure events via the Wails runtime.
type runtimeNotifier struct{ ctx context.Context }

func (n *runtimeNotifier) NotifySuccess(url string) {
	wruntime.EventsEmit(n.ctx, "upload:success", url)
}

func (n *runtimeNotifier) NotifyFailure(reason string) {
	wruntime.EventsEmit(n.ctx, "upload:failure", reason)
}

// userPicturesDir returns ~/Pictures (or HOME if unavailable).
func userPicturesDir() string {
	home, err := os.UserHomeDir()
	if err != nil {
		return os.TempDir()
	}
	return filepath.Join(home, "Pictures")
}
