package migration

import (
	"errors"
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
	"slices"

	"github.com/tidbcloud/observability/vm-migration/internal/jsonio"
	"github.com/tidbcloud/observability/vm-migration/internal/schema"
	"github.com/tidbcloud/observability/vm-migration/internal/status"
)

// Loop step names the shard status file can evidence: its imported and
// verified counts attest exactly these two.
const (
	stepMigrate = "migrate"
	stepVerify  = "verify"
)

func loadInputs(loopPath, statusPath string) (*schema.MigrationLoopPlan, *schema.MigrationLoopStatus, error) {
	var plan schema.MigrationLoopPlan
	if err := jsonio.ReadFile(loopPath, &plan); err != nil {
		return nil, nil, fmt.Errorf("%s: %w", loopPath, err)
	}
	if err := plan.ValidateTenantURLs(); err != nil {
		return nil, nil, fmt.Errorf("%s: %w", loopPath, err)
	}
	for _, tenant := range plan.Tenants {
		for _, step := range tenant.Cmds {
			if err := ValidateStep(&plan, step.Step, step.Action); err != nil {
				return nil, nil, fmt.Errorf("%s: tenant %s step %s: %w", loopPath, tenant.Name, step.Step, err)
			}
		}
		stepNames := make([]string, 0, len(tenant.Cmds))
		for _, step := range tenant.Cmds {
			stepNames = append(stepNames, step.Step)
		}
		if err := schema.ValidateLoopStepSequence(stepNames); err != nil {
			return nil, nil, fmt.Errorf("%s: tenant %s: %w", loopPath, tenant.Name, err)
		}
	}
	var statusDoc schema.MigrationLoopStatus
	if err := jsonio.ReadFile(statusPath, &statusDoc); err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return &plan, nil, nil
		}
		return nil, nil, fmt.Errorf("%s: %w", statusPath, err)
	}
	return &plan, &statusDoc, nil
}

// reconcileStatus rebuilds the loop-status document from the plan and any
// existing status file.
//
// Per-tenant summary JSON is consulted to reopen loop steps whose shard
// counts reveal a progress gap; it is never used to complete a step.
// Per-tenant paths resolve against planDir.
func reconcileStatus(plan *schema.MigrationLoopPlan, existing *schema.MigrationLoopStatus, planDir string) (*schema.MigrationLoopStatus, error) {
	out := &schema.MigrationLoopStatus{UpdatedAt: status.FormatTime(status.UTCNow())}
	if existing != nil && existing.UpdatedAt != "" {
		out.UpdatedAt = existing.UpdatedAt
	}
	existingByName := map[string]*schema.MigrationLoopTenantStatus{}
	if existing != nil {
		for i := range existing.Tenants {
			existingByName[existing.Tenants[i].Name] = &existing.Tenants[i]
		}
	}
	for _, tp := range plan.Tenants {
		var existingTenant *schema.MigrationLoopTenantStatus
		if e, ok := existingByName[tp.Name]; ok {
			existingTenant = e
		}
		t, err := buildTenantStatus(tp, existingTenant, planDir)
		if err != nil {
			return nil, fmt.Errorf("tenant %s: %w", tp.Name, err)
		}
		out.Tenants = append(out.Tenants, *t)
	}
	return out, nil
}

