package api

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"io/fs"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"sort"
	"strings"

	"github.com/redscaresu/infrafactory/internal/runstore"
	"github.com/redscaresu/infrafactory/internal/scenario"
)

type scenariosListResponse struct {
	Groups []scenarioGroup `json:"groups"`
}

type scenarioGroup struct {
	Name      string             `json:"name"`
	Scenarios []scenarioListItem `json:"scenarios"`
}

type scenarioListItem struct {
	Name        string              `json:"name"`
	Path        string              `json:"path"`
	Description string              `json:"description"`
	Cloud       string              `json:"cloud"`
	LastRun     *runMetadataSummary `json:"last_run,omitempty"`
}

type runMetadataSummary struct {
	RunID          string `json:"run_id"`
	Status         string `json:"status"`
	TerminalReason string `json:"terminal_reason,omitempty"`
}

type scenarioDetailResponse struct {
	Name        string                         `json:"name"`
	Path        string                         `json:"path"`
	Description string                         `json:"description"`
	Cloud       string                         `json:"cloud"`
	RawYAML     string                         `json:"raw_yaml"`
	Resources   scenario.Resources             `json:"resources"`
	// S51: Constraints field removed; per-criterion Params live on
	// AcceptanceCriterion now.
	Criteria []scenario.AcceptanceCriterion `json:"criteria"`
}

func listScenariosHandler(state *serverState) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		if r.Method != http.MethodGet {
			writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
			return
		}

		groupsMap := map[string][]scenarioListItem{}
		err := filepath.WalkDir(state.cfg.Paths.Scenarios, func(path string, d fs.DirEntry, walkErr error) error {
			if walkErr != nil {
				return walkErr
			}
			if d.IsDir() {
				return nil
			}
			if !strings.HasSuffix(d.Name(), ".yaml") && !strings.HasSuffix(d.Name(), ".yml") {
				return nil
			}

			rel, err := filepath.Rel(state.cfg.Paths.Scenarios, path)
			if err != nil {
				return err
			}
			rel = filepath.ToSlash(rel)

			sc, _, err := loadScenarioFile(path, state.scenarioSchemaPathCandidates())
			if err != nil {
				return err
			}

			scenarioPath := strings.TrimSuffix(strings.TrimSuffix(rel, ".yaml"), ".yml")
			group := "root"
			if idx := strings.IndexRune(scenarioPath, '/'); idx > 0 {
				group = scenarioPath[:idx]
			}

			item := scenarioListItem{
				Name:        sc.Name,
				Path:        scenarioPath,
				Description: sc.Description,
				Cloud:       sc.Cloud,
				LastRun:     latestRunSummary(state.store, sc.Name),
			}
			groupsMap[group] = append(groupsMap[group], item)
			return nil
		})
		if err != nil {
			writeJSONError(w, http.StatusInternalServerError, err.Error())
			return
		}

		groupNames := make([]string, 0, len(groupsMap))
		for name := range groupsMap {
			groupNames = append(groupNames, name)
		}
		sort.Strings(groupNames)

		resp := scenariosListResponse{
			Groups: make([]scenarioGroup, 0, len(groupNames)),
		}
		for _, name := range groupNames {
			items := groupsMap[name]
			sort.Slice(items, func(i, j int) bool {
				return items[i].Path < items[j].Path
			})
			resp.Groups = append(resp.Groups, scenarioGroup{Name: name, Scenarios: items})
		}

		writeJSON(w, http.StatusOK, resp)
	}
}

type validateScenarioRequest struct {
	YAML string `json:"yaml"`
}

type validateScenarioResponse struct {
	Valid  bool                 `json:"valid"`
	Errors []scenario.Violation `json:"errors"`
}

