// SPDX-License-Identifier: GPL-3.0-or-later

// Package restic provides a wrapper around the restic CLI for backup operations.
package restic

import (
	"bufio"
	"bytes"
	"context"
	"crypto/sha256"
	"embed"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"strconv"
	"strings"
	"sync"
	"time"
)

// Embedded restic binaries for different platforms.
// These are populated at build time using go:embed directives.
// The embedded directory structure should be:
//
//	embedded/restic-linux-amd64
//	embedded/restic-linux-arm64
//	embedded/restic-darwin-amd64
//	embedded/restic-darwin-arm64
//	embedded/restic-windows-amd64.exe
//
//go:embed embedded/*
var embeddedBinaries embed.FS

// CLI wraps the restic command-line tool.
type CLI struct {
	// binaryPath is the path to the restic binary
	binaryPath string

	// env contains environment variables for restic commands
	env []string

	// insecureTLS skips TLS certificate verification
	insecureTLS bool

	// s3Options contains -o flags for S3 backend configuration (e.g., ["s3.connections=1"])
	s3Options []string

	// mu protects concurrent access
	mu sync.Mutex
}

// CLIConfig holds configuration for the CLI wrapper.
type CLIConfig struct {
	// RepositoryURL is the repository URL (REST or S3)
	RepositoryURL string

	// Password is the repository password
	Password string

	// CacheDir is the directory for restic cache
	CacheDir string

	// CustomBinaryPath allows using a specific restic binary instead of embedded
	CustomBinaryPath string

	// ProxyURL is the SOCKS5 proxy URL for Tor (e.g., "socks5://127.0.0.1:9050")
	ProxyURL string

	// S3AccessKeyID is the AWS access key ID for S3 backend authentication
	S3AccessKeyID string

	// S3SecretAccessKey is the AWS secret access key for S3 backend authentication
	S3SecretAccessKey string

	// TLSClientCertPath is the path to a combined PEM file containing client cert and key for mTLS
	// Only used with legacy REST backend
	TLSClientCertPath string

	// CACertPath is the path to the CA certificate for server verification (optional for .onion)
	CACertPath string

	// InsecureSkipVerify disables server certificate verification (use for .onion addresses)
	InsecureSkipVerify bool

	// MaxConnections limits concurrent backend connections (passed as -o s3.connections=N for S3)
	MaxConnections int

	// ExtraEnv contains additional environment variables
	ExtraEnv map[string]string
}

// normalizeRepositoryURL ensures the URL has the correct restic backend prefix.
// Restic requires "rest:" prefix for REST server backends and "s3:" prefix for S3 backends.
// e.g., "http://localhost:8000" -> "rest:http://localhost:8000"
// e.g., "s3:https://server.onion/bucket" -> "s3:https://server.onion/bucket" (unchanged)
// URLs already prefixed with "rest:" or "s3:" are returned unchanged.
func normalizeRepositoryURL(url string) string {
	if strings.HasPrefix(url, "rest:") || strings.HasPrefix(url, "s3:") {
		return url
	}
	if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") {
		return "rest:" + url
	}
	// For other backends (local:, sftp:, etc.), return unchanged
	return url
}

