package agent

import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"net/url"
	"os"
	"path/filepath"
	"regexp"
	"strings"
	"sync"
	"time"

	"github.com/tidbcloud/AgentTeam/desktop/internal/agent/drivercontract"
)

const (
	openCodeRuntimeDebugStringMaxBytes = 512
	openCodeRuntimeDebugLineMaxBytes   = 64 * 1024
	openCodeServeLogMaxBytes           = 10 * 1024 * 1024
	openCodeRuntimeDebugLogMaxBytes    = 10 * 1024 * 1024
)

type openCodeLaunchDiagnostic struct {
	runtimeType         string
	binary              string
	workDir             string
	configDir           string
	modelsPath          string
	modelsExists        bool
	modelsSize          int64
	model               string
	smallModel          string
	providerID          string
	providerName        string
	providerBaseURL     string
	serveLogPath        string
	runtimeDebugLogPath string
	offlineFlags        []string
	isolationFlags      []string
}

type openCodeRuntimeDebugEntry struct {
	SchemaVersion int            `json:"schemaVersion"`
	Timestamp     time.Time      `json:"timestamp"`
	Runtime       string         `json:"runtime"`
	Event         string         `json:"event"`
	AgentID       string         `json:"agentId,omitempty"`
	LaunchID      string         `json:"launchId,omitempty"`
	SessionID     string         `json:"sessionId,omitempty"`
	Metadata      map[string]any `json:"metadata,omitempty"`
}

func openCodeLaunchDiagnostics(cfg drivercontract.LaunchConfig, env []string) openCodeLaunchDiagnostic {
	envMap := openCodeEnvMap(env)
	d := openCodeLaunchDiagnostic{
		runtimeType:    string(cfg.RuntimeType),
		binary:         cfg.Binary,
		workDir:        cfg.WorkDir,
		configDir:      envMap["OPENCODE_CONFIG_DIR"],
		modelsPath:     envMap["OPENCODE_MODELS_PATH"],
		model:          cfg.Model,
		smallModel:     cfg.SmallModel,
		serveLogPath:   openCodeServeLogPath(cfg.WorkDir),
		offlineFlags:   presentOpenCodeEnvFlags(envMap, "OPENCODE_DISABLE_AUTOUPDATE", "OPENCODE_DISABLE_CLAUDE_CODE_PROMPT", "OPENCODE_DISABLE_LSP_DOWNLOAD", "OPENCODE_DISABLE_MODELS_FETCH"),
		isolationFlags: presentOpenCodeEnvFlags(envMap, "OPENCODE_TEST_HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "XDG_STATE_HOME", "OPENCODE_DISABLE_PROJECT_CONFIG", "OPENCODE_DISABLE_DEFAULT_PLUGINS", "OPENCODE_DISABLE_EXTERNAL_SKILLS", "OPENCODE_DISABLE_CLAUDE_CODE_SKILLS"),
	}
	if cfg.RuntimeDebugEnabled {
		d.runtimeDebugLogPath = openCodeRuntimeDebugLogPath(cfg.WorkDir)
	}
	if cfg.Provider != nil {
		d.providerID = cfg.Provider.ID
		d.providerName = cfg.Provider.Name
		d.providerBaseURL = cfg.Provider.BaseURL
	}
	if d.modelsPath != "" {
		if info, err := os.Stat(d.modelsPath); err == nil && !info.IsDir() {
			d.modelsExists = true
			d.modelsSize = info.Size()
		}
	}
	return d
}

func (d openCodeLaunchDiagnostic) logAttrs() []any {
	return []any{
		"runtime_type", d.runtimeType,
		"binary", d.binary,
		"workdir", d.workDir,
		"config_dir", d.configDir,
		"models_path", d.modelsPath,
		"models_exists", d.modelsExists,
		"models_size", d.modelsSize,
		"model", d.model,
		"small_model_configured", strings.TrimSpace(d.smallModel) != "",
		"provider_id", d.providerID,
		"provider_name", d.providerName,
		"provider_base_url", sanitizeOpenCodeLogURL(d.providerBaseURL),
		"serve_log_path", d.serveLogPath,
		"runtime_debug_log_path", d.runtimeDebugLogPath,
		"offline_flags", strings.Join(d.offlineFlags, ","),
		"isolation_flags", strings.Join(d.isolationFlags, ","),
		"storage_scope", openCodeStorageScope(d.isolationFlags),
	}
}

func openCodeServeLogPath(workDir string) string {
	workDir = strings.TrimSpace(workDir)
	if workDir == "" {
		return ""
	}
	return filepath.Join(workDir, "diagnostics", "opencode-serve.log")
}

func openCodeRuntimeDebugLogPath(workDir string) string {
	workDir = strings.TrimSpace(workDir)
	if workDir == "" {
		return ""
	}
	return filepath.Join(workDir, "diagnostics", "opencode-runtime-debug.jsonl")
}

type openCodeDiagnosticLogFile struct {
	mu             sync.Mutex
	writeErrorOnce sync.Once
	file           *os.File
	path           string
	size           int64
	maxBytes       int64
}

func openOpenCodeDiagnosticLogFile(path string, maxBytes int64) (*openCodeDiagnosticLogFile, error) {
	path = strings.TrimSpace(path)
	if path == "" {
		return nil, fmt.Errorf("empty diagnostics log path")
	}
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		return nil, fmt.Errorf("create diagnostics log dir: %w", err)
	}
	if err := rotateOpenCodeDiagnosticLogFile(path, maxBytes); err != nil {
		return nil, err
	}
	file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
	if err != nil {
		return nil, fmt.Errorf("open diagnostics log file: %w", err)
	}
	if err := file.Chmod(0o600); err != nil {
		_ = file.Close()
		return nil, fmt.Errorf("restrict diagnostics log permissions: %w", err)
	}
	size := int64(0)
	if info, statErr := file.Stat(); statErr == nil && !info.IsDir() {
		size = info.Size()
	}
	return &openCodeDiagnosticLogFile{file: file, path: path, size: size, maxBytes: maxBytes}, nil
}

