package agent

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strings"
	"time"

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

const (
	openCodeResumeBootstrapTimeout        = 5 * time.Second
	openCodeResumeHighWaterPageSize       = 100
	openCodeResumeHighWaterPageLimitBytes = 16 * 1024 * 1024
	openCodeResumeHighWaterMaxPages       = 10_000
)

type openCodeResumeMessage struct {
	Info struct {
		ID       string          `json:"id"`
		Role     string          `json:"role"`
		ParentID string          `json:"parentID"`
		Finish   string          `json:"finish"`
		Summary  bool            `json:"summary"`
		Error    json.RawMessage `json:"error"`
		Time     struct {
			Completed *int64 `json:"completed"`
		} `json:"time"`
	} `json:"info"`
	Parts []openCodeResumePart `json:"parts"`
}

type openCodeResumePart struct {
	Type        string  `json:"type"`
	TailStartID *string `json:"tail_start_id"`
	Auto        *bool   `json:"auto"`
	Metadata    struct {
		ProviderExecuted bool `json:"providerExecuted"`
	} `json:"metadata"`
	State struct {
		Status   string `json:"status"`
		Metadata struct {
			Interrupted bool `json:"interrupted"`
		} `json:"metadata"`
	} `json:"state"`
}

// openCodeCompactedResumeFilter mirrors the membership-producing scan in
// MessageV2.filterCompacted. OpenCode feeds that function messages newest
// first. Once a completed compaction boundary is found, only the explicitly
// retained tail is included; older raw rows are outside the effective prompt
// history even if clock rollback gave one of them a lexicographically larger
// message id.
//
// filterCompacted performs a final reorder for model consumption, but
// MessageV2.latest derives its user/assistant bindings by max id. Reordering
// therefore cannot affect the resume candidate and is intentionally omitted.
type openCodeCompactedResumeFilter struct {
	completedCompactions map[string]struct{}
	retainUntilID        string
	done                 bool
}

func (f *openCodeCompactedResumeFilter) includeNewestFirst(message openCodeResumeMessage) bool {
	if f.done {
		return false
	}

	messageID := strings.TrimSpace(message.Info.ID)
	if f.retainUntilID != "" {
		if messageID == f.retainUntilID {
			f.done = true
		}
		return true
	}

	role := strings.TrimSpace(message.Info.Role)
	if role == "user" {
		if _, completed := f.completedCompactions[messageID]; completed {
			part, ok := openCodeResumeCompactionPart(message)
			if !ok {
				return true
			}
			tailStartID := ""
			if part.TailStartID != nil {
				tailStartID = strings.TrimSpace(*part.TailStartID)
			}
			// OpenCode stops at a completed compaction with no retained tail.
			if tailStartID == "" {
				f.done = true
				return true
			}
			f.retainUntilID = tailStartID
			if messageID == tailStartID {
				f.done = true
			}
			return true
		}
		return true
	}

	if role == "assistant" &&
		message.Info.Summary &&
		strings.TrimSpace(message.Info.Finish) != "" &&
		!openCodeResumeMessageHasError(message) {
		if f.completedCompactions == nil {
			f.completedCompactions = make(map[string]struct{})
		}
		parentID := strings.TrimSpace(message.Info.ParentID)
		if parentID != "" {
			f.completedCompactions[parentID] = struct{}{}
		}
	}
	return true
}

func openCodeResumeCompactionPart(message openCodeResumeMessage) (openCodeResumePart, bool) {
	for _, part := range message.Parts {
		if strings.TrimSpace(part.Type) == "compaction" {
			return part, true
		}
	}
	return openCodeResumePart{}, false
}

func openCodeResumeMessageHasError(message openCodeResumeMessage) bool {
	errorJSON := strings.TrimSpace(string(message.Info.Error))
	return errorJSON != "" && errorJSON != "null"
}