func buildTenantStatus(tp schema.MigrationLoopTenant, existing *schema.MigrationLoopTenantStatus, planDir string) (*schema.MigrationLoopTenantStatus, error) {
	defaultSteps := []schema.MigrationLoopStepStatus{}
	for _, c := range tp.Cmds {
		s := schema.MigrationLoopStepStatus{Step: c.Step, Status: StatusPending}
		defaultSteps = append(defaultSteps, s)
	}
	if existing == nil {
		return &schema.MigrationLoopTenantStatus{
			Name:        tp.Name,
			Completed:   false,
			CurrentStep: firstStepName(tp),
			Steps:       defaultSteps,
		}, nil
	}
	existingSteps := map[string]schema.MigrationLoopStepStatus{}
	for _, s := range existing.Steps {
		existingSteps[s.Step] = s
	}
	merged := []schema.MigrationLoopStepStatus{}
	for _, c := range tp.Cmds {
		s, ok := existingSteps[c.Step]
		if !ok {
			s = schema.MigrationLoopStepStatus{Step: c.Step, Status: StatusPending}
		}
		if s.Status == "" {
			s.Status = StatusPending
		}
		merged = append(merged, s)
	}
	reopened := false
	summary, err := readTenantSummary(planDir, tp)
	if err != nil {
		path := tenantStatusPath(planDir, tp)
		if !errors.Is(err, fs.ErrNotExist) {
			return nil, fmt.Errorf("status file %q is unreadable: %w", path, err)
		}
		// No evidence on disk: completed migrate/verify markers are
		// unverifiable claims, and silently skipping those steps is how
		// data goes missing. Fail closed and let an operator decide.
		if hasCompletedProgressStep(merged) {
			return nil, fmt.Errorf("status file %q is missing but loop-status claims completed %s/%s steps; restore the file or reset the migration manually", path, stepMigrate, stepVerify)
		}
	} else if summary.Total > 0 {
		switch {
		case summary.Imported != summary.Total:
			markPendingFrom(merged, stepMigrate)
			reopened = true
		case summary.Verified != summary.Total:
			markPendingFrom(merged, stepVerify)
			reopened = true
		}
	}
	stepsCompleted := len(merged) > 0
	for _, s := range merged {
		if s.Status != StatusCompleted {
			stepsCompleted = false
			break
		}
	}
	completed := stepsCompleted
	currentStep := firstPendingStep(merged)
	if completed {
		currentStep = StepDone
	}
	// A reopened tenant re-runs and records a fresh finish time.
	finishedAt := existing.FinishedAt
	if reopened {
		finishedAt = nil
	}
	return &schema.MigrationLoopTenantStatus{
		Name:        tp.Name,
		Completed:   completed,
		Failed:      existing.Failed,
		CurrentStep: currentStep,
		StartedAt:   existing.StartedAt,
		FinishedAt:  finishedAt,
		Steps:       merged,
	}, nil
}

func markPendingFrom(steps []schema.MigrationLoopStepStatus, stepName string) {
	found := false
	for i := range steps {
		if steps[i].Step == stepName {
			found = true
		}
		if !found {
			continue
		}
		steps[i].Status = StatusPending
		steps[i].StartedAt = nil
		steps[i].FinishedAt = nil
		steps[i].LastExitCode = nil
		steps[i].LastError = nil
	}
}

func firstStepName(tp schema.MigrationLoopTenant) string {
	if len(tp.Cmds) == 0 {
		return ""
	}
	return tp.Cmds[0].Step
}

func firstPendingStep(steps []schema.MigrationLoopStepStatus) string {
	for _, s := range steps {
		if s.Status != StatusCompleted {
			return s.Step
		}
	}
	return ""
}

// readTenantSummary reads the per-tenant shard summary.
//
// Returns a wrapped fs.ErrNotExist when no status file is configured or the
// file is absent, so callers can distinguish "no evidence" from "evidence is
// corrupt". Any other read or decode error is returned as-is.
func readTenantSummary(planDir string, tp schema.MigrationLoopTenant) (schema.Summary, error) {
	if tp.StatusFile == "" {
		return schema.Summary{}, fs.ErrNotExist
	}
	var doc schema.Document
	if err := jsonio.ReadFile(tenantStatusPath(planDir, tp), &doc); err != nil {
		return schema.Summary{}, err
	}
	return doc.Summary, nil
}

// tenantStatusPath returns the per-tenant status file path anchored on the
// plan directory.
func tenantStatusPath(planDir string, tp schema.MigrationLoopTenant) string {
	if tp.StatusFile == "" {
		return "(no statusFile configured)"
	}
	return filepath.Join(planDir, tp.StatusFile)
}

// hasCompletedProgressStep reports whether a migrate or verify step is
// marked completed — the only two steps the shard status file can attest
// to, via its imported/verified counts.
func hasCompletedProgressStep(steps []schema.MigrationLoopStepStatus) bool {
	return slices.ContainsFunc(steps, func(s schema.MigrationLoopStepStatus) bool {
		return s.Status == StatusCompleted && (s.Step == stepMigrate || s.Step == stepVerify)
	})
}

func dumpStatusAtomic(path string, doc *schema.MigrationLoopStatus) error {
	doc.UpdatedAt = status.FormatTime(status.UTCNow())
	if err := status.DumpJSONAtomic(path, doc); err != nil {
		return fmt.Errorf("write status: %w", err)
	}
	return nil
}

// DeriveLoopStatusPath returns the status JSON path paired with a loop path.
//
// Exported for ReadLoopSummary, which applies the same convention.
func DeriveLoopStatusPath(loopPath string) string {
	return schema.SwapSuffix(loopPath, "", "-status.json")
}
