// Package diagnosticbundle builds bounded, scrubbed Desktop diagnostic archives.
package diagnosticbundle

import (
	"archive/zip"
	"bufio"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"regexp"
	"runtime"
	"sort"
	"strings"
	"time"
	"unicode/utf8"

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

const (
	defaultMaxFileBytes    int64 = 10 * 1024 * 1024
	defaultMaxBundleBytes  int64 = 128 * 1024 * 1024
	defaultMaxAgents             = 128
	maxDiagnosticLineBytes       = 256 * 1024
)

var diagnosticFileNames = []string{
	"opencode-runtime-debug.jsonl",
	"opencode-serve.log",
	"opencode-runtime-debug.jsonl.1",
	"opencode-serve.log.1",
}

// Paths contains daemon-owned roots resolved by the API layer. These values
// come from the active local profile, never from renderer request parameters.
type Paths struct {
	ConfigDir     string
	DaemonLogPath string
}

// Agent describes one agent authorized for the selected Desktop binding.
type Agent struct {
	ID                  string `json:"id"`
	Runtime             string `json:"runtime"`
	RuntimeDebugEnabled bool   `json:"runtimeDebugEnabled"`
}

// Scope identifies the selected local Machine and its authorized agents.
type Scope struct {
	WorkspaceID string
	MachineID   string
	Agents      []Agent
}

type manifest struct {
	SchemaVersion int            `json:"schemaVersion"`
	GeneratedAt   time.Time      `json:"generatedAt"`
	AgentTeam     buildManifest  `json:"agentteam"`
	WorkspaceID   string         `json:"workspaceId"`
	MachineID     string         `json:"machineId"`
	Agents        []Agent        `json:"agents"`
	Files         []fileManifest `json:"files"`
	Omitted       []omission     `json:"omitted,omitempty"`
}

type buildManifest struct {
	Version   string `json:"version"`
	Commit    string `json:"commit"`
	GoVersion string `json:"goVersion"`
	OS        string `json:"os"`
	Arch      string `json:"arch"`
}

type fileManifest struct {
	Path          string `json:"path"`
	SourceBytes   int64  `json:"sourceBytes"`
	IncludedBytes int64  `json:"includedBytes"`
	Truncated     bool   `json:"truncated,omitempty"`
	Filtered      bool   `json:"filtered,omitempty"`
	Error         string `json:"error,omitempty"`
}

type omission struct {
	Path   string `json:"path"`
	Reason string `json:"reason"`
}

type source struct {
	path        string
	archivePath string
	agentOwned  bool
	filter      func(string) bool
}

type bundleLimits struct {
	maxFileBytes   int64
	maxBundleBytes int64
	maxAgents      int
}

type bundleWriter struct {
	ctx       context.Context
	archive   *zip.Writer
	configDir string
	limits    bundleLimits
	result    *manifest
	consumed  int64
}

// Write writes one ZIP archive for the already-authorized local scope.
func Write(ctx context.Context, destination io.Writer, paths Paths, scope Scope) error {
	return write(ctx, destination, paths, scope, bundleLimits{
		maxFileBytes:   defaultMaxFileBytes,
		maxBundleBytes: defaultMaxBundleBytes,
		maxAgents:      defaultMaxAgents,
	})
}

func write(
	ctx context.Context,
	destination io.Writer,
	paths Paths,
	scope Scope,
	limits bundleLimits,
) error {
	if destination == nil {
		return fmt.Errorf("diagnostic bundle destination is required")
	}
	if strings.TrimSpace(paths.ConfigDir) == "" {
		return fmt.Errorf("diagnostic bundle config directory is required")
	}
	if limits.maxFileBytes <= 0 || limits.maxBundleBytes <= 0 || limits.maxAgents <= 0 {
		return fmt.Errorf("diagnostic bundle limits must be positive")
	}

	scope.WorkspaceID = boundedManifestValue(scope.WorkspaceID)
	scope.MachineID = boundedManifestValue(scope.MachineID)
	scope.Agents = append([]Agent(nil), scope.Agents...)
	sort.Slice(scope.Agents, func(i, j int) bool {
		return scope.Agents[i].ID < scope.Agents[j].ID
	})
	omittedAgentCount := 0
	if len(scope.Agents) > limits.maxAgents {
		omittedAgentCount = len(scope.Agents) - limits.maxAgents
		scope.Agents = scope.Agents[:limits.maxAgents]
	}
	for index := range scope.Agents {
		scope.Agents[index].ID = boundedManifestValue(scope.Agents[index].ID)
		scope.Agents[index].Runtime = boundedManifestValue(scope.Agents[index].Runtime)
	}

	result := manifest{
		SchemaVersion: 1,
		GeneratedAt:   time.Now().UTC(),
		AgentTeam: buildManifest{
			Version:   version.BuildVersion(),
			Commit:    version.Commit,
			GoVersion: version.GoToolchainVersion(),
			OS:        runtime.GOOS,
			Arch:      runtime.GOARCH,
		},
		WorkspaceID: scope.WorkspaceID,
		MachineID:   scope.MachineID,
		Agents:      append([]Agent(nil), scope.Agents...),
		Files:       []fileManifest{},
	}
	if omittedAgentCount > 0 {
		result.Omitted = append(result.Omitted, omission{
			Path:   "agents",
			Reason: fmt.Sprintf("agent_count_limit:%d", omittedAgentCount),
		})
	}

	sources := buildSources(paths, scope, &result)
	archive := zip.NewWriter(destination)
	writer := bundleWriter{
		ctx:       ctx,
		archive:   archive,
		configDir: paths.ConfigDir,
		limits:    limits,
		result:    &result,
	}
	for _, candidate := range sources {
		if err := writer.add(candidate); err != nil {
			_ = writer.archive.Close()
			return err
		}
	}
	if err := writer.addManifest(); err != nil {
		_ = writer.archive.Close()
		return err
	}
	if err := writer.archive.Close(); err != nil {
		return fmt.Errorf("close diagnostic archive: %w", err)
	}
	return nil
}

func (writer *bundleWriter) add(candidate source) error {
	if err := writer.ctx.Err(); err != nil {
		return err
	}
	info, resolvedPath, err := inspectSource(writer.configDir, candidate)
	if err != nil {
		writer.result.Omitted = append(writer.result.Omitted, omission{
			Path:   candidate.archivePath,
			Reason: omissionReason(err),
		})
		return nil
	}

	remaining := writer.limits.maxBundleBytes - writer.consumed
	if remaining <= 0 {
		writer.result.Omitted = append(writer.result.Omitted, omission{
			Path:   candidate.archivePath,
			Reason: "bundle_size_limit",
		})
		return nil
	}
	inputBytes := minInt64(info.Size(), writer.limits.maxFileBytes, remaining)
	entry := fileManifest{
		Path:        candidate.archivePath,
		SourceBytes: info.Size(),
		Truncated:   inputBytes < info.Size(),
		Filtered:    candidate.filter != nil,
	}
	writer.consumed += inputBytes

	header, err := zip.FileInfoHeader(info)
	if err != nil {
		entry.Error = "metadata_unavailable"
		writer.result.Files = append(writer.result.Files, entry)
		return nil
	}
	header.Name = candidate.archivePath
	header.Method = zip.Deflate
	header.SetMode(0o600)
	entryWriter, err := writer.archive.CreateHeader(header)
	if err != nil {
		return fmt.Errorf("create diagnostic archive entry %q: %w", candidate.archivePath, err)
	}
	entry.IncludedBytes, err = copySource(
		writer.ctx,
		entryWriter,
		resolvedPath,
		info,
		inputBytes,
		candidate.filter,
	)
	if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
		return err
	}
	if err != nil {
		entry.Error = "read_failed"
	}
	writer.result.Files = append(writer.result.Files, entry)
	return nil
}

