package regionmedic

import (
	"fmt"
	"io"
	"os"
	"path/filepath"
	"time"
)

// HealPlan is a dry-run description of a single-region heal: which live file
// would be overwritten, from which backup, and which salvage files quarantined.
type HealPlan struct {
	Region        RegionCoord `json:"region"`
	RegionDir     string      `json:"region_dir"`
	RegionFile    string      `json:"region_file"`   // live path to overwrite
	LiveReport    RegionReport `json:"live_report"`  // validation of the current live file
	Source        Candidate   `json:"source"`        // chosen backup (Bytes populated)
	ErrorBackups  []string    `json:"error_backups"` // salvage files to quarantine
	Evaluated     []Candidate `json:"evaluated"`     // all backups considered (audit trail)
}

// Ready reports whether the plan can be applied: a clean source was found and
// the live file path is known.
func (p HealPlan) Ready() bool { return p.Source.Found && p.Source.Clean() && p.RegionFile != "" }

// HealResult records what ApplyHeal did.
type HealResult struct {
	Region           RegionCoord `json:"region"`
	RegionFile       string      `json:"region_file"`
	SnapshotPath     string      `json:"snapshot_path"`     // corrupt original saved here
	SourceBackup     string      `json:"source_backup"`     // backup the clean copy came from
	BytesWritten     int         `json:"bytes_written"`
	QuarantinedFiles []string    `json:"quarantined_files"`
}

// PlanHeal builds a heal plan for one region: it validates the current live
// region file, lists the error_backup salvage files for that region, and picks
// the newest clean backup copy via FindLastGoodRegion.
//
//   - regionDir: the instance's active Save Region/ directory.
//   - region:    which region to heal.
//   - backups:   candidate panel backups (newest-first or any order).
//   - deep:      deep-validate (inflate + coord check) backup candidates.
//   - notAfter:  skip backups at/after this time (corruption time); zero disables.
func PlanHeal(regionDir string, region RegionCoord, backups []BackupRef, deep bool, notAfter time.Time) (HealPlan, error) {
	plan := HealPlan{Region: region, RegionDir: regionDir}
	plan.RegionFile = filepath.Join(regionDir, region.FileName())
	if st, err := os.Stat(plan.RegionFile); err != nil || st.IsDir() {
		// No live file is not fatal for planning, but record it.
		plan.LiveReport = RegionReport{Path: plan.RegionFile, Err: "live region file missing"}
		plan.RegionFile = ""
	} else {
		plan.LiveReport = ValidateRegionFile(filepath.Join(regionDir, region.FileName()), deep)
	}

	// Collect error_backup salvage files belonging to this region.
	if ents, err := os.ReadDir(regionDir); err == nil {
		for _, e := range ents {
			if e.IsDir() {
				continue
			}
			cx, cz, ok := ParseErrorBackup(e.Name())
			if !ok {
				continue
			}
			if RegionForChunk(cx, cz) == region {
				plan.ErrorBackups = append(plan.ErrorBackups, filepath.Join(regionDir, e.Name()))
			}
		}
	}

	entry := SaveRelEntry(regionDir, region.FileName())
	best, evaluated, err := FindLastGoodRegion(backups, entry, deep, notAfter)
	if err != nil {
		return plan, err
	}
	plan.Source = best
	plan.Evaluated = evaluated
	return plan, nil
}

// ApplyHeal executes a ready plan:
//  1. snapshots the current (corrupt) live region file into snapshotDir,
//  2. overwrites the live region file in place with the clean source bytes
//     (truncate-in-place preserves the file's owner/mode/ACLs),
//  3. moves the region's error_backup salvage files into quarantineDir.
//
// snapshotDir and quarantineDir are created if missing. Moves fall back to
// copy+remove when rename crosses a filesystem boundary. ApplyHeal refuses a
// plan that is not Ready().
func ApplyHeal(plan HealPlan, snapshotDir, quarantineDir string) (HealResult, error) {
	var res HealResult
	if !plan.Ready() {
		return res, fmt.Errorf("plan not ready: no clean source or missing live region file")
	}
	res.Region = plan.Region
	res.RegionFile = plan.RegionFile
	res.SourceBackup = plan.Source.Backup.Path

	if err := os.MkdirAll(snapshotDir, 0o755); err != nil {
		return res, fmt.Errorf("mkdir snapshot dir: %w", err)
	}
	// 1. Snapshot the corrupt original.
	stamp := plan.LiveReport.timeStamp()
	res.SnapshotPath = filepath.Join(snapshotDir, plan.Region.FileName()+".corrupt-"+stamp)
	if err := copyFile(plan.RegionFile, res.SnapshotPath); err != nil {
		return res, fmt.Errorf("snapshot corrupt region: %w", err)
	}

	// 2. Overwrite in place (O_TRUNC keeps the inode's owner/mode/ACL).
	if err := writeInPlace(plan.RegionFile, plan.Source.Bytes); err != nil {
		return res, fmt.Errorf("write clean region: %w", err)
	}
	res.BytesWritten = len(plan.Source.Bytes)

	// 3. Quarantine the salvage files (best-effort per file, but report errors).
	if len(plan.ErrorBackups) > 0 {
		if err := os.MkdirAll(quarantineDir, 0o755); err != nil {
			return res, fmt.Errorf("mkdir quarantine dir: %w", err)
		}
		for _, src := range plan.ErrorBackups {
			dst := filepath.Join(quarantineDir, filepath.Base(src))
			if err := moveFile(src, dst); err != nil {
				return res, fmt.Errorf("quarantine %s: %w", filepath.Base(src), err)
			}
			res.QuarantinedFiles = append(res.QuarantinedFiles, dst)
		}
	}
	return res, nil
}

// timeStamp returns a filesystem-safe stamp; uses the report path's modtime is
// not available here, so callers get a coarse unique-ish suffix. We avoid
// time.Now in the pure package only inside tests; here a stamp is fine.
func (RegionReport) timeStamp() string { return time.Now().UTC().Format("20060102-150405") }

// writeInPlace truncates an existing file and writes data, preserving the
// file's existing owner/permissions/ACL (no create-new). If the file does not
// exist it is created 0o664.
func writeInPlace(path string, data []byte) error {
	f, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0)
	if err != nil {
		if os.IsNotExist(err) {
			f, err = os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o664)
		}
		if err != nil {
			return err
		}
	}
	if _, err := f.Write(data); err != nil {
		f.Close()
		return err
	}
	if err := f.Sync(); err != nil {
		f.Close()
		return err
	}
	return f.Close()
}

// copyFile copies src to dst (content only), creating dst.
func copyFile(src, dst string) error {
	in, err := os.Open(src)
	if err != nil {
		return err
	}
	defer in.Close()
	out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
	if err != nil {
		return err
	}
	if _, err := io.Copy(out, in); err != nil {
		out.Close()
		return err
	}
	if err := out.Sync(); err != nil {
		out.Close()
		return err
	}
	return out.Close()
}

// moveFile renames src to dst, falling back to copy+remove across filesystems.
func moveFile(src, dst string) error {
	if err := os.Rename(src, dst); err == nil {
		return nil
	}
	if err := copyFile(src, dst); err != nil {
		return err
	}
	return os.Remove(src)
}