type openCodeDiagnosticLogs struct {
	serve        *openCodeDiagnosticLogFile
	runtimeDebug *openCodeDiagnosticLogFile
}

func (d *OpenCodeDriver) attachOpenCodeDiagnosticLogs(cfg drivercontract.LaunchConfig, stderr, stdout *capturingPrefixWriter) (*openCodeDiagnosticLogs, error) {
	serveLog, err := openOpenCodeDiagnosticLogFile(openCodeServeLogPath(cfg.WorkDir), openCodeServeLogMaxBytes)
	if err != nil {
		return nil, err
	}
	d.mu.Lock()
	launchID := strings.TrimSpace(d.runtimeDebugLaunchID)
	if launchID == "" {
		launchID = newOpenCodeLaunchID()
		d.runtimeDebugLaunchID = launchID
	}
	d.mu.Unlock()
	if stderr != nil {
		stderr.onLine = func(line string) {
			serveLog.WriteStream(launchID, "stderr", line)
		}
	}
	if stdout != nil {
		stdout.onLine = func(line string) {
			serveLog.WriteStream(launchID, "stdout", line)
		}
	}

	var runtimeDebugLog *openCodeDiagnosticLogFile
	if cfg.RuntimeDebugEnabled {
		var debugErr error
		runtimeDebugLog, debugErr = openOpenCodeDiagnosticLogFile(openCodeRuntimeDebugLogPath(cfg.WorkDir), openCodeRuntimeDebugLogMaxBytes)
		if debugErr != nil {
			// Runtime diagnostics are observational and must never prevent an
			// otherwise healthy agent from starting.
			slog.Warn("failed to open opencode runtime diagnostics log",
				"agent_id", strings.TrimSpace(cfg.AgentID),
				"path", openCodeRuntimeDebugLogPath(cfg.WorkDir),
				"error", debugErr,
			)
			runtimeDebugLog = nil
		}
	}
	d.mu.Lock()
	d.serveLogPath = serveLog.Path()
	d.runtimeDebugLog = runtimeDebugLog
	d.runtimeDebugAgentID = strings.TrimSpace(cfg.AgentID)
	d.mu.Unlock()
	return &openCodeDiagnosticLogs{serve: serveLog, runtimeDebug: runtimeDebugLog}, nil
}

func rotateOpenCodeDiagnosticLogFile(path string, maxBytes int64) error {
	if maxBytes <= 0 {
		return nil
	}
	info, err := os.Stat(path)
	if err != nil {
		if os.IsNotExist(err) {
			return nil
		}
		return fmt.Errorf("stat diagnostics log file: %w", err)
	}
	if info.IsDir() || info.Size() < maxBytes {
		return nil
	}
	rotated := path + ".1"
	if err := os.Remove(rotated); err != nil && !os.IsNotExist(err) {
		return fmt.Errorf("remove rotated diagnostics log file: %w", err)
	}
	if err := os.Rename(path, rotated); err != nil {
		return fmt.Errorf("rotate diagnostics log file: %w", err)
	}
	return nil
}

