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

// Package restic provides a wrapper around the restic CLI for backup operations.
// It handles repository initialization, backup, restore, and snapshot management.
package restic

import (
	"context"
	"errors"
	"fmt"
	"io"
	"net/http"
	"strings"
	"sync"
	"time"
)

// CompressionLevel defines backup compression settings.
type CompressionLevel string

const (
	// CompressionOff disables compression
	CompressionOff CompressionLevel = "off"
	// CompressionAuto uses automatic compression (restic default)
	CompressionAuto CompressionLevel = "auto"
	// CompressionMax uses maximum compression (zstd level 19)
	CompressionMax CompressionLevel = "max"
)

// Config holds restic engine configuration.
type Config struct {
	// RepositoryURL is the restic repository URL.
	// For S3 backend: "s3:https://server.onion/bucket-name"
	// For REST backend (legacy): "rest:https://server.onion/cert-hash"
	RepositoryURL string

	// Password is the repository password (derived from certificate)
	Password string

	// Compression level for new backups
	Compression CompressionLevel

	// HTTPClient for REST API communication (optional, only needed for legacy REST backend)
	HTTPClient *http.Client

	// 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
	CACertPath string

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

	// ExcludeCaches excludes directories with CACHEDIR.TAG
	ExcludeCaches bool

	// DefaultExcludes are patterns to exclude by default
	DefaultExcludes []string

	// MaxConnections limits concurrent connections to the server
	MaxConnections int

	// CacheDir is the directory for restic cache
	CacheDir string

	// CustomResticPath allows using a specific restic binary
	CustomResticPath string
}

// DefaultConfig returns default engine configuration.
func DefaultConfig() *Config {
	return &Config{
		Compression:    CompressionMax,
		ExcludeCaches:  true,
		MaxConnections: 1,
		DefaultExcludes: []string{
			"*.tmp",
			"*.temp",
			".cache/**",
			"**/node_modules/**",
			"**/.git/objects/**",
		},
	}
}

// Engine wraps restic operations for backup and restore.
type Engine struct {
	cfg         *Config
	cli         *CLI
	mu          sync.RWMutex
	initialized bool
}

// NewEngine creates a new restic engine.
func NewEngine(cfg *Config) (*Engine, error) {
	if cfg == nil {
		cfg = DefaultConfig()
	}

	if cfg.RepositoryURL == "" {
		return nil, errors.New("repository URL is required")
	}
	if cfg.Password == "" {
		return nil, errors.New("repository password is required")
	}

	// Build extra environment for restic
	extraEnv := make(map[string]string)
	if cfg.Compression != "" {
		extraEnv["RESTIC_COMPRESSION"] = string(cfg.Compression)
	}

	// Create CLI wrapper
	cli, err := NewCLI(CLIConfig{
		RepositoryURL:      cfg.RepositoryURL,
		Password:           cfg.Password,
		CacheDir:           cfg.CacheDir,
		CustomBinaryPath:   cfg.CustomResticPath,
		ProxyURL:           cfg.ProxyURL,
		S3AccessKeyID:      cfg.S3AccessKeyID,
		S3SecretAccessKey:  cfg.S3SecretAccessKey,
		TLSClientCertPath:  cfg.TLSClientCertPath,
		CACertPath:         cfg.CACertPath,
		InsecureSkipVerify: cfg.InsecureSkipVerify,
		MaxConnections:     cfg.MaxConnections,
		ExtraEnv:           extraEnv,
	})
	if err != nil {
		return nil, fmt.Errorf("failed to initialize restic CLI: %w", err)
	}

	return &Engine{
		cfg: cfg,
		cli: cli,
	}, nil
}

// RepositoryURL returns the repository URL.
func (e *Engine) RepositoryURL() string {
	return e.cfg.RepositoryURL
}

// IsInitialized returns whether the repository is initialized.
func (e *Engine) IsInitialized() bool {
	e.mu.RLock()
	defer e.mu.RUnlock()
	return e.initialized
}