// NewCLI creates a new CLI wrapper.
func NewCLI(cfg CLIConfig) (*CLI, error) {
	var binaryPath string
	var err error

	if cfg.CustomBinaryPath != "" {
		binaryPath = cfg.CustomBinaryPath
		// Verify it exists and is executable
		if _, err := os.Stat(binaryPath); err != nil {
			return nil, fmt.Errorf("custom restic binary not found: %w", err)
		}
	} else {
		// Extract embedded binary
		binaryPath, err = extractEmbeddedBinary()
		if err != nil {
			return nil, fmt.Errorf("failed to extract restic binary: %w", err)
		}
	}

	// Build environment variables
	env := os.Environ()
	env = append(env, fmt.Sprintf("RESTIC_REPOSITORY=%s", normalizeRepositoryURL(cfg.RepositoryURL)))
	env = append(env, fmt.Sprintf("RESTIC_PASSWORD=%s", cfg.Password))

	if cfg.CacheDir != "" {
		env = append(env, fmt.Sprintf("RESTIC_CACHE_DIR=%s", cfg.CacheDir))
	}

	// Set proxy for Tor routing
	if cfg.ProxyURL != "" {
		env = append(env, fmt.Sprintf("HTTP_PROXY=%s", cfg.ProxyURL))
		env = append(env, fmt.Sprintf("HTTPS_PROXY=%s", cfg.ProxyURL))
		env = append(env, fmt.Sprintf("ALL_PROXY=%s", cfg.ProxyURL))
	}

	// Set S3 credentials for AWS Sig V4 authentication
	if cfg.S3AccessKeyID != "" {
		env = append(env, fmt.Sprintf("AWS_ACCESS_KEY_ID=%s", cfg.S3AccessKeyID))
	}
	if cfg.S3SecretAccessKey != "" {
		env = append(env, fmt.Sprintf("AWS_SECRET_ACCESS_KEY=%s", cfg.S3SecretAccessKey))
	}

	// Set mTLS client certificate (combined cert+key PEM file) - legacy REST backend only
	if cfg.TLSClientCertPath != "" {
		env = append(env, fmt.Sprintf("RESTIC_TLS_CLIENT_CERT=%s", cfg.TLSClientCertPath))
	}

	// Set CA certificate for server verification
	if cfg.CACertPath != "" {
		env = append(env, fmt.Sprintf("RESTIC_CACERT=%s", cfg.CACertPath))
	}

	// Skip TLS verification (for .onion addresses where Tor provides identity)
	// Note: This is set via CLI flag --insecure-tls, not env var

	for k, v := range cfg.ExtraEnv {
		env = append(env, fmt.Sprintf("%s=%s", k, v))
	}

	// Build S3 backend options (passed as -o flags to restic commands)
	var s3Options []string
	if strings.HasPrefix(cfg.RepositoryURL, "s3:") {
		conns := cfg.MaxConnections
		if conns <= 0 {
			conns = 1 // Default to 1 for Tor stability
		}
		s3Options = append(s3Options, fmt.Sprintf("s3.connections=%d", conns))
	}

	return &CLI{
		binaryPath:  binaryPath,
		env:         env,
		insecureTLS: cfg.InsecureSkipVerify,
		s3Options:   s3Options,
	}, nil
}

// extractEmbeddedBinary extracts the appropriate restic binary for the current
// platform to the user cache dir and returns its path. It reuses a cached copy
// ONLY when that copy byte-for-byte matches the binary embedded in THIS build —
// so a stale or wrong-architecture cache left by an earlier build can never
// survive an update. (A self-consistency check alone would happily reuse a stale
// cache: that is the bug that shipped a macOS restic under the linux-amd64 name.)
func extractEmbeddedBinary() (string, error) {
	// Determine the binary name for this platform.
	binaryName := fmt.Sprintf("restic-%s-%s", runtime.GOOS, runtime.GOARCH)
	if runtime.GOOS == "windows" {
		binaryName += ".exe"
	}

	// Resolve the embedded binary for this platform: the platform-specific name,
	// then the generic single-platform name, then a restic already on PATH.
	data, err := embeddedBinaries.ReadFile("embedded/" + binaryName)
	if err != nil {
		data, err = embeddedBinaries.ReadFile("embedded/restic")
		if err != nil {
			if systemPath, pathErr := exec.LookPath("restic"); pathErr == nil {
				return systemPath, nil
			}
			return "", fmt.Errorf("embedded binary not found for %s/%s and restic not in PATH: %w", runtime.GOOS, runtime.GOARCH, err)
		}
	}

	// Determine extraction path.
	cacheDir, err := os.UserCacheDir()
	if err != nil {
		cacheDir = os.TempDir()
	}
	extractDir := filepath.Join(cacheDir, "privatebackup", "bin")
	if err := os.MkdirAll(extractDir, 0700); err != nil {
		return "", fmt.Errorf("failed to create extraction directory: %w", err)
	}
	extractPath := filepath.Join(extractDir, binaryName)

	// Reuse the cache only if it matches THIS build's embedded restic exactly.
	want := sha256.Sum256(data)
	if cached, rerr := os.ReadFile(extractPath); rerr == nil && sha256.Sum256(cached) == want {
		return extractPath, nil
	}

	// (Re)extract: write to a temp file then rename, so a crash mid-write can't
	// leave a truncated binary (the checksum match above would reject it next time
	// anyway, but this avoids ever exec'ing a half-written file).
	tmp := extractPath + ".tmp"
	if err := os.WriteFile(tmp, data, 0700); err != nil {
		return "", fmt.Errorf("failed to write binary: %w", err)
	}
	if err := os.Rename(tmp, extractPath); err != nil {
		_ = os.Remove(tmp)
		return "", fmt.Errorf("failed to install binary: %w", err)
	}
	// Best-effort checksum sidecar (informational; the embed match above is authoritative).
	_ = os.WriteFile(extractPath+".sha256", []byte(hex.EncodeToString(want[:])), 0644)

	return extractPath, nil
}