func (writer *bundleWriter) addManifest() error {
	manifestWriter, err := writer.archive.Create("manifest.json")
	if err != nil {
		return fmt.Errorf("create diagnostic manifest: %w", err)
	}
	encoder := json.NewEncoder(manifestWriter)
	encoder.SetIndent("", "  ")
	if err := encoder.Encode(writer.result); err != nil {
		return fmt.Errorf("encode diagnostic manifest: %w", err)
	}
	return nil
}

func buildSources(paths Paths, scope Scope, result *manifest) []source {
	agents := make([]Agent, 0, len(scope.Agents))
	for _, configured := range scope.Agents {
		if !safeAgentID(configured.ID) {
			result.Omitted = append(result.Omitted, omission{
				Path:   "agent:" + boundedManifestValue(configured.ID),
				Reason: "invalid_agent_id",
			})
			continue
		}
		agents = append(agents, configured)
	}

	sources := make([]source, 0, 1+len(agents)*len(diagnosticFileNames))
	if logPath := strings.TrimSpace(paths.DaemonLogPath); logPath != "" && len(agents) > 0 {
		sources = append(sources, source{
			path:        logPath,
			archivePath: "daemon/agentteam.log",
			filter:      agentLineFilter(agents),
		})
	}
	for _, filename := range diagnosticFileNames {
		for _, configured := range agents {
			sources = append(sources, source{
				path: filepath.Join(
					paths.ConfigDir,
					"agents",
					configured.ID,
					"diagnostics",
					filename,
				),
				archivePath: filepath.ToSlash(filepath.Join("agents", configured.ID, filename)),
				agentOwned:  true,
			})
		}
	}
	return sources
}

