package regionmedic

import (
	"archive/tar"
	"compress/gzip"
	"fmt"
	"io"
	"os"
	"path"
	"path/filepath"
	"sort"
	"strings"
	"time"
)

// BackupRef identifies one panel backup tarball for an instance.
type BackupRef struct {
	Path      string    `json:"path"`
	ID        string    `json:"id,omitempty"` // "bkp_<hex>" parsed from the name
	CreatedAt time.Time `json:"created_at"`   // parsed from the filename (UTC)
}

// backupTimeLayout matches the panel backup filename timestamp
// (time.Now().UTC().Format) — see agent/internal/dispatch/backup.go.
const backupTimeLayout = "20060102-150405"

// ListPanelBackups reads an instance's backup directory
// (<agent --backup-dir>/<instance_id>/) and returns the backups sorted newest
// first, parsing the UTC timestamp + bkp id out of each filename:
//
//	20060102-150405-bkp_<hex>.tar.gz
//
// Files that don't match the pattern are skipped.
func ListPanelBackups(instanceBackupDir string) ([]BackupRef, error) {
	ents, err := os.ReadDir(instanceBackupDir)
	if err != nil {
		return nil, err
	}
	var refs []BackupRef
	for _, e := range ents {
		if e.IsDir() {
			continue
		}
		name := e.Name()
		if !strings.HasSuffix(name, ".tar.gz") {
			continue
		}
		ref, ok := parseBackupName(name)
		if !ok {
			continue
		}
		ref.Path = filepath.Join(instanceBackupDir, name)
		refs = append(refs, ref)
	}
	sort.Slice(refs, func(i, j int) bool { return refs[i].CreatedAt.After(refs[j].CreatedAt) })
	return refs, nil
}

// parseBackupName parses "20060102-150405-bkp_<hex>.tar.gz".
func parseBackupName(name string) (BackupRef, bool) {
	base := strings.TrimSuffix(name, ".tar.gz")
	// timestamp is the first 15 chars: YYYYMMDD-HHMMSS
	if len(base) < len(backupTimeLayout) {
		return BackupRef{}, false
	}
	tsPart := base[:len(backupTimeLayout)]
	ts, err := time.Parse(backupTimeLayout, tsPart)
	if err != nil {
		return BackupRef{}, false
	}
	ref := BackupRef{CreatedAt: ts.UTC()}
	if i := strings.Index(base, "bkp_"); i >= 0 {
		ref.ID = base[i:]
	}
	return ref, true
}

// ExtractRegionFromBackup pulls one region file out of a panel backup tarball.
// The tar stores paths relative to the backup root, and a single backup may
// contain SEVERAL worlds (e.g. ".../Saves/Navezgane/MyGame/Region/r.0.0.7rg"
// AND ".../Saves/West Apeeni Mountains/MapRender/Region/r.0.0.7rg"). The caller
// MUST therefore pass a world-qualified entry suffix — e.g.
// "Saves/Navezgane/MyGame/Region/r.0.0.7rg" — so the correct world's region is
// returned; a bare "r.0.0.7rg" would be ambiguous and could heal from the wrong
// world. An entry matches when its cleaned path equals entry or ends with
// "/"+entry. Returns ErrRegionNotInBackup if no entry matches.
func ExtractRegionFromBackup(backupPath, entry string) ([]byte, error) {
	f, err := os.Open(backupPath)
	if err != nil {
		return nil, err
	}
	defer f.Close()
	gz, err := gzip.NewReader(f)
	if err != nil {
		return nil, fmt.Errorf("gzip: %w", err)
	}
	defer gz.Close()
	want := strings.TrimPrefix(entry, "/")
	tr := tar.NewReader(gz)
	for {
		hdr, err := tr.Next()
		if err == io.EOF {
			break
		}
		if err != nil {
			return nil, fmt.Errorf("tar: %w", err)
		}
		if hdr.Typeflag != tar.TypeReg && hdr.Typeflag != tar.TypeRegA {
			continue
		}
		clean := strings.TrimPrefix(path.Clean(hdr.Name), "./")
		if clean == want || strings.HasSuffix(clean, "/"+want) {
			b, err := io.ReadAll(tr)
			if err != nil {
				return nil, fmt.Errorf("read entry: %w", err)
			}
			return b, nil
		}
	}
	return nil, fmt.Errorf("%w: %s", ErrRegionNotInBackup, entry)
}

// SaveRelEntry converts an absolute Region directory and a region filename into
// the world-qualified backup entry suffix used by ExtractRegionFromBackup. A
// regionDir of ".../Saves/Navezgane/MyGame/Region" + "r.0.0.7rg" yields
// "Saves/Navezgane/MyGame/Region/r.0.0.7rg". If regionDir contains no "/Saves/"
// segment it falls back to the (ambiguous) "Region/<name>".
func SaveRelEntry(regionDir, regionFileName string) string {
	slashed := filepath.ToSlash(regionDir)
	if i := strings.Index(slashed, "/Saves/"); i >= 0 {
		return slashed[i+1:] + "/" + regionFileName // from "Saves/" onward
	}
	if strings.HasPrefix(slashed, "Saves/") {
		return slashed + "/" + regionFileName
	}
	return "Region/" + regionFileName
}

// ErrRegionNotInBackup is returned when a backup doesn't contain the region.
var ErrRegionNotInBackup = fmt.Errorf("region not found in backup")

// Candidate is a backup evaluated as a heal source for one region.
type Candidate struct {
	Backup BackupRef    `json:"backup"`
	Found  bool         `json:"found"`           // region present in this backup
	Report RegionReport `json:"report"`          // validation of the extracted region
	Bytes  []byte       `json:"-"`               // extracted region bytes (best only)
	Note   string       `json:"note,omitempty"`  // why skipped, if applicable
}

// Clean reports whether this candidate's region image validated healthy.
func (c Candidate) Clean() bool { return c.Found && c.Report.Healthy() }

// FindLastGoodRegion walks backups newest→oldest, extracts the region (using a
// world-qualified entry suffix — see SaveRelEntry) from each and validates it,
// and returns the newest CLEAN candidate (best.Found && best.Clean()). The full
// evaluated list is returned for logging/auditing. Only the chosen best carries
// Bytes (others are dropped to save memory). If no clean copy is found,
// best.Found is false.
//
// backups should already be sorted newest-first (ListPanelBackups does this);
// FindLastGoodRegion re-sorts defensively. An optional notAfter bound skips
// backups created at/after it (use the corruption time to avoid trusting a
// snapshot that already captured the damage); pass the zero time to disable.
func FindLastGoodRegion(backups []BackupRef, entry string, deep bool, notAfter time.Time) (best Candidate, evaluated []Candidate, err error) {
	sorted := append([]BackupRef(nil), backups...)
	sort.Slice(sorted, func(i, j int) bool { return sorted[i].CreatedAt.After(sorted[j].CreatedAt) })

	for _, b := range sorted {
		c := Candidate{Backup: b}
		if !notAfter.IsZero() && !b.CreatedAt.Before(notAfter) {
			c.Note = "skipped: at/after corruption time"
			evaluated = append(evaluated, c)
			continue
		}
		data, exErr := ExtractRegionFromBackup(b.Path, entry)
		if exErr != nil {
			c.Note = exErr.Error()
			evaluated = append(evaluated, c)
			continue
		}
		c.Found = true
		c.Report = ValidateRegionBytes(data, deep)
		if c.Report.Healthy() && !best.Found {
			c.Bytes = data
			best = c
		}
		evaluated = append(evaluated, c)
	}
	return best, evaluated, nil
}