// refreshOpenCodeResumedSessionMessageHighWater scans every paginated message
// row before AgentTeam supplies an external messageID to a resumed session.
// OpenCode's own ID generator can move backwards with the wall clock and its
// page endpoint orders by time_created, so reading only limit=1 cannot establish
// the required high-water. Resume selection is separate: it mirrors
// filterCompacted membership before applying MessageV2.latest, so raw rows
// outside the effective post-compaction history cannot be selected.
func (d *OpenCodeDriver) refreshOpenCodeResumedSessionMessageHighWater(ctx context.Context, sessionID string) error {
	_, _, _, err := d.scanOpenCodeResumedSessionMessages(ctx, sessionID)
	return err
}

func (d *OpenCodeDriver) scanOpenCodeResumedSessionMessages(
	ctx context.Context,
	sessionID string,
) (openCodeResumeMessage, bool, openCodeResumeMessage, error) {
	trimmedSessionID := strings.TrimSpace(sessionID)
	if trimmedSessionID == "" {
		return openCodeResumeMessage{}, false, openCodeResumeMessage{}, fmt.Errorf("resume high-water refresh requires session id")
	}

	cursor := ""
	var latestByID openCodeResumeMessage
	hasLatestByID := false
	var maxUserMessage openCodeResumeMessage
	var compactedFilter openCodeCompactedResumeFilter
	for page := 0; page < openCodeResumeHighWaterMaxPages; page++ {
		endpoint := fmt.Sprintf(
			"/session/%s/message?limit=%d",
			trimmedSessionID,
			openCodeResumeHighWaterPageSize,
		)
		if cursor != "" {
			endpoint += "&before=" + url.QueryEscape(cursor)
		}
		req, err := d.newRequest(ctx, http.MethodGet, endpoint, nil)
		if err != nil {
			return openCodeResumeMessage{}, false, openCodeResumeMessage{}, fmt.Errorf("build resumed session high-water request: %w", err)
		}
		d.addDirectoryHeader(req, d.getWorkDir())

		d.mu.Lock()
		client := d.httpClient
		d.mu.Unlock()
		if client == nil {
			return openCodeResumeMessage{}, false, openCodeResumeMessage{}, fmt.Errorf("resumed session high-water refresh requires HTTP client")
		}
		resp, err := client.Do(req)
		if err != nil {
			return openCodeResumeMessage{}, false, openCodeResumeMessage{}, fmt.Errorf("get resumed session high-water page: %w", err)
		}
		if resp.StatusCode != http.StatusOK {
			bodyBytes, _ := io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
			_ = resp.Body.Close()
			return openCodeResumeMessage{}, false, openCodeResumeMessage{}, fmt.Errorf(
				"get resumed session high-water page: status %d (response body bytes read: %d)",
				resp.StatusCode,
				bodyBytes,
			)
		}
		body, readErr := io.ReadAll(io.LimitReader(resp.Body, openCodeResumeHighWaterPageLimitBytes+1))
		nextCursor := strings.TrimSpace(resp.Header.Get("X-Next-Cursor"))
		closeErr := resp.Body.Close()
		if readErr != nil {
			return openCodeResumeMessage{}, false, openCodeResumeMessage{}, fmt.Errorf("read resumed session high-water page: %w", readErr)
		}
		if closeErr != nil {
			return openCodeResumeMessage{}, false, openCodeResumeMessage{}, fmt.Errorf("close resumed session high-water page: %w", closeErr)
		}
		if len(body) > openCodeResumeHighWaterPageLimitBytes {
			return openCodeResumeMessage{}, false, openCodeResumeMessage{}, fmt.Errorf(
				"resumed session high-water page exceeds %d bytes",
				openCodeResumeHighWaterPageLimitBytes,
			)
		}
		var messages []openCodeResumeMessage
		if err := json.Unmarshal(body, &messages); err != nil {
			return openCodeResumeMessage{}, false, openCodeResumeMessage{}, fmt.Errorf("decode resumed session high-water page: %w", err)
		}
		for _, message := range messages {
			messageID := strings.TrimSpace(message.Info.ID)
			if messageID == "" {
				return openCodeResumeMessage{}, false, openCodeResumeMessage{}, fmt.Errorf("resumed session contains message with empty id")
			}
			if _, ok := openCodeMessageIDEncoded(messageID); !ok {
				// OpenCode accepts any caller-supplied string beginning with
				// "msg", but MessageV2.latest compares ids lexicographically.
				// AgentTeam cannot allocate a proven-greater standard ascending id
				// when an external/non-standard id is present, so fail closed
				// instead of risking a prompt that runAgentTeam silently ignores.
				return openCodeResumeMessage{}, false, openCodeResumeMessage{}, fmt.Errorf(
					"resumed session contains unsupported non-ascending message id %q",
					messageID,
				)
			}
			d.observeOpenCodeMessageID(messageID)
			d.observeOpenCodeMessageID(message.Info.ParentID)
		}
		// MessageV2.page returns each page oldest-first, while
		// MessageV2.stream reverses every page and feeds filterCompacted
		// newest-first across pages. Preserve that exact scan order.
		for i := len(messages) - 1; i >= 0; i-- {
			message := messages[i]
			if !compactedFilter.includeNewestFirst(message) {
				continue
			}
			messageID := strings.TrimSpace(message.Info.ID)
			// Match MessageV2.latest rather than the HTTP page order. The page
			// is time_created-based, so wall-clock rollback can put the
			// lexicographic maximum on an older included page.
			if !hasLatestByID || messageID > strings.TrimSpace(latestByID.Info.ID) {
				latestByID = message
				hasLatestByID = true
			}
			if strings.TrimSpace(message.Info.Role) == "user" &&
				messageID > strings.TrimSpace(maxUserMessage.Info.ID) {
				maxUserMessage = message
			}
		}
		if nextCursor == "" {
			return latestByID, hasLatestByID, maxUserMessage, nil
		}
		if nextCursor == cursor {
			return openCodeResumeMessage{}, false, openCodeResumeMessage{}, fmt.Errorf("resumed session high-water cursor did not advance")
		}
		cursor = nextCursor
	}
	return openCodeResumeMessage{}, false, openCodeResumeMessage{}, fmt.Errorf(
		"resumed session high-water scan exceeded %d pages",
		openCodeResumeHighWaterMaxPages,
	)
}