func inspectSource(configDir string, candidate source) (os.FileInfo, string, error) {
	path := filepath.Clean(strings.TrimSpace(candidate.path))
	if path == "" || path == "." {
		return nil, "", fmt.Errorf("invalid_path")
	}
	info, err := os.Lstat(path)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, "", fmt.Errorf("not_found")
		}
		return nil, "", fmt.Errorf("unreadable")
	}
	if info.Mode()&os.ModeSymlink != 0 {
		return nil, "", fmt.Errorf("symlink_rejected")
	}
	if !info.Mode().IsRegular() {
		return nil, "", fmt.Errorf("not_regular")
	}
	resolvedPath, err := filepath.EvalSymlinks(path)
	if err != nil {
		return nil, "", fmt.Errorf("unreadable")
	}
	if candidate.agentOwned {
		agentRoot := filepath.Join(configDir, "agents")
		resolvedRoot, err := filepath.EvalSymlinks(agentRoot)
		if err != nil || !pathWithin(resolvedRoot, resolvedPath) {
			return nil, "", fmt.Errorf("outside_agent_root")
		}
	}
	return info, resolvedPath, nil
}

func copySource(
	ctx context.Context,
	destination io.Writer,
	path string,
	expected os.FileInfo,
	inputBytes int64,
	filter func(string) bool,
) (int64, error) {
	file, err := os.Open(path)
	if err != nil {
		return 0, err
	}
	defer file.Close()
	opened, err := file.Stat()
	if err != nil || !opened.Mode().IsRegular() || !os.SameFile(expected, opened) {
		return 0, fmt.Errorf("diagnostic source changed before reading")
	}

	start := opened.Size() - inputBytes
	if start < 0 {
		start = 0
	}
	if _, err := file.Seek(start, io.SeekStart); err != nil {
		return 0, err
	}
	reader := bufio.NewReader(io.LimitReader(file, inputBytes))
	if start > 0 {
		if err := discardPartialLine(ctx, reader); err != nil && !errors.Is(err, io.EOF) {
			return 0, err
		}
	}

	counter := &countingWriter{writer: destination}
	for {
		if err := ctx.Err(); err != nil {
			return counter.written, err
		}
		line, oversized, err := readBoundedLine(ctx, reader)
		if len(line) > 0 || oversized {
			if filter == nil || (!oversized && filter(line)) {
				output := "[oversized diagnostic line omitted]\n"
				if !oversized {
					hadNewline := strings.HasSuffix(line, "\n")
					output = agent.ScrubDiagnosticLogText(strings.TrimSuffix(line, "\n"))
					if hadNewline {
						output += "\n"
					}
				}
				if _, writeErr := io.WriteString(counter, output); writeErr != nil {
					return counter.written, writeErr
				}
			}
		}
		if errors.Is(err, io.EOF) {
			return counter.written, nil
		}
		if err != nil {
			return counter.written, err
		}
	}
}