func validateScenarioHandler(state *serverState) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		if r.Method != http.MethodPost {
			writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
			return
		}
		contentType := strings.TrimSpace(strings.SplitN(r.Header.Get("Content-Type"), ";", 2)[0])
		// Require an explicit application/json content-type. Earlier this
		// handler accepted empty content-type as JSON, but that's a silent
		// asymmetry with the explicit text/plain rejection — clients
		// should always declare what they're sending.
		if !strings.EqualFold(contentType, "application/json") {
			writeJSONError(w, http.StatusUnsupportedMediaType, "content-type must be application/json")
			return
		}

		const maxValidatePayloadBytes = 1 << 20 // 1 MB
		body, err := io.ReadAll(io.LimitReader(r.Body, maxValidatePayloadBytes+1))
		if err != nil {
			writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("read request body: %v", err))
			return
		}
		if len(body) > maxValidatePayloadBytes {
			writeJSONError(w, http.StatusRequestEntityTooLarge, "validate payload exceeds 1 MB limit")
			return
		}

		var req validateScenarioRequest
		dec := json.NewDecoder(bytes.NewReader(body))
		dec.DisallowUnknownFields()
		if err := dec.Decode(&req); err != nil {
			writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("decode request body: %v", err))
			return
		}
		// Reject trailing JSON so a body like `{"yaml":"x"}{"yaml":"y"}`
		// doesn't silently validate only the first object.
		if dec.More() {
			writeJSONError(w, http.StatusBadRequest, "request body must contain a single JSON object")
			return
		}
		if strings.TrimSpace(req.YAML) == "" {
			writeJSONError(w, http.StatusBadRequest, "yaml field is required")
			return
		}

		schemaPath, err := selectSchemaPath(state.scenarioSchemaPathCandidates())
		if err != nil {
			writeJSONError(w, http.StatusInternalServerError, err.Error())
			return
		}

		validateErr := scenario.ValidateBytes([]byte(req.YAML), schemaPath, "")
		if validateErr == nil {
			writeJSON(w, http.StatusOK, validateScenarioResponse{
				Valid:  true,
				Errors: []scenario.Violation{},
			})
			return
		}

		var validationErr *scenario.ValidationError
		if errors.As(validateErr, &validationErr) {
			writeJSON(w, http.StatusOK, validateScenarioResponse{
				Valid:  false,
				Errors: validationErr.Violations,
			})
			return
		}

		if errors.Is(validateErr, scenario.ErrMalformedScenario) {
			writeJSON(w, http.StatusOK, validateScenarioResponse{
				Valid: false,
				Errors: []scenario.Violation{{
					Path:    "",
					Message: fmt.Sprintf("yaml syntax: %s", extractYAMLSyntaxDetail(validateErr.Error())),
				}},
			})
			return
		}

		writeJSONError(w, http.StatusInternalServerError, validateErr.Error())
	}
}

// extractYAMLSyntaxDetail strips the wrapper prefix added by parseAndValidate
// (e.g. `malformed scenario: parse scenario "<label>": <detail>`) so the
// message surfaces just the underlying parser error. Also strips the
// redundant `yaml: ` prefix the underlying gopkg.in/yaml.v3 library
// prepends, so the response message doesn't end up doubled as
// `yaml syntax: yaml: ...`.
//
// The previous implementation used strings.LastIndex(": ") which also
// matched the colon inside `yaml: line 5: did not find expected key`,
// truncating the helpful `line 5:` context. Splitting on the wrapper's
// closing `": ` instead keeps the full yaml-side message.
func extractYAMLSyntaxDetail(message string) string {
	const wrapperOpen = `parse scenario "`
	if idx := strings.Index(message, wrapperOpen); idx != -1 {
		// Walk past the closing `": ` of the sourceLabel quote.
		if close := strings.Index(message[idx:], `": `); close != -1 {
			message = message[idx+close+len(`": `):]
		}
	}
	message = strings.TrimSpace(message)
	return strings.TrimPrefix(message, "yaml: ")
}

func selectSchemaPath(candidates []string) (string, error) {
	var lastErr error
	for _, schemaPath := range candidates {
		if _, err := os.Stat(schemaPath); err != nil {
			lastErr = err
			continue
		}
		return schemaPath, nil
	}
	if lastErr != nil {
		return "", fmt.Errorf("locate scenario schema: %w", lastErr)
	}
	return "", fmt.Errorf("locate scenario schema: no schema paths configured")
}

