package stacks

import (
	"encoding/json"
	"fmt"
	"os/exec"
	"regexp"
	"strings"
)

// ExtractedCreds is the result of reading an app's auto-generated first-login credential from a file
// inside its container (driven by Metadata.InitialCreds). Available is false when the spec is present
// but the credential can't be read right now (container down, file gone) — the UI then hides the card
// rather than showing an error.
type ExtractedCreds struct {
	Available bool   `json:"available"`
	Username  string `json:"username,omitempty"`
	Password  string `json:"password,omitempty"`
	Note      string `json:"note,omitempty"`
}

// ReadInitialCredentials reads and parses the configured first-login credential file from the deployed
// stack's container. Returns (nil, nil) when the stack has no InitialCreds spec (nothing to do); a
// non-Available result when the spec exists but the file can't be read yet. The file path comes from
// the catalog (trusted) and is passed to `docker exec … cat` as a single arg (no shell), so a path
// with spaces/odd chars can't break out.
func (m *Manager) ReadInitialCredentials(stackName string) (*ExtractedCreds, error) {
	stack, ok := m.GetStack(stackName)
	if !ok {
		return nil, fmt.Errorf("stack %q not found", stackName)
	}
	spec := stack.Meta.InitialCreds
	if spec == nil || spec.File == "" {
		return nil, nil // no spec — nothing to extract
	}

	// Resolve which container to read from: explicit override, else the stack's main container.
	container := spec.Container
	if container == "" {
		container = findProbeContainer(stack.Name, stack.Containers)
	}
	if container == "" {
		if m.isDebug() {
			m.logger.Printf("[DEBUG] [stacks] initial-creds %s: no running container to read from", stackName)
		}
		return &ExtractedCreds{Available: false}, nil
	}

	out, err := exec.Command("docker", "exec", container, "cat", spec.File).Output()
	if err != nil {
		// File missing / container not exec-able yet — expected during early boot or after the
		// customer deletes the file. Not an error worth surfacing; hide the card.
		if m.isDebug() {
			m.logger.Printf("[DEBUG] [stacks] initial-creds %s: cat %s in %s failed: %v", stackName, spec.File, container, err)
		}
		return &ExtractedCreds{Available: false}, nil
	}
	content := string(out)
	if strings.TrimSpace(content) == "" {
		return &ExtractedCreds{Available: false}, nil
	}

	creds, perr := parseInitialCreds(spec, content)
	if perr != nil {
		m.logger.Printf("[WARN] [stacks] initial-creds %s: parse %s (%s) failed: %v", stackName, spec.File, spec.Format, perr)
		return &ExtractedCreds{Available: false}, nil
	}
	if creds.Password == "" {
		// A spec that yields no password is useless — log so a bad catalog spec is noticed.
		m.logger.Printf("[WARN] [stacks] initial-creds %s: no password extracted from %s (format=%s)", stackName, spec.File, spec.Format)
		return &ExtractedCreds{Available: false}, nil
	}
	creds.Note = spec.Note
	creds.Available = true
	if m.isDebug() {
		m.logger.Printf("[DEBUG] [stacks] initial-creds %s: extracted username=%q password=<redacted len=%d>", stackName, creds.Username, len(creds.Password))
	}
	return creds, nil
}

// parseInitialCreds is the pure parsing core (unit-testable without docker): given the spec and the
// raw file content, it pulls out username/password per the declared format.
func parseInitialCreds(spec *InitialCredentials, content string) (*ExtractedCreds, error) {
	switch strings.ToLower(spec.Format) {
	case "json":
		var obj map[string]any
		if err := json.Unmarshal([]byte(content), &obj); err != nil {
			return nil, fmt.Errorf("invalid JSON: %w", err)
		}
		pwKey := spec.PasswordKey
		if pwKey == "" {
			pwKey = "password"
		}
		return &ExtractedCreds{
			Username: jsonStr(obj, spec.UsernameKey),
			Password: jsonStr(obj, pwKey),
		}, nil

	case "regex":
		if spec.PasswordPattern == "" {
			return nil, fmt.Errorf("regex format requires password_pattern")
		}
		pw, err := firstSubmatch(spec.PasswordPattern, content)
		if err != nil {
			return nil, err
		}
		user := ""
		if spec.UsernamePattern != "" {
			user, _ = firstSubmatch(spec.UsernamePattern, content)
		}
		return &ExtractedCreds{Username: user, Password: pw}, nil

	case "plain", "":
		// Whole file (trimmed) is the password; no username.
		return &ExtractedCreds{Password: strings.TrimSpace(content)}, nil

	default:
		return nil, fmt.Errorf("unknown format %q (want json|regex|plain)", spec.Format)
	}
}

// jsonStr fetches a string value from a decoded JSON object; empty key or non-string → "".
func jsonStr(obj map[string]any, key string) string {
	if key == "" {
		return ""
	}
	if v, ok := obj[key]; ok {
		if s, ok := v.(string); ok {
			return s
		}
	}
	return ""
}

// firstSubmatch compiles pattern and returns its first capture group against content.
func firstSubmatch(pattern, content string) (string, error) {
	re, err := regexp.Compile(pattern)
	if err != nil {
		return "", fmt.Errorf("bad pattern %q: %w", pattern, err)
	}
	mm := re.FindStringSubmatch(content)
	if len(mm) < 2 {
		return "", nil // no match / no capture group — treated as "not found"
	}
	return strings.TrimSpace(mm[1]), nil
}