// InitRepository initializes a new restic repository.
// Returns ErrRepositoryExists if already initialized.
func (e *Engine) InitRepository(ctx context.Context) error {
	e.mu.Lock()
	defer e.mu.Unlock()

	// Try to initialize using CLI
	err := e.cli.Init(ctx)
	if err != nil {
		// Check if it's because repo already exists
		errStr := err.Error()
		if errors.Is(err, ErrRepositoryExists) ||
			strings.Contains(errStr, "config file already exists") ||
			strings.Contains(errStr, "repository master key and config already initialized") ||
			strings.Contains(errStr, "repository master key and target already initialized") ||
			strings.Contains(errStr, "already initialized") {
			e.initialized = true
			return ErrRepositoryExists
		}
		return fmt.Errorf("failed to initialize repository: %w", err)
	}

	e.initialized = true
	return nil
}

// OpenRepository opens an existing repository.
func (e *Engine) OpenRepository(ctx context.Context) error {
	e.mu.Lock()
	defer e.mu.Unlock()

	// Try to list snapshots to verify repository exists and password is correct
	_, err := e.cli.ListSnapshots(ctx)
	if err != nil {
		if errors.Is(err, ErrRepositoryNotFound) {
			return ErrRepositoryNotFound
		}
		if errors.Is(err, ErrInvalidPassword) {
			return ErrInvalidPassword
		}
		return fmt.Errorf("failed to open repository: %w", err)
	}

	e.initialized = true
	return nil
}

// BackupOptions configures a backup operation.
type BackupOptions struct {
	// Paths to backup
	Paths []string

	// Excludes are patterns to exclude
	Excludes []string

	// Tags to apply to the snapshot
	Tags []string

	// ProgressCallback is called with upload progress updates (TotalBytes known).
	ProgressCallback func(progress BackupProgress)

	// ActivityCallback is called on every status event from restic, including
	// the index-loading and file-scanning phases where TotalBytes is not yet
	// known. Use this to drive a liveness watchdog.
	ActivityCallback func()

	// DryRun simulates the backup without uploading
	DryRun bool
}

// BackupProgress represents backup operation progress.
type BackupProgress struct {
	// Phase of the backup (scanning, backing up, finalizing)
	Phase string

	// TotalFiles is the total number of files to process
	TotalFiles int64

	// ProcessedFiles is the number of files processed so far
	ProcessedFiles int64

	// TotalBytes is the total size to backup
	TotalBytes int64

	// ProcessedBytes is the bytes processed so far
	ProcessedBytes int64

	// CurrentFile is the file currently being processed
	CurrentFile string

	// BytesPerSecond is the current transfer rate
	BytesPerSecond int64

	// EstimatedTimeRemaining in seconds
	EstimatedTimeRemaining int64
}

// BackupResult contains the results of a backup operation.
type BackupResult struct {
	// SnapshotID is the ID of the created snapshot
	SnapshotID string

	// FilesNew is the number of new files
	FilesNew int64

	// FilesChanged is the number of changed files
	FilesChanged int64

	// FilesUnmodified is the number of unchanged files
	FilesUnmodified int64

	// DirsNew is the number of new directories
	DirsNew int64

	// DirsChanged is the number of changed directories
	DirsChanged int64

	// DirsUnmodified is the number of unchanged directories
	DirsUnmodified int64

	// DataAdded is the amount of data added (after deduplication)
	DataAdded int64

	// TotalFilesProcessed is the total files processed
	TotalFilesProcessed int64

	// TotalBytesProcessed is the total bytes processed
	TotalBytesProcessed int64

	// Duration is the time taken for the backup
	Duration time.Duration

	// Errors is a list of files that had errors
	Errors []string
}