func scenarioByPathHandler(state *serverState) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		relPath, ok := parseTailPath(r.URL.Path, "/api/scenarios/")
		if !ok {
			writeJSONError(w, http.StatusNotFound, "scenario path not provided")
			return
		}
		runModeRequest := false
		layer3StatusRequest := false
		if strings.HasSuffix(relPath, "/run-mode") {
			runModeRequest = true
			relPath = strings.TrimSuffix(relPath, "/run-mode")
		} else if strings.HasSuffix(relPath, "/layer3-status") {
			layer3StatusRequest = true
			relPath = strings.TrimSuffix(relPath, "/layer3-status")
		}

		scenarioFile, err := resolveScenarioFile(state.cfg.Paths.Scenarios, relPath)
		if err != nil {
			writeJSONError(w, http.StatusForbidden, err.Error())
			return
		}

		switch r.Method {
		case http.MethodGet:
			if runModeRequest {
				handleGetScenarioRunMode(w, r.Context(), state, relPath, scenarioFile)
				return
			}
			if layer3StatusRequest {
				handleGetScenarioLayer3Status(w, state, relPath, scenarioFile)
				return
			}
			handleGetScenarioByPath(w, state, relPath, scenarioFile)
		case http.MethodPut:
			if runModeRequest || layer3StatusRequest {
				writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
				return
			}
			handlePutScenarioByPath(w, r, state, scenarioFile)
		default:
			writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
		}
	}
}

func handleGetScenarioLayer3Status(w http.ResponseWriter, state *serverState, relPath, scenarioFile string) {
	sc, _, err := loadScenarioFile(scenarioFile, state.scenarioSchemaPathCandidates())
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			writeJSONError(w, http.StatusNotFound, "scenario not found")
			return
		}
		writeJSONError(w, http.StatusInternalServerError, err.Error())
		return
	}

	requiredVars := layer3RequiredEnvForCloud(sc.Cloud)
	missing := make([]string, 0, len(requiredVars))
	for _, name := range requiredVars {
		if strings.TrimSpace(os.Getenv(name)) == "" {
			missing = append(missing, name)
		}
	}
	ready := len(missing) == 0
	detail := "Layer 3 credentials ready"
	if !ready {
		detail = "Missing " + strings.Join(missing, ", ")
	}

	writeJSON(w, http.StatusOK, map[string]any{
		"name":                   filepath.Base(relPath),
		"path":                   relPath,
		"cloud":                  sc.Cloud,
		"config_default_enabled": state.cfg.Validation.Layers.SandboxDeploy.Enabled,
		"credentials_ready":      ready,
		"missing_credentials":    missing,
		"ready":                  ready,
		"detail":                 detail,
	})
}

// layer3RequiredEnvForCloud returns the env vars that must be set for the
// Layer 3 (real-cloud) credentials check, dispatched by the scenario's
// `cloud` field. Falls back to Scaleway for empty/unknown values to keep
// pre-multi-cloud scenarios working unchanged.
func layer3RequiredEnvForCloud(cloud string) []string {
	switch strings.ToLower(strings.TrimSpace(cloud)) {
	case "gcp":
		return []string{"GOOGLE_CREDENTIALS", "GOOGLE_PROJECT"}
	case "aws":
		return []string{"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"}
	default:
		return []string{"SCW_ACCESS_KEY", "SCW_SECRET_KEY"}
	}
}

func handleGetScenarioByPath(w http.ResponseWriter, state *serverState, relPath, scenarioFile string) {
	sc, rawYAML, err := loadScenarioFile(scenarioFile, state.scenarioSchemaPathCandidates())
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			writeJSONError(w, http.StatusNotFound, "scenario not found")
			return
		}
		writeJSONError(w, http.StatusInternalServerError, err.Error())
		return
	}

	writeJSON(w, http.StatusOK, scenarioDetailResponse{
		Name:        sc.Name,
		Path:        relPath,
		Description: sc.Description,
		Cloud:       sc.Cloud,
		RawYAML:     string(rawYAML),
		Resources:   sc.Resources,
		Criteria:    sc.AcceptanceCriteria,
	})
}