func (l *openCodeDiagnosticLogFile) Path() string {
	if l == nil {
		return ""
	}
	return l.path
}

func (l *openCodeDiagnosticLogFile) WriteStream(launchID, stream, line string) {
	if l == nil {
		return
	}
	launchID = strings.TrimSpace(launchID)
	stream = strings.TrimSpace(stream)
	if stream == "" {
		stream = "log"
	}
	_, _ = fmt.Fprintf(
		l,
		"%s [launch:%s] [%s] %s\n",
		time.Now().UTC().Format(time.RFC3339Nano),
		launchID,
		stream,
		scrubOpenCodeLogText(line),
	)
}

func (l *openCodeDiagnosticLogFile) WriteJSONLine(value any) error {
	if l == nil {
		return nil
	}
	data, err := json.Marshal(value)
	if err != nil {
		return fmt.Errorf("encode diagnostics JSON line: %w", err)
	}
	if len(data) > openCodeRuntimeDebugLineMaxBytes {
		return fmt.Errorf("diagnostics JSON line exceeds %d bytes", openCodeRuntimeDebugLineMaxBytes)
	}
	data = append(data, '\n')
	_, err = l.Write(data)
	return err
}

func (l *openCodeDiagnosticLogFile) logWriteErrorOnce(message string, err error) {
	if l == nil || err == nil {
		return
	}
	l.writeErrorOnce.Do(func() {
		slog.Warn("failed to write opencode runtime diagnostic",
			"message", strings.TrimSpace(message),
			"path", l.Path(),
			"error", err,
		)
	})
}

func (l *openCodeDiagnosticLogFile) Write(p []byte) (int, error) {
	if l == nil {
		return len(p), nil
	}
	l.mu.Lock()
	defer l.mu.Unlock()
	if l.file == nil {
		return len(p), nil
	}
	if err := l.rotateLocked(int64(len(p))); err != nil {
		return 0, err
	}
	if _, err := l.file.Write(p); err != nil {
		return 0, err
	}
	l.size += int64(len(p))
	return len(p), nil
}

func (l *openCodeDiagnosticLogFile) rotateLocked(nextBytes int64) error {
	if l == nil || l.file == nil || l.maxBytes <= 0 || l.path == "" {
		return nil
	}
	if l.size <= 0 || l.size+nextBytes < l.maxBytes {
		return nil
	}
	if err := l.file.Close(); err != nil {
		return fmt.Errorf("close diagnostics log before rotation: %w", err)
	}
	l.file = nil
	rotated := l.path + ".1"
	if err := os.Remove(rotated); err != nil && !os.IsNotExist(err) {
		return fmt.Errorf("remove rotated diagnostics log file: %w", err)
	}
	if err := os.Rename(l.path, rotated); err != nil && !os.IsNotExist(err) {
		return fmt.Errorf("rotate diagnostics log file: %w", err)
	}
	file, err := os.OpenFile(l.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
	if err != nil {
		return fmt.Errorf("open diagnostics log file after rotation: %w", err)
	}
	l.file = file
	l.size = 0
	return nil
}

func (l *openCodeDiagnosticLogFile) Close() error {
	if l == nil {
		return nil
	}
	l.mu.Lock()
	defer l.mu.Unlock()
	if l.file == nil {
		return nil
	}
	file := l.file
	l.file = nil
	return file.Close()
}

func (l *openCodeDiagnosticLogs) Close() error {
	if l == nil {
		return nil
	}
	return errors.Join(l.runtimeDebug.Close(), l.serve.Close())
}

var _ io.WriteCloser = (*openCodeDiagnosticLogFile)(nil)

func sanitizeOpenCodeLogURL(raw string) string {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return ""
	}
	parsed, err := url.Parse(raw)
	if err != nil {
		return "<invalid-url>"
	}
	parsed.User = nil
	parsed.RawQuery = ""
	parsed.ForceQuery = false
	parsed.Fragment = ""
	return parsed.String()
}

func openCodeEnvMap(env []string) map[string]string {
	result := make(map[string]string, len(env))
	for _, entry := range env {
		key, value, ok := strings.Cut(entry, "=")
		if ok {
			result[key] = value
		}
	}
	return result
}