// Backup performs a backup operation.
func (e *Engine) Backup(ctx context.Context, opts BackupOptions) (*BackupResult, error) {
	if !e.IsInitialized() {
		// Try to open or initialize the repository
		if err := e.OpenRepository(ctx); err != nil {
			if errors.Is(err, ErrRepositoryNotFound) {
				// Initialize new repository
				if initErr := e.InitRepository(ctx); initErr != nil && !errors.Is(initErr, ErrRepositoryExists) {
					return nil, fmt.Errorf("failed to initialize repository: %w", initErr)
				}
			} else {
				return nil, err
			}
		}
	}

	if len(opts.Paths) == 0 {
		return nil, errors.New("no paths specified for backup")
	}

	// Combine default excludes with user excludes
	excludes := append(e.cfg.DefaultExcludes, opts.Excludes...)

	// Build CLI options
	cliOpts := CLIBackupOptions{
		Paths:         opts.Paths,
		Excludes:      excludes,
		Tags:          opts.Tags,
		ExcludeCaches: e.cfg.ExcludeCaches,
		DryRun:        opts.DryRun,
	}

	// Set up progress callback if provided.
	// ActivityCallback fires on every status event; ProgressCallback only fires
	// once TotalBytes is known (upload phase).
	if opts.ProgressCallback != nil || opts.ActivityCallback != nil {
		cliOpts.OnProgress = func(status BackupOutput) {
			if opts.ActivityCallback != nil {
				opts.ActivityCallback()
			}

			if opts.ProgressCallback != nil {
				progress := BackupProgress{
					TotalFiles:     status.TotalFiles,
					ProcessedFiles: status.FilesDone,
					TotalBytes:     status.TotalBytes,
					ProcessedBytes: status.BytesDone,
				}

				if len(status.CurrentFiles) > 0 {
					progress.CurrentFile = status.CurrentFiles[0]
				}

				if status.PercentDone > 0 && status.PercentDone < 1 {
					progress.Phase = "backing up"
				} else if status.MessageType == "summary" {
					progress.Phase = "complete"
				} else {
					progress.Phase = "scanning"
				}

				opts.ProgressCallback(progress)
			}
		}
	}

	return e.cli.Backup(ctx, cliOpts)
}

// RestoreOptions configures a restore operation.
type RestoreOptions struct {
	// SnapshotID is the snapshot to restore (or "latest")
	SnapshotID string

	// Target is the directory to restore to
	Target string

	// Paths restricts restore to specific paths within the snapshot
	Paths []string

	// Excludes are patterns to exclude from restore
	Excludes []string

	// ProgressCallback is called with progress updates
	ProgressCallback func(progress RestoreProgress)

	// DryRun simulates the restore without writing files
	DryRun bool

	// Overwrite controls handling of existing files
	Overwrite bool
}

// RestoreProgress represents restore operation progress.
type RestoreProgress struct {
	// Phase of the restore (preparing, restoring, verifying)
	Phase string

	// TotalFiles is the total number of files to restore
	TotalFiles int64

	// ProcessedFiles is the number of files restored so far
	ProcessedFiles int64

	// TotalBytes is the total size to restore
	TotalBytes int64

	// ProcessedBytes is the bytes restored so far
	ProcessedBytes int64

	// CurrentFile is the file currently being restored
	CurrentFile string

	// BytesPerSecond is the current transfer rate
	BytesPerSecond int64
}

// RestoreResult contains the results of a restore operation.
type RestoreResult struct {
	// FilesRestored is the number of files restored
	FilesRestored int64

	// DirsRestored is the number of directories restored
	DirsRestored int64

	// BytesRestored is the total bytes restored
	BytesRestored int64

	// Duration is the time taken for the restore
	Duration time.Duration

	// Errors is a list of files that had errors
	Errors []string
}

// Restore performs a restore operation.
func (e *Engine) Restore(ctx context.Context, opts RestoreOptions) (*RestoreResult, error) {
	if !e.IsInitialized() {
		if err := e.OpenRepository(ctx); err != nil {
			return nil, err
		}
	}

	if opts.SnapshotID == "" {
		return nil, errors.New("snapshot ID is required")
	}
	if opts.Target == "" {
		return nil, errors.New("target directory is required")
	}

	// Build CLI options
	cliOpts := CLIRestoreOptions{
		SnapshotID: opts.SnapshotID,
		Target:     opts.Target,
		Include:    opts.Paths,
		Exclude:    opts.Excludes,
		DryRun:     opts.DryRun,
	}

	// Set up progress callback if provided
	if opts.ProgressCallback != nil {
		cliOpts.OnProgress = func(status RestoreOutput) {
			progress := RestoreProgress{
				TotalFiles:     status.TotalFiles,
				ProcessedFiles: status.FilesRestored,
				TotalBytes:     status.TotalBytes,
				ProcessedBytes: status.BytesRestored,
			}

			if status.PercentDone > 0 && status.PercentDone < 1 {
				progress.Phase = "restoring"
			} else if status.PercentDone >= 1 {
				progress.Phase = "complete"
			} else {
				progress.Phase = "preparing"
			}

			opts.ProgressCallback(progress)
		}
	}

	return e.cli.Restore(ctx, cliOpts)
}