func handlePutScenarioByPath(w http.ResponseWriter, r *http.Request, state *serverState, scenarioFile string) {
	const maxScenarioPayloadBytes = 1 << 20 // 1 MB
	payload, err := io.ReadAll(io.LimitReader(r.Body, maxScenarioPayloadBytes+1))
	if err != nil {
		writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("read request body: %v", err))
		return
	}
	if len(payload) > maxScenarioPayloadBytes {
		writeJSONError(w, http.StatusRequestEntityTooLarge, "scenario payload exceeds 1 MB limit")
		return
	}

	tmpFile, err := os.CreateTemp("", "scenario-validate-*.yaml")
	if err != nil {
		writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("create temp file: %v", err))
		return
	}
	tmpPath := tmpFile.Name()
	tmpFile.Close()
	defer os.Remove(tmpPath)
	if err := os.WriteFile(tmpPath, payload, 0o600); err != nil {
		writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("write temp scenario: %v", err))
		return
	}

	_, _, err = loadScenarioFile(tmpPath, state.scenarioSchemaPathCandidates())
	if err != nil {
		var validationErr *scenario.ValidationError
		if errors.As(err, &validationErr) {
			writeJSON(w, http.StatusUnprocessableEntity, map[string]any{
				"errors": validationErr.Violations,
			})
			return
		}
		if errors.Is(err, scenario.ErrMalformedScenario) {
			writeJSONError(w, http.StatusBadRequest, err.Error())
			return
		}
		writeJSONError(w, http.StatusBadRequest, err.Error())
		return
	}

	if err := os.WriteFile(scenarioFile, payload, 0o644); err != nil {
		writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("write scenario file: %v", err))
		return
	}
	writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}

func latestRunSummary(store *runstore.FilesystemStore, scenarioName string) *runMetadataSummary {
	runs, err := store.ListRuns(scenarioName)
	if err != nil || len(runs) == 0 {
		return nil
	}
	latest := runs[0]
	for _, run := range runs[1:] {
		if run.StartedAt.After(latest.StartedAt) || (run.StartedAt.Equal(latest.StartedAt) && run.RunID > latest.RunID) {
			latest = run
		}
	}
	return &runMetadataSummary{
		RunID:          latest.RunID,
		Status:         latest.Status,
		TerminalReason: latest.TerminalReason,
	}
}

func handleGetScenarioRunMode(w http.ResponseWriter, ctx context.Context, state *serverState, relPath, scenarioFile string) {
	sc, _, err := loadScenarioFile(scenarioFile, state.scenarioSchemaPathCandidates())
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			writeJSONError(w, http.StatusNotFound, "scenario not found")
			return
		}
		writeJSONError(w, http.StatusInternalServerError, err.Error())
		return
	}
	mockReader, mockName := state.mockStateForCloud(sc.Cloud)
	if mockReader == nil {
		writeJSONError(w, http.StatusNotImplemented, "mock state detection is not configured for cloud="+sc.Cloud)
		return
	}

	statePayload, err := mockReader.State(ctx)
	if err != nil {
		writeJSONError(w, http.StatusInternalServerError, err.Error())
		return
	}
	hasMockResources, err := apiMockStateHasResources(statePayload)
	if err != nil {
		writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("decode mock state for run mode detection: %v", err))
		return
	}
	hasTFState := apiTFStateExists(filepath.Join(state.cfg.Paths.Output, sc.Name))
	previousRunID, err := state.store.LatestSuccessfulRunID(sc.Name)
	if err != nil {
		writeJSONError(w, http.StatusInternalServerError, err.Error())
		return
	}

	missing := make([]string, 0, 3)
	if !hasMockResources {
		missing = append(missing, mockName+" state")
	}
	if !hasTFState {
		missing = append(missing, "terraform.tfstate")
	}
	if previousRunID == "" {
		missing = append(missing, "previous successful run")
	}

	mode := "incremental"
	reason := "auto-detected from " + mockName + " state, terraform.tfstate, and previous successful run"
	if len(missing) > 0 {
		mode = "clean"
		reason = "missing " + strings.Join(missing, ", ")
	}

	writeJSON(w, http.StatusOK, map[string]any{
		"name":                        sc.Name,
		"path":                        relPath,
		"cloud":                       sc.Cloud,
		"mock_provider":               mockName,
		"mode":                        mode,
		"reason":                      reason,
		"previous_run_id":             previousRunID,
		"has_mock_resources":          hasMockResources,
		"has_tfstate":                 hasTFState,
		"has_previous_successful_run": previousRunID != "",
	})
}

