// This file combines point-in-time and continuous replay results in one concise report.
package evaluation

import (
	"context"
	"fmt"
	"os"
	"path/filepath"
	"strings"

	productionpolicy "github.com/tidbcloud/cluster-service-ng/internal/capacity-planner/pkg/policy"
	"github.com/tidbcloud/cluster-service-ng/internal/capacity-planner/pkg/tier"
)

const offlineEvaluationMarkdown = "offline-evalution-report.md"

type PointInTimeBaseline struct {
	Name    string
	Metrics SuiteMetrics
}

type PointInTimeDecisionEvaluation struct {
	Candidate SuiteMetrics
	Baselines []PointInTimeBaseline
}

type OfflineEvaluationReport struct {
	PolicyType  string
	PointInTime PointInTimeDecisionEvaluation
	Replay      *ReplayReport
}

func RunOfflineEvaluationWithOptions(ctx context.Context, experiment *ReplayExperiment, policyRevision string, resolveOptions ReplayDatasetResolveOptions) (*OfflineEvaluationReport, error) {
	replay, err := RunReplayExperimentWithOptions(ctx, experiment, policyRevision, resolveOptions)
	if err != nil {
		return nil, err
	}

	catalog, err := LoadCatalog(experiment.CasesDir)
	if err != nil {
		return nil, err
	}
	pointInTimeCases := componentCases(catalog.Cases, experiment.Spec.Component)
	if len(pointInTimeCases) == 0 {
		return nil, fmt.Errorf("evaluation: no %s point-in-time cases", experiment.Spec.Component)
	}

	candidateSuite, err := evaluatePointInTimeCandidate(ctx, experiment, pointInTimeCases)
	if err != nil {
		return nil, fmt.Errorf("evaluation: evaluate point-in-time cases: %w", err)
	}

	baselines, err := evaluateOfflineBaselines(ctx, experiment, pointInTimeCases)
	if err != nil {
		return nil, err
	}

	return &OfflineEvaluationReport{
		PolicyType: replay.PolicyType,
		PointInTime: PointInTimeDecisionEvaluation{
			Candidate: candidateSuite.Metrics,
			Baselines: baselines,
		},
		Replay: replay,
	}, nil
}

func evaluatePointInTimeCandidate(ctx context.Context, experiment *ReplayExperiment, cases []*CaseBundle) (*SuiteResult, error) {
	suite := &SuiteResult{Results: make([]Result, 0, len(cases))}
	for i := range cases {
		bundle := cases[i]
		observed, err := ResolveTiDBTierAt(bundle.Input.Metrics, bundle.Input.DecisionAt, tiDBTierMaxAge)
		if err != nil {
			return nil, fmt.Errorf("case %q: resolve TiDB tier: %w", bundle.ID, err)
		}
		bundle.Input.Current.Tier = observed.NodeSpec
		candidate, mappings, err := BuildReplayPolicy(experiment, observed)
		if err != nil {
			return nil, fmt.Errorf("case %q: build policy: %w", bundle.ID, err)
		}
		result, err := EvaluateWithProjection(ctx, bundle, candidate, mappings)
		if err != nil {
			return nil, err
		}
		suite.Results = append(suite.Results, *result)
		addSuiteResult(&suite.Metrics, result)
	}
	finishSuiteMetrics(&suite.Metrics)
	return suite, nil
}

func evaluateOfflineBaselines(ctx context.Context, experiment *ReplayExperiment, cases []*CaseBundle) ([]PointInTimeBaseline, error) {
	templates, err := tier.LoadTemplatesFile(experiment.TierTemplatesPath)
	if err != nil {
		return nil, fmt.Errorf("evaluation: load baseline tier templates: %w", err)
	}
	tiers, err := tier.New(templates, &experiment.Spec.Policy.Tiers)
	if err != nil {
		return nil, fmt.Errorf("evaluation: materialize baseline tiers: %w", err)
	}
	if len(tiers) == 0 {
		return nil, fmt.Errorf("evaluation: baseline tier is required")
	}

	policies := []struct {
		name  string
		build func(tier.Tier) productionpolicy.Policy
	}{
		{name: "AlwaysNoScale", build: AlwaysNoScale},
		{name: "AlwaysScaleOut", build: AlwaysScaleOut},
	}

	suites := make([]SuiteResult, len(policies))
	for i := range policies {
		suites[i].Results = make([]Result, 0, len(cases))
	}
	for i := range cases {
		bundle := cases[i]
		observed, err := ResolveTiDBTierAt(bundle.Input.Metrics, bundle.Input.DecisionAt, tiDBTierMaxAge)
		if err != nil {
			return nil, fmt.Errorf("evaluation: case %q: resolve baseline TiDB tier: %w", bundle.ID, err)
		}
		selected, err := selectObservedTiDBTier(tiers, observed)
		if err != nil {
			return nil, fmt.Errorf("evaluation: case %q: select baseline TiDB tier: %w", bundle.ID, err)
		}
		for j := range policies {
			result, err := Evaluate(ctx, bundle, policies[j].build(selected))
			if err != nil {
				return nil, fmt.Errorf("evaluation: evaluate %s point-in-time baseline: %w", policies[j].name, err)
			}
			suites[j].Results = append(suites[j].Results, *result)
			addSuiteResult(&suites[j].Metrics, result)
		}
	}

	baselines := make([]PointInTimeBaseline, 0, len(policies))
	for i := range policies {
		finishSuiteMetrics(&suites[i].Metrics)
		baselines = append(baselines, PointInTimeBaseline{Name: policies[i].name, Metrics: suites[i].Metrics})
	}
	return baselines, nil
}