// Snapshot represents a backup snapshot.
type Snapshot struct {
	ID       string    `json:"id"`
	Time     time.Time `json:"time"`
	Hostname string    `json:"hostname"`
	Username string    `json:"username"`
	Paths    []string  `json:"paths"`
	Tags     []string  `json:"tags,omitempty"`
	// Short is a shortened snapshot ID
	Short string `json:"short_id"`
}

// ListSnapshots lists all snapshots in the repository.
func (e *Engine) ListSnapshots(ctx context.Context) ([]Snapshot, error) {
	if !e.IsInitialized() {
		if err := e.OpenRepository(ctx); err != nil {
			return nil, err
		}
	}

	return e.cli.ListSnapshots(ctx)
}

// GetSnapshot retrieves a specific snapshot by ID.
func (e *Engine) GetSnapshot(ctx context.Context, id string) (*Snapshot, error) {
	if !e.IsInitialized() {
		if err := e.OpenRepository(ctx); err != nil {
			return nil, err
		}
	}

	if id == "" {
		return nil, errors.New("snapshot ID is required")
	}

	return e.cli.GetSnapshot(ctx, id)
}

// DeleteSnapshot deletes a snapshot by ID.
func (e *Engine) DeleteSnapshot(ctx context.Context, id string) error {
	if !e.IsInitialized() {
		if err := e.OpenRepository(ctx); err != nil {
			return err
		}
	}

	if id == "" {
		return errors.New("snapshot ID is required")
	}

	return e.cli.Forget(ctx, ForgetOptions{
		SnapshotIDs: []string{id},
		Prune:       true,
	})
}

// PruneOptions configures a prune operation.
type PruneOptions struct {
	// 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)
	KeepWithinDaily   string // e.g., "7d"
	KeepWithinWeekly  string // e.g., "28d" (4 weeks)
	KeepWithinMonthly string // e.g., "12m"
	KeepWithinYearly  string // e.g., "10y"

	// DryRun simulates the prune without deleting
	DryRun bool
}

// PruneResult contains the results of a prune operation.
type PruneResult struct {
	// SnapshotsRemoved is the number of snapshots removed
	SnapshotsRemoved int

	// SpaceFreed is the space freed in bytes
	SpaceFreed int64

	// Duration is the time taken for the prune
	Duration time.Duration
}

// Prune removes old snapshots according to retention policy.
func (e *Engine) Prune(ctx context.Context, opts PruneOptions) (*PruneResult, error) {
	if !e.IsInitialized() {
		if err := e.OpenRepository(ctx); err != nil {
			return nil, err
		}
	}

	start := time.Now()

	// Use forget with prune to apply retention policy and clean up
	err := e.cli.Forget(ctx, ForgetOptions{
		KeepLast:          opts.KeepLast,
		KeepDaily:         opts.KeepDaily,
		KeepWeekly:        opts.KeepWeekly,
		KeepMonthly:       opts.KeepMonthly,
		KeepYearly:        opts.KeepYearly,
		KeepWithinDaily:   opts.KeepWithinDaily,
		KeepWithinWeekly:  opts.KeepWithinWeekly,
		KeepWithinMonthly: opts.KeepWithinMonthly,
		KeepWithinYearly:  opts.KeepWithinYearly,
		Prune:             true,
		DryRun:            opts.DryRun,
	})
	if err != nil {
		return nil, err
	}

	return &PruneResult{
		Duration: time.Since(start),
	}, nil
}

// AutoPruneOptions configures automatic pruning with append-only safe defaults.
type AutoPruneOptions struct {
	// KeepWithinDaily keeps all daily snapshots within this duration (e.g., "7d").
	KeepWithinDaily string
	// KeepWithinWeekly keeps all weekly snapshots within this duration (e.g., "28d").
	KeepWithinWeekly string
	// KeepWithinMonthly keeps all monthly snapshots within this duration (e.g., "12m").
	KeepWithinMonthly string
	// KeepWithinYearly keeps all yearly snapshots within this duration (e.g., "10y").
	KeepWithinYearly string
	// DryRun simulates the prune without deleting.
	DryRun bool
}