func discardPartialLine(ctx context.Context, reader *bufio.Reader) error {
	for {
		if err := ctx.Err(); err != nil {
			return err
		}
		_, err := reader.ReadSlice('\n')
		if errors.Is(err, bufio.ErrBufferFull) {
			continue
		}
		return err
	}
}

func readBoundedLine(ctx context.Context, reader *bufio.Reader) (string, bool, error) {
	var line strings.Builder
	oversized := false
	for {
		if err := ctx.Err(); err != nil {
			return "", false, err
		}
		fragment, err := reader.ReadSlice('\n')
		if !oversized && line.Len()+len(fragment) <= maxDiagnosticLineBytes {
			_, _ = line.Write(fragment)
		} else {
			oversized = true
		}
		if errors.Is(err, bufio.ErrBufferFull) {
			continue
		}
		if err == nil || errors.Is(err, io.EOF) {
			return line.String(), oversized, err
		}
		return line.String(), oversized, err
	}
}

func agentLineFilter(agents []Agent) func(string) bool {
	patterns := make([]*regexp.Regexp, 0, len(agents)*2)
	for _, configured := range agents {
		id := regexp.QuoteMeta(configured.ID)
		patterns = append(
			patterns,
			regexp.MustCompile(`(?:^|\s)agent_id=(?:"`+id+`"|`+id+`)(?:\s|$)`),
			regexp.MustCompile(`"agentId"\s*:\s*"`+id+`"`),
		)
	}
	return func(line string) bool {
		for _, pattern := range patterns {
			if pattern.MatchString(line) {
				return true
			}
		}
		return false
	}
}

func safeAgentID(id string) bool {
	id = strings.TrimSpace(id)
	return id != "" &&
		id != "." &&
		id != ".." &&
		filepath.Base(id) == id &&
		!strings.ContainsAny(id, `/\`)
}

func pathWithin(root, candidate string) bool {
	root, err := filepath.Abs(root)
	if err != nil {
		return false
	}
	candidate, err = filepath.Abs(candidate)
	if err != nil {
		return false
	}
	relative, err := filepath.Rel(root, candidate)
	return err == nil &&
		relative != ".." &&
		!strings.HasPrefix(relative, ".."+string(filepath.Separator))
}

func omissionReason(err error) string {
	reason := strings.TrimSpace(err.Error())
	if reason == "" {
		return "unavailable"
	}
	return reason
}

func boundedManifestValue(value string) string {
	value = strings.ToValidUTF8(strings.TrimSpace(value), "�")
	if len(value) <= 256 {
		return value
	}
	const suffix = "…"
	for len(value)+len(suffix) > 256 || !utf8.ValidString(value) {
		_, size := utf8.DecodeLastRuneInString(value)
		value = value[:len(value)-size]
	}
	return value + suffix
}

func minInt64(values ...int64) int64 {
	result := values[0]
	for _, value := range values[1:] {
		if value < result {
			result = value
		}
	}
	return result
}

type countingWriter struct {
	writer  io.Writer
	written int64
}

func (writer *countingWriter) Write(data []byte) (int, error) {
	written, err := writer.writer.Write(data)
	writer.written += int64(written)
	return written, err
}