func componentCases(cases []*CaseBundle, component string) []*CaseBundle {
	selected := make([]*CaseBundle, 0, len(cases))
	for i := range cases {
		if cases[i].Input.Component == component {
			selected = append(selected, cases[i])
		}
	}
	return selected
}

func WriteOfflineEvaluationReport(outputDir string, report *OfflineEvaluationReport) (string, error) {
	if outputDir == "" || report == nil || report.Replay == nil {
		return "", fmt.Errorf("evaluation: offline report output and value are required")
	}
	if err := os.MkdirAll(outputDir, 0o755); err != nil {
		return "", fmt.Errorf("evaluation: create offline report directory: %w", err)
	}

	path := filepath.Join(outputDir, offlineEvaluationMarkdown)
	if err := os.WriteFile(path, []byte(RenderOfflineEvaluationReportMarkdown(report)), 0o644); err != nil {
		return "", fmt.Errorf("evaluation: write offline Markdown report: %w", err)
	}
	return path, nil
}

func RenderOfflineEvaluationReportMarkdown(report *OfflineEvaluationReport) string {
	var output strings.Builder
	candidate := &report.PointInTime.Candidate
	noScale := findOfflineBaseline(report.PointInTime.Baselines, "AlwaysNoScale")
	replay := report.Replay

	fmt.Fprint(&output, "# Offline Autoscaling Evaluation Report\n\n")
	fmt.Fprint(&output, "## Conclusion\n\n")
	fmt.Fprintf(&output, "- **Overall:** %s\n", offlineOverallConclusion(candidate, noScale, &replay.Summary))
	fmt.Fprintf(&output, "- **Point-in-time decisions:** The Policy made the expected horizontal action in **%s**.", formatOfRate(pointInTimeActionAgreement(candidate), candidate.TotalCases))
	if noScale != nil {
		fmt.Fprintf(&output, " `AlwaysNoScale` did so in **%s**.", formatOfRate(pointInTimeActionAgreement(&noScale.Metrics), noScale.Metrics.TotalCases))
	}
	fmt.Fprint(&output, "\n")
	fmt.Fprintf(&output, "- **Continuous behavior:** The Policy changed its replica target **%d times**, compared with **%d times** for HPA. It reversed direction **%d times**, compared with **%d times** for HPA.\n",
		replay.Summary.TargetChanges, replay.Summary.HPATargetChanges, replay.Summary.DirectionReversals, replay.Summary.HPADirectionReversals)
	fmt.Fprintf(&output, "- **Capacity tendency:** %s This is not measured savings.\n\n", formatOfflineCapacity(replay.Summary.RecommendedReplicaHours, replay.Summary.HPAReplicaHours, replay.Summary.ComparableReplicaHourIntervals))

	fmt.Fprint(&output, "## Point-in-Time Decision Evaluation: Was Each Decision Correct?\n\n")
	fmt.Fprint(&output, "Each case has its own `decisionAt` and calls the Policy once. Cases may use historical or manually constructed metrics, and are not limited to the replay period.\n\n")
	fmt.Fprint(&output, "| Policy | Expected ScaleOut | Expected NoScale | Correct horizontal action | Exact replica target |\n|---|---:|---:|---:|---:|\n")
	writePointInTimeRow(&output, report.PolicyType, candidate)
	for i := range report.PointInTime.Baselines {
		baseline := &report.PointInTime.Baselines[i]
		writePointInTimeRow(&output, baseline.Name, &baseline.Metrics)
	}
	fmt.Fprint(&output, "\nA case expecting ScaleOut is correct when the Policy requests more replicas. A case expecting NoScale is correct when the Policy keeps the current replica count. Exact target matching is stricter.\n\n")
	fmt.Fprint(&output, "Case labels are fixed expected outcomes from the case YAML files; this report does not infer them from metrics.\n\n")

	fmt.Fprint(&output, "## Continuous Replay: Did the Policy Behave Stably?\n\n")
	fmt.Fprintf(&output, "The replay uses one continuous period: `%s` to `%s`.\n\n", replay.Dataset.Window.Start.Format(timeLayout), replay.Dataset.Window.End.Format(timeLayout))
	fmt.Fprint(&output, "| Measure | Policy | HPA |\n|---|---:|---:|\n")
	fmt.Fprintf(&output, "| Completed Policy calls | %s | - |\n", formatCountRate(replay.Summary.EvaluatedPoints, replay.Summary.ScheduledPoints))
	fmt.Fprintf(&output, "| Replica target changes | %d | %d |\n", replay.Summary.TargetChanges, replay.Summary.HPATargetChanges)
	fmt.Fprintf(&output, "| Direction reversals | %d | %d |\n", replay.Summary.DirectionReversals, replay.Summary.HPADirectionReversals)
	fmt.Fprintf(&output, "| Replica-hours | %.3f | %.3f |\n\n", replay.Summary.RecommendedReplicaHours, replay.Summary.HPAReplicaHours)
	fmt.Fprint(&output, "Fewer target changes and direction reversals mean less scaling churn. Replica-hours show capacity tendency only because replay decisions were not executed.\n\n")

	fmt.Fprint(&output, "## Run Identity\n\n")
	fmt.Fprint(&output, "| Input | Value |\n|---|---|\n")
	fmt.Fprintf(&output, "| Component | `%s` |\n", replay.Dataset.Component)
	fmt.Fprintf(&output, "| Cluster | `%s` |\n", replay.Dataset.Target.ClusterID)
	fmt.Fprintf(&output, "| Policy source | `%s` |\n", replay.PolicyRevision)
	fmt.Fprintf(&output, "| Experiment configuration | `%s` |\n", replay.ExperimentDigest)
	fmt.Fprintf(&output, "| Historical archive | `%s` |\n", replay.Dataset.ArchiveDigest)
	return output.String()
}