// Version returns the restic version.
func (c *CLI) Version(ctx context.Context) (string, error) {
	out, err := c.run(ctx, nil, "version")
	if err != nil {
		return "", err
	}
	return strings.TrimSpace(string(out)), nil
}

// Init initializes a new repository.
func (c *CLI) Init(ctx context.Context) error {
	_, err := c.run(ctx, nil, "init")
	return err
}

// Check verifies repository integrity.
func (c *CLI) Check(ctx context.Context, readData bool) error {
	args := []string{"check"}
	if readData {
		args = append(args, "--read-data")
	}
	_, err := c.run(ctx, nil, args...)
	return err
}

// BackupOutput represents the JSON output from restic backup.
type BackupOutput struct {
	MessageType string `json:"message_type"`

	// For status messages
	PercentDone  float64  `json:"percent_done,omitempty"`
	TotalFiles   int64    `json:"total_files,omitempty"`
	FilesDone    int64    `json:"files_done,omitempty"`
	TotalBytes   int64    `json:"total_bytes,omitempty"`
	BytesDone    int64    `json:"bytes_done,omitempty"`
	CurrentFiles []string `json:"current_files,omitempty"`

	// For summary messages
	FilesNew            int64   `json:"files_new,omitempty"`
	FilesChanged        int64   `json:"files_changed,omitempty"`
	FilesUnmodified     int64   `json:"files_unmodified,omitempty"`
	DirsNew             int64   `json:"dirs_new,omitempty"`
	DirsChanged         int64   `json:"dirs_changed,omitempty"`
	DirsUnmodified      int64   `json:"dirs_unmodified,omitempty"`
	DataBlobs           int64   `json:"data_blobs,omitempty"`
	TreeBlobs           int64   `json:"tree_blobs,omitempty"`
	DataAdded           int64   `json:"data_added,omitempty"`
	TotalFilesProcessed int64   `json:"total_files_processed,omitempty"`
	TotalBytesProcessed int64   `json:"total_bytes_processed,omitempty"`
	TotalDuration       float64 `json:"total_duration,omitempty"`
	SnapshotID          string  `json:"snapshot_id,omitempty"`
}

// CLIBackupOptions configures a backup operation.
type CLIBackupOptions struct {
	Paths         []string
	Excludes      []string
	Tags          []string
	ExcludeCaches bool
	DryRun        bool
	OnProgress    func(status BackupOutput)
}

// Backup performs a backup and returns the result.
func (c *CLI) Backup(ctx context.Context, opts CLIBackupOptions) (*BackupResult, error) {
	if len(opts.Paths) == 0 {
		return nil, fmt.Errorf("no paths specified for backup")
	}

	args := []string{"backup", "--json"}

	for _, exclude := range opts.Excludes {
		args = append(args, "--exclude", exclude)
	}

	for _, tag := range opts.Tags {
		args = append(args, "--tag", tag)
	}

	if opts.ExcludeCaches {
		args = append(args, "--exclude-caches")
	}

	if opts.DryRun {
		args = append(args, "--dry-run")
	}

	args = append(args, opts.Paths...)

	start := time.Now()
	var result BackupResult

	// Run with streaming output parsing
	err := c.runWithOutput(ctx, args, func(line []byte) {
		var output BackupOutput
		if err := json.Unmarshal(line, &output); err != nil {
			return // Skip non-JSON lines
		}

		if opts.OnProgress != nil {
			opts.OnProgress(output)
		}

		// Capture summary
		if output.MessageType == "summary" {
			result.SnapshotID = output.SnapshotID
			result.FilesNew = output.FilesNew
			result.FilesChanged = output.FilesChanged
			result.FilesUnmodified = output.FilesUnmodified
			result.DirsNew = output.DirsNew
			result.DirsChanged = output.DirsChanged
			result.DirsUnmodified = output.DirsUnmodified
			result.DataAdded = output.DataAdded
			result.TotalFilesProcessed = output.TotalFilesProcessed
			result.TotalBytesProcessed = output.TotalBytesProcessed
		}
	})

	result.Duration = time.Since(start)

	if err != nil {
		return nil, err
	}

	return &result, nil
}