func presentOpenCodeEnvFlags(env map[string]string, keys ...string) []string {
	result := make([]string, 0, len(keys))
	for _, key := range keys {
		if _, ok := env[key]; ok {
			result = append(result, key)
		}
	}
	return result
}

func openCodeStorageScope(isolationFlags []string) string {
	for _, flag := range isolationFlags {
		switch strings.TrimSpace(flag) {
		case "OPENCODE_TEST_HOME", "XDG_DATA_HOME":
			return "isolated"
		}
	}
	return "runtime_default"
}

func scrubOpenCodeLogText(text string) string {
	text = strings.TrimSpace(text)
	if text == "" {
		return ""
	}
	for _, rule := range openCodeLogScrubRules {
		text = rule.pattern.ReplaceAllString(text, rule.replacement)
	}
	return text
}

// ScrubDiagnosticLogText applies the credential and payload redaction used by
// OpenCode diagnostics. Diagnostic exports reuse this as defense in depth.
func ScrubDiagnosticLogText(text string) string {
	return scrubOpenCodeLogText(text)
}

func scrubOpenCodeDebugMetadata(metadata map[string]any) map[string]any {
	if len(metadata) == 0 {
		return nil
	}
	out := make(map[string]any, len(metadata))
	for key, value := range metadata {
		out[key] = scrubOpenCodeDebugValue(key, value)
	}
	return out
}

func openCodeRuntimeErrorDiagnostic(raw json.RawMessage, message string) map[string]any {
	messageBytes, messageFingerprint := runtimeErrorDiagnosticFingerprint(message)
	out := map[string]any{
		"error_message_bytes":       messageBytes,
		"error_message_fingerprint": messageFingerprint,
	}
	if code := classifyUserFacingRuntimeErrorCode("opencode", message); code != "" {
		out["runtime_error_code"] = code
	}

	var root map[string]json.RawMessage
	if err := json.Unmarshal(raw, &root); err != nil {
		out["error_shape"] = "invalid_json"
		return out
	}
	out["error_shape"] = "json"
	openCodeCollectSafeRuntimeErrorFields(root, out, 0)
	return out
}

func openCodeCollectSafeRuntimeErrorFields(object map[string]json.RawMessage, out map[string]any, depth int) {
	if depth > maxRuntimeErrorDetailDepth {
		return
	}
	for _, field := range []struct {
		input  string
		output string
	}{
		{input: "name", output: "error_name"},
		{input: "type", output: "error_type"},
		{input: "code", output: "cause_code"},
		{input: "errno", output: "cause_errno"},
		{input: "sqliteErrorCode", output: "sqlite_error_code"},
		{input: "sqlite_errorcode", output: "sqlite_error_code"},
		{input: "sqlite_error_code", output: "sqlite_error_code"},
		{input: "sqliteErrorName", output: "sqlite_error_name"},
		{input: "sqlite_errorname", output: "sqlite_error_name"},
		{input: "sqlite_error_name", output: "sqlite_error_name"},
		{input: "httpStatusCode", output: "http_status_code"},
		{input: "http_status_code", output: "http_status_code"},
		{input: "statusCode", output: "http_status_code"},
		{input: "status_code", output: "http_status_code"},
	} {
		if _, exists := out[field.output]; exists {
			continue
		}
		raw, ok := runtimeErrorObjectValue(object, field.input)
		if !ok {
			continue
		}
		if scalar, ok := openCodeSafeRuntimeErrorScalar(raw, field.output); ok {
			out[field.output] = scalar
		}
	}
	for _, key := range runtimeErrorNestedDetailKeys {
		raw, ok := runtimeErrorObjectValue(object, key)
		if !ok {
			continue
		}
		var nested map[string]json.RawMessage
		if json.Unmarshal(raw, &nested) == nil {
			openCodeCollectSafeRuntimeErrorFields(nested, out, depth+1)
		}
	}
}

func openCodeSafeRuntimeErrorScalar(raw json.RawMessage, key string) (any, bool) {
	var text string
	if json.Unmarshal(raw, &text) == nil {
		return scrubOpenCodeDebugStringForKey(key, text), true
	}
	var number json.Number
	if json.Unmarshal(raw, &number) == nil {
		return number, true
	}
	var boolean bool
	if json.Unmarshal(raw, &boolean) == nil {
		return boolean, true
	}
	return nil, false
}

