package doctor

import (
	"archive/zip"
	"encoding/json"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"time"

	"reasonix/internal/agent"
	"reasonix/internal/config"
	"reasonix/internal/store"
)

const sessionBundleSchemaVersion = 1

// SessionBundleOptions controls a session-conflict diagnostic zip. SessionRef
// accepts either a branch id such as "20260706-...-recovery-abcd1234", a
// matching .jsonl basename, or an absolute/relative transcript path.
type SessionBundleOptions struct {
	Version    string
	SessionRef string
	OutputPath string
	Now        time.Time
}

type SessionBundleResult struct {
	Path        string
	SessionPath string
	Included    []SessionBundleFile
	Missing     []string
}

type SessionBundleManifest struct {
	SchemaVersion int                  `json:"schema_version"`
	GeneratedAt   time.Time            `json:"generated_at"`
	Version       string               `json:"version"`
	RequestedRef  string               `json:"requested_ref"`
	SessionPath   string               `json:"session_path"`
	Sessions      []SessionBundleEntry `json:"sessions"`
	Included      []SessionBundleFile  `json:"included"`
	Missing       []string             `json:"missing,omitempty"`
}

type SessionBundleEntry struct {
	BranchID       string `json:"branch_id"`
	Role           string `json:"role"`
	Path           string `json:"path"`
	ParentID       string `json:"parent_id,omitempty"`
	Recovered      bool   `json:"recovered,omitempty"`
	RecoveryReason string `json:"recovery_reason,omitempty"`
	RecoveryDepth  int    `json:"recovery_depth,omitempty"`
}

type SessionBundleFile struct {
	Role string `json:"role"`
	Name string `json:"name"`
	Path string `json:"path"`
	Size int64  `json:"size"`
}

type sessionArtifact struct {
	role string
	path string
}

// WriteSessionBundle writes a zip containing the selected session transcript,
// its persistence sidecars, a redacted doctor report, and the recovery parent
// chain discoverable through branch metadata.
func WriteSessionBundle(opts SessionBundleOptions) (SessionBundleResult, error) {
	ref := strings.TrimSpace(opts.SessionRef)
	if ref == "" {
		return SessionBundleResult{}, fmt.Errorf("session branch id or path is required")
	}
	now := opts.Now
	if now.IsZero() {
		now = time.Now()
	}
	sessionPath, err := resolveSessionBundlePath(ref)
	if err != nil {
		return SessionBundleResult{}, err
	}
	chain := sessionBundleChain(sessionPath)
	outPath := strings.TrimSpace(opts.OutputPath)
	if outPath == "" {
		outPath = defaultSessionBundlePath(agent.BranchID(sessionPath))
	}
	if abs, err := filepath.Abs(outPath); err == nil {
		outPath = abs
	}
	if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
		return SessionBundleResult{}, err
	}

	f, err := os.Create(outPath)
	if err != nil {
		return SessionBundleResult{}, err
	}
	defer f.Close()

	zw := zip.NewWriter(f)
	manifest := SessionBundleManifest{
		SchemaVersion: sessionBundleSchemaVersion,
		GeneratedAt:   now,
		Version:       opts.Version,
		RequestedRef:  redactSessionBundleRef(ref),
		SessionPath:   redactSessionBundlePath(sessionPath),
	}
	result := SessionBundleResult{Path: outPath, SessionPath: sessionPath}

	report := Collect(Options{Version: opts.Version})
	reportData, err := json.MarshalIndent(report, "", "  ")
	if err != nil {
		_ = zw.Close()
		return SessionBundleResult{}, err
	}
	if err := addBundleBytes(zw, "doctor.json", reportData, now); err != nil {
		_ = zw.Close()
		return SessionBundleResult{}, err
	}

	for i, path := range chain {
		role := "parent"
		if i == 0 {
			role = "requested"
		}
		meta, ok, metaErr := agent.LoadBranchMeta(path)
		if metaErr != nil {
			metaPath := store.SessionMeta(path)
			manifest.Missing = append(manifest.Missing, redactSessionBundlePath(metaPath)+": "+redactSessionBundleError(metaErr))
		}
		entry := SessionBundleEntry{
			BranchID: agent.BranchID(path),
			Role:     role,
			Path:     redactSessionBundlePath(path),
		}
		if ok {
			entry.ParentID = meta.ParentID
			entry.Recovered = meta.Recovered
			entry.RecoveryReason = meta.RecoveryReason
			entry.RecoveryDepth = meta.RecoveryDepth
		}
		manifest.Sessions = append(manifest.Sessions, entry)

		for _, artifact := range sessionBundleArtifacts(path) {
			info, statErr := os.Stat(artifact.path)
			if statErr != nil {
				if !os.IsNotExist(statErr) {
					manifest.Missing = append(manifest.Missing, redactSessionBundlePath(artifact.path)+": "+redactSessionBundleError(statErr))
				}
				continue
			}
			if info.IsDir() {
				continue
			}
			zipName := filepath.ToSlash(filepath.Join("sessions", safeBundleName(agent.BranchID(path)), filepath.Base(artifact.path)))
			if err := addBundleFile(zw, zipName, artifact.path, info); err != nil {
				_ = zw.Close()
				return SessionBundleResult{}, err
			}
			file := SessionBundleFile{
				Role: artifact.role,
				Name: zipName,
				Path: redactSessionBundlePath(artifact.path),
				Size: info.Size(),
			}
			manifest.Included = append(manifest.Included, file)
			result.Included = append(result.Included, file)
		}
	}
	sort.Strings(manifest.Missing)
	result.Missing = append(result.Missing, manifest.Missing...)
	manifestData, err := json.MarshalIndent(manifest, "", "  ")
	if err != nil {
		_ = zw.Close()
		return SessionBundleResult{}, err
	}
	if err := addBundleBytes(zw, "manifest.json", manifestData, now); err != nil {
		_ = zw.Close()
		return SessionBundleResult{}, err
	}
	if err := zw.Close(); err != nil {
		return SessionBundleResult{}, err
	}
	return result, nil
}