// RestoreOutput represents the JSON output from restic restore.
type RestoreOutput struct {
	MessageType   string  `json:"message_type"`
	PercentDone   float64 `json:"percent_done,omitempty"`
	TotalFiles    int64   `json:"total_files,omitempty"`
	FilesRestored int64   `json:"files_restored,omitempty"`
	TotalBytes    int64   `json:"total_bytes,omitempty"`
	BytesRestored int64   `json:"bytes_restored,omitempty"`
}

// CLIRestoreOptions configures a restore operation.
type CLIRestoreOptions struct {
	SnapshotID string
	Target     string
	Include    []string
	Exclude    []string
	DryRun     bool
	OnProgress func(status RestoreOutput)
}

// Restore restores files from a snapshot.
func (c *CLI) Restore(ctx context.Context, opts CLIRestoreOptions) (*RestoreResult, error) {
	if opts.SnapshotID == "" {
		return nil, fmt.Errorf("snapshot ID is required")
	}
	if opts.Target == "" {
		return nil, fmt.Errorf("target directory is required")
	}

	args := []string{"restore", opts.SnapshotID, "--target", opts.Target, "--json"}

	for _, include := range opts.Include {
		args = append(args, "--include", include)
	}

	for _, exclude := range opts.Exclude {
		args = append(args, "--exclude", exclude)
	}

	if opts.DryRun {
		args = append(args, "--dry-run")
	}

	start := time.Now()
	var result RestoreResult

	err := c.runWithOutput(ctx, args, func(line []byte) {
		var output RestoreOutput
		if err := json.Unmarshal(line, &output); err != nil {
			return
		}

		if opts.OnProgress != nil {
			opts.OnProgress(output)
		}

		// Capture final state
		if output.MessageType == "summary" || output.PercentDone >= 1.0 {
			result.FilesRestored = output.FilesRestored
			result.BytesRestored = output.BytesRestored
		}
	})

	result.Duration = time.Since(start)

	if err != nil {
		return nil, err
	}

	return &result, nil
}

// SnapshotJSON represents a snapshot in restic's JSON output.
type SnapshotJSON struct {
	ID       string    `json:"id"`
	ShortID  string    `json:"short_id"`
	Time     time.Time `json:"time"`
	Hostname string    `json:"hostname"`
	Username string    `json:"username"`
	Paths    []string  `json:"paths"`
	Tags     []string  `json:"tags,omitempty"`
}

// ListSnapshots returns all snapshots in the repository.
func (c *CLI) ListSnapshots(ctx context.Context) ([]Snapshot, error) {
	out, err := c.run(ctx, nil, "snapshots", "--json")
	if err != nil {
		return nil, err
	}

	var snapshots []SnapshotJSON
	if err := json.Unmarshal(out, &snapshots); err != nil {
		return nil, fmt.Errorf("failed to parse snapshots: %w", err)
	}

	result := make([]Snapshot, len(snapshots))
	for i, s := range snapshots {
		result[i] = Snapshot{
			ID:       s.ID,
			Short:    s.ShortID,
			Time:     s.Time,
			Hostname: s.Hostname,
			Username: s.Username,
			Paths:    s.Paths,
			Tags:     s.Tags,
		}
	}

	return result, nil
}

// GetSnapshot retrieves a specific snapshot by ID.
func (c *CLI) GetSnapshot(ctx context.Context, id string) (*Snapshot, error) {
	out, err := c.run(ctx, nil, "snapshots", "--json", id)
	if err != nil {
		return nil, err
	}

	var snapshots []SnapshotJSON
	if err := json.Unmarshal(out, &snapshots); err != nil {
		return nil, fmt.Errorf("failed to parse snapshot: %w", err)
	}

	if len(snapshots) == 0 {
		return nil, ErrSnapshotNotFound
	}

	s := snapshots[0]
	return &Snapshot{
		ID:       s.ID,
		Short:    s.ShortID,
		Time:     s.Time,
		Hostname: s.Hostname,
		Username: s.Username,
		Paths:    s.Paths,
		Tags:     s.Tags,
	}, nil
}