func scrubOpenCodeDebugLogAttrs(attrs []any) []any {
	if len(attrs) == 0 {
		return nil
	}
	out := make([]any, 0, len(attrs))
	for i := 0; i < len(attrs); i += 2 {
		key := fmt.Sprint(attrs[i])
		out = append(out, attrs[i])
		if i+1 >= len(attrs) {
			continue
		}
		out = append(out, scrubOpenCodeDebugValue(key, attrs[i+1]))
	}
	return out
}

func scrubOpenCodeDebugValue(key string, value any) any {
	if value == nil {
		return nil
	}
	if openCodeDebugMetadataKeySensitive(key) {
		return "<redacted>"
	}
	switch typed := value.(type) {
	case error:
		return scrubOpenCodeDebugStringForKey(key, typed.Error())
	case string:
		return scrubOpenCodeDebugStringForKey(key, typed)
	case []string:
		out := make([]string, 0, len(typed))
		for _, item := range typed {
			out = append(out, scrubOpenCodeDebugStringForKey(key, item))
		}
		return out
	case []any:
		out := make([]any, 0, len(typed))
		for _, item := range typed {
			out = append(out, scrubOpenCodeDebugValue(key, item))
		}
		return out
	case map[string]any:
		return scrubOpenCodeDebugMetadata(typed)
	case map[string]string:
		out := make(map[string]any, len(typed))
		for childKey, childValue := range typed {
			out[childKey] = scrubOpenCodeDebugValue(childKey, childValue)
		}
		return out
	case fmt.Stringer:
		return scrubOpenCodeDebugStringForKey(key, typed.String())
	default:
		return value
	}
}

func scrubOpenCodeDebugStringForKey(key string, value string) string {
	value = scrubOpenCodeLogText(value)
	if strings.Contains(strings.ToLower(key), "url") {
		value = sanitizeOpenCodeLogURL(value)
	}
	if len(value) <= openCodeRuntimeDebugStringMaxBytes {
		return value
	}
	return value[:openCodeRuntimeDebugStringMaxBytes] + "…"
}

func openCodeDebugMetadataKeySensitive(key string) bool {
	lower := strings.ToLower(strings.TrimSpace(key))
	if lower == "" {
		return false
	}
	return strings.Contains(lower, "key") ||
		strings.Contains(lower, "token") ||
		strings.Contains(lower, "secret") ||
		strings.Contains(lower, "password") ||
		strings.Contains(lower, "authorization") ||
		strings.Contains(lower, "credential")
}

var openCodeLogScrubRules = []struct {
	pattern     *regexp.Regexp
	replacement string
}{
	{pattern: regexp.MustCompile(`(?i)("(?:apiKey|api_key|x-api-key|accessToken|refreshToken|token|password|secret|authorization|OPENCODE_CONFIG_CONTENT|OPENCODE_SERVER_PASSWORD|AGENTTEAM_EXEC_TOKEN_FILE)"\s*:\s*)"[^"]*"`), replacement: `${1}"<redacted>"`},
	{pattern: regexp.MustCompile(`(?im)(OPENCODE_CONFIG_CONTENT)(\s*[:=]\s*)[^\r\n]+`), replacement: `${1}${2}<redacted>`},
	{pattern: regexp.MustCompile(`(?im)(OPENCODE_SERVER_PASSWORD)(\s*[:=]\s*)[^\s\r\n,}]+`), replacement: `${1}${2}<redacted>`},
	{pattern: regexp.MustCompile(`(?im)(AGENTTEAM_EXEC_TOKEN_FILE)(\s*[:=]\s*)[^\s\r\n,}]+`), replacement: `${1}${2}<redacted>`},
	{pattern: regexp.MustCompile(`(?im)(Authorization)(\s*[:=]\s*)(Bearer\s+[^\s\r\n,}]+|[^\s\r\n,}]+)`), replacement: `${1}${2}<redacted>`},
	{pattern: regexp.MustCompile(`(?im)(apiKey|api_key|x-api-key|token)(\s*[:=]\s*)([^\s,}]+|"[^"]*")`), replacement: `${1}${2}<redacted>`},
	// Database errors can include serialized tool inputs after "params:".
	// Preserve the query/correlation header while keeping message and tool
	// bodies out of the local diagnostics bundle.
	{pattern: regexp.MustCompile(`(?im)^params:\s*.*$`), replacement: `params: <redacted>`},
	{pattern: regexp.MustCompile(`(?i)\\nparams:\s*.*$`), replacement: `\nparams: <redacted>`},
}