// DefaultAutoPruneOptions returns the default append-only safe retention policy.
// See: https://restic.readthedocs.io/en/stable/060_forget.html#security-considerations-in-append-only-mode
func DefaultAutoPruneOptions() AutoPruneOptions {
	return AutoPruneOptions{
		KeepWithinDaily:   "7d",
		KeepWithinWeekly:  "28d", // 4 weeks
		KeepWithinMonthly: "12m",
		KeepWithinYearly:  "10y",
	}
}

// AutoPrune applies the automatic retention policy using append-only safe settings.
// This uses --keep-within-* flags instead of --keep-* to prevent an attacker from
// pushing out legitimate snapshots with fake ones.
func (e *Engine) AutoPrune(ctx context.Context, opts AutoPruneOptions) (*PruneResult, error) {
	if !e.IsInitialized() {
		if err := e.OpenRepository(ctx); err != nil {
			return nil, err
		}
	}

	start := time.Now()

	err := e.cli.Forget(ctx, ForgetOptions{
		KeepWithinDaily:   opts.KeepWithinDaily,
		KeepWithinWeekly:  opts.KeepWithinWeekly,
		KeepWithinMonthly: opts.KeepWithinMonthly,
		KeepWithinYearly:  opts.KeepWithinYearly,
		Prune:             true,
		DryRun:            opts.DryRun,
	})
	if err != nil {
		return nil, err
	}

	return &PruneResult{
		Duration: time.Since(start),
	}, nil
}

// SnapshotStats contains statistics about a snapshot.
type SnapshotStats struct {
	TotalSize  int64 `json:"total_size"`
	TotalFiles int64 `json:"total_files"`
}

// Stats retrieves statistics about a snapshot.
func (e *Engine) Stats(ctx context.Context, snapshotID string) (*SnapshotStats, error) {
	if !e.IsInitialized() {
		if err := e.OpenRepository(ctx); err != nil {
			return nil, err
		}
	}

	return e.cli.Stats(ctx, snapshotID)
}

// RepositoryStats contains statistics about the repository.
type RepositoryStats struct {
	TotalSize          int64   `json:"total_size"`
	TotalUncompressed  int64   `json:"total_uncompressed_size"`
	UniqueSize         int64   `json:"unique_size"`
	DeduplicationRatio float64 `json:"deduplication_ratio"`
	TotalPacks         int     `json:"total_packs"`
}

// RepositoryStats retrieves statistics about the entire repository.
func (e *Engine) RepositoryStats(ctx context.Context) (*RepositoryStats, error) {
	if !e.IsInitialized() {
		if err := e.OpenRepository(ctx); err != nil {
			return nil, err
		}
	}

	stats, err := e.cli.Stats(ctx, "")
	if err != nil {
		return nil, err
	}

	return &RepositoryStats{
		TotalSize: stats.TotalSize,
	}, nil
}

// Check verifies the repository integrity.
func (e *Engine) Check(ctx context.Context, readData bool) error {
	if !e.IsInitialized() {
		if err := e.OpenRepository(ctx); err != nil {
			return err
		}
	}

	return e.cli.Check(ctx, readData)
}

// Unlock removes stale locks from the repository.
func (e *Engine) Unlock(ctx context.Context) error {
	return e.cli.Unlock(ctx)
}

// ListSnapshotFiles lists files in a snapshot, optionally under pathPrefix.
func (e *Engine) ListSnapshotFiles(ctx context.Context, snapshotID, pathPrefix string) ([]LsEntry, error) {
	if !e.IsInitialized() {
		if err := e.OpenRepository(ctx); err != nil {
			return nil, err
		}
	}
	return e.cli.Ls(ctx, snapshotID, pathPrefix)
}

// Close closes the engine and releases resources.
func (e *Engine) Close() error {
	e.mu.Lock()
	defer e.mu.Unlock()
	e.initialized = false
	return nil
}

// ProgressWriter wraps an io.Writer to report progress.
type ProgressWriter struct {
	Writer   io.Writer
	Callback func(written int64)
	written  int64
}

// Write writes data and reports progress.
func (pw *ProgressWriter) Write(p []byte) (int, error) {
	n, err := pw.Writer.Write(p)
	pw.written += int64(n)
	if pw.Callback != nil {
		pw.Callback(pw.written)
	}
	return n, err
}