// ForgetOptions configures snapshot removal.
// For append-only repositories, use KeepWithin* options instead of Keep* for security.
// See: https://restic.readthedocs.io/en/stable/060_forget.html#security-considerations-in-append-only-mode
type ForgetOptions struct {
	SnapshotIDs []string

	// Count-based retention (use for non-append-only repos)
	KeepLast    int
	KeepDaily   int
	KeepWeekly  int
	KeepMonthly int
	KeepYearly  int

	// Time-window retention (use for append-only repos - RECOMMENDED)
	// These keep ALL snapshots within the time window, preventing
	// an attacker from pushing out legitimate snapshots with fakes.
	KeepWithinDaily   string // e.g., "7d"
	KeepWithinWeekly  string // e.g., "28d" (4 weeks)
	KeepWithinMonthly string // e.g., "12m"
	KeepWithinYearly  string // e.g., "10y"

	Prune  bool
	DryRun bool
}

// Forget removes snapshots according to the policy.
func (c *CLI) Forget(ctx context.Context, opts ForgetOptions) error {
	args := []string{"forget"}

	for _, id := range opts.SnapshotIDs {
		args = append(args, id)
	}

	// Count-based retention
	if opts.KeepLast > 0 {
		args = append(args, "--keep-last", strconv.Itoa(opts.KeepLast))
	}
	if opts.KeepDaily > 0 {
		args = append(args, "--keep-daily", strconv.Itoa(opts.KeepDaily))
	}
	if opts.KeepWeekly > 0 {
		args = append(args, "--keep-weekly", strconv.Itoa(opts.KeepWeekly))
	}
	if opts.KeepMonthly > 0 {
		args = append(args, "--keep-monthly", strconv.Itoa(opts.KeepMonthly))
	}
	if opts.KeepYearly > 0 {
		args = append(args, "--keep-yearly", strconv.Itoa(opts.KeepYearly))
	}

	// Time-window retention (append-only safe)
	if opts.KeepWithinDaily != "" {
		args = append(args, "--keep-within-daily", opts.KeepWithinDaily)
	}
	if opts.KeepWithinWeekly != "" {
		args = append(args, "--keep-within-weekly", opts.KeepWithinWeekly)
	}
	if opts.KeepWithinMonthly != "" {
		args = append(args, "--keep-within-monthly", opts.KeepWithinMonthly)
	}
	if opts.KeepWithinYearly != "" {
		args = append(args, "--keep-within-yearly", opts.KeepWithinYearly)
	}

	if opts.Prune {
		args = append(args, "--prune")
	}

	if opts.DryRun {
		args = append(args, "--dry-run")
	}

	_, err := c.run(ctx, nil, args...)
	return err
}

// Prune removes unreferenced data from the repository.
func (c *CLI) Prune(ctx context.Context) error {
	_, err := c.run(ctx, nil, "prune")
	return err
}

// StatsJSON represents repository statistics in restic's JSON output.
type StatsJSON struct {
	TotalSize      int64 `json:"total_size"`
	TotalFileCount int64 `json:"total_file_count"`
}

// Stats returns statistics about a snapshot or the repository.
func (c *CLI) Stats(ctx context.Context, snapshotID string) (*SnapshotStats, error) {
	args := []string{"stats", "--json"}
	if snapshotID != "" {
		args = append(args, snapshotID)
	}

	out, err := c.run(ctx, nil, args...)
	if err != nil {
		return nil, err
	}

	var stats StatsJSON
	if err := json.Unmarshal(out, &stats); err != nil {
		return nil, fmt.Errorf("failed to parse stats: %w", err)
	}

	return &SnapshotStats{
		TotalSize:  stats.TotalSize,
		TotalFiles: stats.TotalFileCount,
	}, nil
}

// Unlock removes stale locks from the repository.
func (c *CLI) Unlock(ctx context.Context) error {
	_, err := c.run(ctx, nil, "unlock")
	return err
}

// LsEntry represents a single file or directory node returned by restic ls.
type LsEntry struct {
	Name        string `json:"name"`
	Type        string `json:"type"` // "file" or "dir" or "symlink"
	Path        string `json:"path"`
	Size        int64  `json:"size"`
	Permissions string `json:"permissions"`
}