func pointInTimeActionAgreement(metrics *SuiteMetrics) int {
	return metrics.UsefulScaleOutAccepted + metrics.UnnecessaryScaleOutRejected
}

func findOfflineBaseline(baselines []PointInTimeBaseline, name string) *PointInTimeBaseline {
	for i := range baselines {
		if baselines[i].Name == name {
			return &baselines[i]
		}
	}
	return nil
}

func offlineOverallConclusion(candidate *SuiteMetrics, noScale *PointInTimeBaseline, replay *ReplaySummary) string {
	needsTuning := replay.TargetChanges > replay.HPATargetChanges || replay.DirectionReversals > replay.HPADirectionReversals
	if noScale != nil && pointInTimeActionAgreement(candidate) < pointInTimeActionAgreement(&noScale.Metrics) {
		needsTuning = true
	}
	if needsTuning {
		return "This candidate needs more tuning."
	}
	return "This candidate does not regress against the offline baselines, but production safety is not proven."
}

func formatOfRate(count, total int) string {
	return fmt.Sprintf("%d of %d cases (%.1f%%)", count, total, percentage(count, total))
}

func formatOfflineCapacity(policyHours, hpaHours float64, intervals int) string {
	if intervals == 0 || hpaHours == 0 {
		return "A replica-hours comparison is unavailable."
	}
	if policyHours == hpaHours {
		return fmt.Sprintf("The Policy and HPA both requested **%.3f replica-hours**.", policyHours)
	}
	if policyHours < hpaHours {
		return fmt.Sprintf("The Policy requested **%.3f replica-hours**, **%.1f%% less** than HPA.", policyHours, (hpaHours-policyHours)*100/hpaHours)
	}
	return fmt.Sprintf("The Policy requested **%.3f replica-hours**, **%.1f%% more** than HPA.", policyHours, (policyHours-hpaHours)*100/hpaHours)
}

func writePointInTimeRow(output *strings.Builder, name string, metrics *SuiteMetrics) {
	fmt.Fprintf(output, "| `%s` | %s | %s | %s | %s |\n",
		name,
		formatCountRate(metrics.UsefulScaleOutAccepted, metrics.UsefulScaleOutCases),
		formatCountRate(metrics.UnnecessaryScaleOutRejected, metrics.UnnecessaryScaleOutCases),
		formatCountRate(pointInTimeActionAgreement(metrics), metrics.TotalCases),
		formatCountRate(metrics.PassedCases, metrics.TotalCases))
}