func apiMockStateHasResources(payload []byte) (bool, error) {
	var state map[string]any
	if err := json.Unmarshal(payload, &state); err != nil {
		return false, err
	}
	for _, rootNode := range state {
		rootMap, ok := rootNode.(map[string]any)
		if !ok {
			continue
		}
		for _, value := range rootMap {
			items, ok := value.([]any)
			if ok && len(items) > 0 {
				return true, nil
			}
		}
	}
	return false, nil
}

func apiTFStateExists(outputDir string) bool {
	_, err := os.Stat(filepath.Join(outputDir, "terraform.tfstate"))
	return err == nil
}

func parseTailPath(fullPath, prefix string) (string, bool) {
	if !strings.HasPrefix(fullPath, prefix) {
		return "", false
	}
	tail := strings.TrimPrefix(fullPath, prefix)
	tail = strings.TrimPrefix(tail, "/")
	tail = strings.TrimSpace(tail)
	if tail == "" {
		return "", false
	}
	return filepath.ToSlash(tail), true
}

func resolveScenarioFile(root, relPath string) (string, error) {
	decodedRel, err := url.PathUnescape(relPath)
	if err != nil {
		return "", fmt.Errorf("invalid scenario path encoding")
	}
	cleanRel := filepath.Clean(decodedRel)
	if cleanRel == "." || cleanRel == "" {
		return "", fmt.Errorf("invalid scenario path")
	}
	if strings.Contains(cleanRel, "..") {
		return "", fmt.Errorf("path traversal is not allowed")
	}

	filePath := cleanRel
	if filepath.Ext(filePath) == "" {
		filePath += ".yaml"
	}

	absRoot, err := filepath.Abs(root)
	if err != nil {
		return "", fmt.Errorf("resolve scenarios root: %w", err)
	}
	absTarget, err := filepath.Abs(filepath.Join(absRoot, filePath))
	if err != nil {
		return "", fmt.Errorf("resolve scenario path: %w", err)
	}
	rel, err := filepath.Rel(absRoot, absTarget)
	if err != nil {
		return "", fmt.Errorf("resolve scenario relative path: %w", err)
	}
	if strings.HasPrefix(rel, "..") || rel == "." {
		return "", fmt.Errorf("path traversal is not allowed")
	}
	return absTarget, nil
}

func loadScenarioFile(path string, schemaCandidates []string) (scenario.Scenario, []byte, error) {
	payload, err := os.ReadFile(path)
	if err != nil {
		return scenario.Scenario{}, nil, err
	}
	var lastErr error
	for _, schemaPath := range schemaCandidates {
		if _, err := os.Stat(schemaPath); err != nil {
			lastErr = err
			continue
		}
		sc, err := scenario.LoadWithSchema(path, schemaPath)
		if err == nil {
			return sc, payload, nil
		}
		return scenario.Scenario{}, nil, err
	}
	if lastErr != nil {
		return scenario.Scenario{}, nil, fmt.Errorf("locate scenario schema: %w", lastErr)
	}
	return scenario.Scenario{}, nil, fmt.Errorf("locate scenario schema: no schema paths configured")
}