// Ls lists files in a snapshot, optionally filtered to a path prefix.
// It parses the NDJSON output of `restic ls --json`.
func (c *CLI) Ls(ctx context.Context, snapshotID, pathPrefix string) ([]LsEntry, error) {
	args := []string{"ls", "--json", snapshotID}
	if pathPrefix != "" {
		args = append(args, pathPrefix)
	}

	out, err := c.run(ctx, nil, args...)
	if err != nil {
		return nil, err
	}

	var entries []LsEntry
	scanner := bufio.NewScanner(bytes.NewReader(out))
	for scanner.Scan() {
		line := scanner.Bytes()
		if len(line) == 0 {
			continue
		}
		var raw struct {
			StructType  string `json:"struct_type"`
			Name        string `json:"name"`
			Type        string `json:"type"`
			Path        string `json:"path"`
			Size        int64  `json:"size"`
			Permissions string `json:"permissions"`
		}
		if err := json.Unmarshal(line, &raw); err != nil {
			continue
		}
		if raw.StructType != "node" {
			continue
		}
		entries = append(entries, LsEntry{
			Name:        raw.Name,
			Type:        raw.Type,
			Path:        raw.Path,
			Size:        raw.Size,
			Permissions: raw.Permissions,
		})
	}
	return entries, scanner.Err()
}

// prependGlobalFlags prepends global restic flags (--insecure-tls, -o options) to args.
func (c *CLI) prependGlobalFlags(args []string) []string {
	var prefix []string
	if c.insecureTLS {
		prefix = append(prefix, "--insecure-tls")
	}
	for _, opt := range c.s3Options {
		prefix = append(prefix, "-o", opt)
	}
	if len(prefix) > 0 {
		args = append(prefix, args...)
	}
	return args
}

// run executes a restic command and returns its output.
func (c *CLI) run(ctx context.Context, extraEnv []string, args ...string) ([]byte, error) {
	c.mu.Lock()
	defer c.mu.Unlock()

	args = c.prependGlobalFlags(args)

	cmd := exec.CommandContext(ctx, c.binaryPath, args...)
	cmd.Env = append(c.env, extraEnv...)

	var stdout, stderr bytes.Buffer
	cmd.Stdout = &stdout
	cmd.Stderr = &stderr

	err := cmd.Run()
	if err != nil {
		// Parse restic-specific errors
		stderrStr := stderr.String()
		if strings.Contains(stderrStr, "repository does not exist") {
			return nil, ErrRepositoryNotFound
		}
		if strings.Contains(stderrStr, "wrong password") || strings.Contains(stderrStr, "invalid password") {
			return nil, ErrInvalidPassword
		}
		if strings.Contains(stderrStr, "repository is already locked") {
			return nil, ErrLocked
		}
		if strings.Contains(stderrStr, "snapshot") && strings.Contains(stderrStr, "not found") {
			return nil, ErrSnapshotNotFound
		}
		// Detect rate limiting (server returns 403 with RATE_LIMIT_EXCEEDED message)
		// Server uses 403 instead of 429 because restic recognizes 403 as permanent error
		if strings.Contains(stderrStr, "RATE_LIMIT_EXCEEDED") {
			return nil, ErrRateLimited
		}
		return nil, fmt.Errorf("restic error: %s: %w", stderrStr, err)
	}

	return stdout.Bytes(), nil
}

// runWithOutput executes a restic command and streams its output line by line.
func (c *CLI) runWithOutput(ctx context.Context, args []string, onLine func([]byte)) error {
	c.mu.Lock()
	defer c.mu.Unlock()

	args = c.prependGlobalFlags(args)

	cmd := exec.CommandContext(ctx, c.binaryPath, args...)
	cmd.Env = c.env

	stdout, err := cmd.StdoutPipe()
	if err != nil {
		return fmt.Errorf("failed to create stdout pipe: %w", err)
	}

	var stderr bytes.Buffer
	cmd.Stderr = &stderr

	if err := cmd.Start(); err != nil {
		return fmt.Errorf("failed to start restic: %w", err)
	}

	// Read output line by line
	scanner := bufio.NewScanner(stdout)
	for scanner.Scan() {
		onLine(scanner.Bytes())
	}

	if err := scanner.Err(); err != nil && err != io.EOF {
		return fmt.Errorf("error reading output: %w", err)
	}

	err = cmd.Wait()
	if err != nil {
		stderrStr := stderr.String()
		if strings.Contains(stderrStr, "repository does not exist") {
			return ErrRepositoryNotFound
		}
		// Detect rate limiting (server returns 403 with RATE_LIMIT_EXCEEDED message)
		if strings.Contains(stderrStr, "RATE_LIMIT_EXCEEDED") {
			return ErrRateLimited
		}
		return fmt.Errorf("restic error: %s: %w", stderrStr, err)
	}

	return nil
}

// BinaryPath returns the path to the restic binary being used.
func (c *CLI) BinaryPath() string {
	return c.binaryPath
}