func resolveSessionBundlePath(ref string) (string, error) {
	if path, ok := existingSessionPath(ref); ok {
		return path, nil
	}
	branch := sessionBundleBranchID(ref)
	if branch == "" {
		return "", fmt.Errorf("invalid session reference %q", ref)
	}
	name := branch + ".jsonl"
	var matches []string
	for _, dir := range sessionBundleSearchDirs() {
		path := filepath.Join(dir, name)
		if info, err := os.Stat(path); err == nil && !info.IsDir() {
			matches = append(matches, path)
		}
	}
	if len(matches) == 0 {
		return "", fmt.Errorf("session %q not found under Reasonix session directories", branch)
	}
	sort.Strings(matches)
	if len(matches) > 1 {
		redacted := make([]string, len(matches))
		for i, match := range matches {
			redacted[i] = redactSessionBundlePath(match)
		}
		return "", fmt.Errorf("session %q matched multiple files; pass an absolute path: %s", branch, strings.Join(redacted, ", "))
	}
	return matches[0], nil
}

func redactSessionBundleRef(ref string) string {
	if strings.ContainsAny(ref, `/\`) {
		return redactSessionBundlePath(ref)
	}
	return ref
}

func redactSessionBundlePath(path string) string {
	path = strings.TrimSpace(path)
	if path == "" {
		return path
	}
	root := strings.TrimSpace(config.MemoryUserDir())
	if root != "" {
		absRoot, rootErr := filepath.Abs(root)
		absPath, pathErr := filepath.Abs(path)
		if rootErr == nil && pathErr == nil {
			if rel, err := filepath.Rel(absRoot, absPath); err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
				if rel == "." {
					return "<REASONIX_HOME>"
				}
				return filepath.ToSlash(filepath.Join("<REASONIX_HOME>", rel))
			}
		}
	}
	return redactHome(path)
}

func redactSessionBundleError(err error) string {
	if err == nil {
		return ""
	}
	return redactSessionBundleText(err.Error())
}

func redactSessionBundleText(text string) string {
	if text == "" {
		return text
	}
	if root := strings.TrimSpace(config.MemoryUserDir()); root != "" {
		text = replacePathPrefix(text, root, "<REASONIX_HOME>")
		if absRoot, err := filepath.Abs(root); err == nil {
			text = replacePathPrefix(text, absRoot, "<REASONIX_HOME>")
		}
	}
	if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" {
		text = replacePathPrefix(text, home, "~")
	}
	return text
}

func replacePathPrefix(text, old, new string) string {
	old = strings.TrimRight(strings.TrimSpace(old), string(os.PathSeparator))
	if old == "" {
		return text
	}
	text = strings.ReplaceAll(text, old+string(os.PathSeparator), new+string(os.PathSeparator))
	return strings.ReplaceAll(text, old, new)
}

func existingSessionPath(ref string) (string, bool) {
	path := strings.Trim(strings.TrimSpace(ref), `"'`)
	if path == "" {
		return "", false
	}
	if !strings.HasSuffix(filepath.Base(path), ".jsonl") {
		if candidate := path + ".jsonl"; strings.ContainsAny(path, `/\`) {
			path = candidate
		}
	}
	if !strings.HasSuffix(filepath.Base(path), ".jsonl") {
		return "", false
	}
	if abs, err := filepath.Abs(path); err == nil {
		path = abs
	}
	info, err := os.Stat(path)
	if err != nil || info.IsDir() {
		return "", false
	}
	return path, true
}

func sessionBundleBranchID(ref string) string {
	ref = strings.Trim(strings.TrimSpace(ref), `"'`)
	base := filepath.Base(ref)
	for _, suffix := range []string{".jsonl.meta", ".jsonl", ".meta", ".json"} {
		base = strings.TrimSuffix(base, suffix)
	}
	return strings.TrimSpace(base)
}

func sessionBundleSearchDirs() []string {
	seen := map[string]bool{}
	var out []string
	add := func(dir string) {
		dir = strings.TrimSpace(dir)
		if dir == "" {
			return
		}
		if abs, err := filepath.Abs(dir); err == nil {
			dir = abs
		}
		if seen[dir] {
			return
		}
		seen[dir] = true
		out = append(out, dir)
	}
	add(config.SessionDir())
	if cwd, err := os.Getwd(); err == nil {
		add(config.ProjectSessionDir(cwd))
	}
	if root := config.MemoryUserDir(); root != "" {
		projects := filepath.Join(root, "projects")
		_ = filepath.WalkDir(projects, func(path string, d os.DirEntry, err error) error {
			if err != nil || !d.IsDir() {
				return nil
			}
			if d.Name() == "sessions" {
				add(path)
				return filepath.SkipDir
			}
			return nil
		})
	}
	sort.Strings(out)
	return out
}

func sessionBundleChain(path string) []string {
	var out []string
	seen := map[string]bool{}
	for len(out) < 16 {
		if path == "" {
			break
		}
		if abs, err := filepath.Abs(path); err == nil {
			path = abs
		}
		if seen[path] {
			break
		}
		seen[path] = true
		out = append(out, path)
		meta, ok, err := agent.LoadBranchMeta(path)
		if err != nil || !ok || strings.TrimSpace(meta.ParentID) == "" {
			break
		}
		parent := filepath.Join(filepath.Dir(path), meta.ParentID+".jsonl")
		if info, err := os.Stat(parent); err != nil || info.IsDir() {
			break
		}
		path = parent
	}
	return out
}

func sessionBundleArtifacts(sessionPath string) []sessionArtifact {
	return []sessionArtifact{
		{role: "transcript", path: sessionPath},
		{role: "meta", path: store.SessionMeta(sessionPath)},
		{role: "goal_state", path: store.SessionGoalState(sessionPath)},
		{role: "events", path: store.SessionEventLog(sessionPath)},
		// The salvage sidecar is the primary evidence for truncation incidents
		// (torn tails, dual-writer interleaves) — exactly what session bundles
		// exist to debug. Bundles copy artifact contents verbatim already, so
		// it adds no new sensitivity class.
		{role: "events_damaged", path: store.SessionEventLogDamaged(sessionPath)},
		{role: "event_index", path: store.SessionEventIndex(sessionPath)},
		{role: "conflicts", path: store.SessionConflictLog(sessionPath)},
		{role: "lease", path: store.SessionLeaseInfo(sessionPath)},
	}
}

func addBundleFile(zw *zip.Writer, name, path string, info os.FileInfo) error {
	h, err := zip.FileInfoHeader(info)
	if err != nil {
		return err
	}
	h.Name = name
	h.Method = zip.Deflate
	w, err := zw.CreateHeader(h)
	if err != nil {
		return err
	}
	f, err := os.Open(path)
	if err != nil {
		return err
	}
	defer f.Close()
	_, err = io.Copy(w, f)
	return err
}

func addBundleBytes(zw *zip.Writer, name string, data []byte, modTime time.Time) error {
	h := &zip.FileHeader{Name: name, Method: zip.Deflate, Modified: modTime}
	h.SetMode(0o644)
	w, err := zw.CreateHeader(h)
	if err != nil {
		return err
	}
	_, err = w.Write(data)
	return err
}

func defaultSessionBundlePath(branch string) string {
	branch = safeBundleName(branch)
	if branch == "" {
		branch = "session"
	}
	return filepath.Join(os.TempDir(), "reasonix-session-"+branch+"-diag.zip")
}

func safeBundleName(name string) string {
	name = strings.TrimSpace(name)
	if name == "" {
		return ""
	}
	var b strings.Builder
	for _, r := range name {
		switch {
		case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
			b.WriteRune(r)
		case r == '-', r == '_', r == '.':
			b.WriteRune(r)
		default:
			b.WriteByte('_')
		}
		if b.Len() >= 120 {
			break
		}
	}
	return strings.Trim(b.String(), "._-")
}