func (d *OpenCodeDriver) recordSessionResumeFallback(reason, operation string) {
	d.mu.Lock()
	defer d.mu.Unlock()
	if d.sessionResumeFallback != nil {
		return
	}
	d.sessionResumeFallback = newSessionResumeFallback(reason, operation)
}

// createOrResumeSession creates a new session or resumes an existing one.
func (d *OpenCodeDriver) createOrResumeSession(ctx context.Context, cfg drivercontract.LaunchConfig) (string, bool, bool, error) {
	if strings.TrimSpace(cfg.SessionID) != "" {
		sessionID := strings.TrimSpace(cfg.SessionID)
		d.emitRuntimeDebug("opencode session resume check started", map[string]any{"session_id": sessionID})
		req, err := d.newRequest(ctx, http.MethodGet, "/session/"+sessionID, nil)
		if err != nil {
			return "", false, false, err
		}
		d.addDirectoryHeader(req, cfg.WorkDir)

		resp, err := d.httpClient.Do(req)
		if err != nil {
			d.emitRuntimeDebug("opencode session resume lookup transport failed", map[string]any{
				"session_id": sessionID,
				"error":      err,
			})
			return "", false, false, fmt.Errorf("get session: %w", err)
		}

		if resp.StatusCode == http.StatusOK {
			d.emitRuntimeDebug("opencode session resume lookup succeeded", map[string]any{"session_id": sessionID, "status": resp.StatusCode})
			var session struct {
				Agent  string          `json:"agent"`
				Revert json.RawMessage `json:"revert"`
			}
			decodeErr := json.NewDecoder(resp.Body).Decode(&session)
			logIgnoredCleanupError("failed to close opencode session lookup response body", resp.Body.Close())
			if decodeErr != nil {
				return "", false, false, fmt.Errorf("decode session: %w", decodeErr)
			}
			if strings.TrimSpace(session.Agent) != openCodeDefaultAgent {
				logIgnoredCleanupError("failed to close opencode session lookup response body", resp.Body.Close())
				return "", false, false, fmt.Errorf("get session: existing session agent %q is not resumable", strings.TrimSpace(session.Agent))
			}
			d.emitRuntimeDebug("opencode session resumed", map[string]any{"session_id": sessionID, "agent": strings.TrimSpace(session.Agent)})
			return sessionID, true, openCodeRawErrorPresent(session.Revert), nil
		} else {
			body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
			logIgnoredCleanupError("failed to close opencode session lookup response body", resp.Body.Close())
			if resp.StatusCode != http.StatusNotFound && resp.StatusCode != http.StatusGone {
				return "", false, false, fmt.Errorf("get session: status %d", resp.StatusCode)
			}
			d.recordSessionResumeFallback("invalid_or_not_found_session", "session/get")
			d.emitRuntimeDebug("opencode session resume lookup failed", map[string]any{
				"status":              resp.StatusCode,
				"response_body_bytes": len(body),
				"response_body":       "<omitted>",
			})
		}
	}

	d.emitRuntimeDebug("opencode session create started", map[string]any{"agent": openCodeDefaultAgent})
	body := map[string]interface{}{"agent": openCodeDefaultAgent}
	bodyJSON, _ := json.Marshal(body)

	req, err := d.newRequest(ctx, http.MethodPost, "/session", strings.NewReader(string(bodyJSON)))
	if err != nil {
		return "", false, false, err
	}
	req.Header.Set("Content-Type", "application/json")
	d.addDirectoryHeader(req, cfg.WorkDir)

	resp, err := d.httpClient.Do(req)
	if err != nil {
		d.emitRuntimeDebug("opencode session create transport failed", map[string]any{"error": err})
		return "", false, false, fmt.Errorf("create session: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
		d.emitRuntimeDebug("opencode session create failed", map[string]any{
			"status":              resp.StatusCode,
			"response_body_bytes": len(respBody),
			"response_body":       "<omitted>",
		})
		return "", false, false, fmt.Errorf("create session: status %d: %s", resp.StatusCode, string(respBody))
	}

	var session struct {
		ID string `json:"id"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&session); err != nil {
		d.emitRuntimeDebug("opencode session create decode failed", map[string]any{"error": err})
		return "", false, false, fmt.Errorf("decode session response: %w", err)
	}
	if strings.TrimSpace(session.ID) == "" {
		d.emitRuntimeDebug("opencode session create returned empty id", nil)
		return "", false, false, fmt.Errorf("create session returned empty ID")
	}
	d.emitRuntimeDebug("opencode session created", map[string]any{"session_id": strings.TrimSpace(session.ID)})
	return session.ID, false, false, nil
}

// recoverInterruptedResumedSession restarts the prompt agentteam when a previous
// OpenCode process persisted an unanswered user message or an unfinished
// assistant/tool turn. Session metadata survives process restarts, but
// OpenCode's active prompt agentteam and pending permission state do not.
func (d *OpenCodeDriver) recoverInterruptedResumedSession(ctx context.Context, sessionID string) error {
	trimmedSessionID := strings.TrimSpace(sessionID)
	if trimmedSessionID == "" {
		return fmt.Errorf("resume bootstrap requires session id")
	}
	latest, hasLatest, maxUserMessage, err := d.scanOpenCodeResumedSessionMessages(ctx, trimmedSessionID)
	if err != nil {
		return err
	}
	if !hasLatest {
		d.emitRuntimeDebug("opencode resumed session needs no prompt bootstrap", map[string]any{
			"session_id": trimmedSessionID,
			"reason":     "terminal",
		})
		return nil
	}
	maxUserMessageID := strings.TrimSpace(maxUserMessage.Info.ID)
	if strings.TrimSpace(latest.Info.Role) == "assistant" {
		if maxUserMessageID == "" {
			return fmt.Errorf(
				"resumed session effective history has assistant %q but no user message",
				strings.TrimSpace(latest.Info.ID),
			)
		}
		parentID := strings.TrimSpace(latest.Info.ParentID)
		if parentID != maxUserMessageID {
			// This is the clock-rollback/external-writer shape in which
			// OpenCode's effective max-id assistant is not paired with its
			// effective max-id user. Reusing either old id for an empty wake can
			// select the wrong turn or immediately no-op, so no-initial resume
			// fails closed. A fresh initial prompt only needs the raw high-water
			// scan above and is intentionally not rejected by this check.
			return fmt.Errorf(
				"resumed session max assistant %q does not parent max user %q",
				strings.TrimSpace(latest.Info.ID),
				maxUserMessageID,
			)
		}
	}

	d.observeOpenCodeMessageID(latest.Info.ID)
	d.observeOpenCodeMessageID(latest.Info.ParentID)
	promptMessageID, reason := openCodeResumePromptMessageID(latest)
	if promptMessageID == "" {
		d.emitRuntimeDebug("opencode resumed session needs no prompt bootstrap", map[string]any{
			"session_id":        trimmedSessionID,
			"tail_message_id":   strings.TrimSpace(latest.Info.ID),
			"tail_message_role": strings.TrimSpace(latest.Info.Role),
			"reason":            reason,
		})
		return nil
	}
	var resumeCompactionAuto *bool
	if promptMessageID == maxUserMessageID {
		if compactionPart, ok := openCodeResumeCompactionPart(maxUserMessage); ok {
			if compactionPart.Auto == nil {
				return fmt.Errorf(
					"resumed session compaction user %q is missing its auto mode",
					maxUserMessageID,
				)
			}
			auto := *compactionPart.Auto
			resumeCompactionAuto = &auto
		}
	}
	if latest.Info.Summary && resumeCompactionAuto == nil {
		return fmt.Errorf(
			"resumed session summary assistant %q has no compaction parent task",
			strings.TrimSpace(latest.Info.ID),
		)
	}
	d.emitRuntimeDebug("opencode resumed session prompt bootstrap started", map[string]any{
		"session_id":        trimmedSessionID,
		"tail_message_id":   strings.TrimSpace(latest.Info.ID),
		"prompt_message_id": promptMessageID,
		"reason":            reason,
		"compaction_resume": resumeCompactionAuto != nil,
	})
	if err := d.sendPromptAsyncPartsWithRunnerBootstrap(
		ctx,
		nil,
		"",
		promptMessageID,
		true,
		resumeCompactionAuto,
	); err != nil {
		return fmt.Errorf("kick interrupted resumed session prompt: %w", err)
	}
	d.emitRuntimeDebug("opencode resumed session prompt bootstrap accepted", map[string]any{
		"session_id":        trimmedSessionID,
		"tail_message_id":   strings.TrimSpace(latest.Info.ID),
		"prompt_message_id": promptMessageID,
		"reason":            reason,
	})
	return nil
}

func openCodeResumePromptMessageID(message openCodeResumeMessage) (string, string) {
	messageID := strings.TrimSpace(message.Info.ID)
	switch strings.TrimSpace(message.Info.Role) {
	case "user":
		if messageID == "" {
			return "", "terminal"
		}
		return messageID, "unanswered_user"
	case "assistant":
		parentID := strings.TrimSpace(message.Info.ParentID)
		if parentID == "" {
			return "", "terminal"
		}
		errorJSON := strings.TrimSpace(string(message.Info.Error))
		if errorJSON != "" && errorJSON != "null" {
			return "", "terminal"
		}
		for _, part := range message.Parts {
			if strings.TrimSpace(part.Type) != "tool" || part.Metadata.ProviderExecuted {
				continue
			}
			if strings.TrimSpace(part.State.Status) == "error" && part.State.Metadata.Interrupted {
				continue
			}
			return parentID, "unfinished_assistant"
		}
		if strings.TrimSpace(message.Info.Finish) != "" {
			return "", "terminal"
		}
		if message.Info.Time.Completed != nil {
			return "", "terminal"
		}
		// OpenCode persists the assistant row before model/tool execution. A
		// process exit while permission.ask is blocked therefore leaves an
		// assistant without finish/error/completed, sometimes with a running
		// tool part. Re-enter the agentteam using its parent user message id.
		return parentID, "unfinished_assistant"
	default:
		return "", "terminal"
	}
}
