//go:build !windows

package infra

import (
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
)

// sessionFile mirrors the on-disk shape of ~/.claude/sessions/<PID>.json
// written by Claude Code 2.1.x. Only the fields we consume are declared.
type sessionFile struct {
	PID       int    `json:"pid"`
	SessionID string `json:"sessionId"`
	Cwd       string `json:"cwd"`
	ProcStart string `json:"procStart"`
	Status    string `json:"status"`
	UpdatedAt int64  `json:"updatedAt"`
}

// goalFile mirrors the on-disk shape of ~/.claude/goals/<sessionId>.json.
type goalFile struct {
	Goal        string `json:"goal"`
	Phase       string `json:"phase"`
	Emojis      string `json:"emojis"`
	Rename      string `json:"rename"`
	DodContract string `json:"dod_contract"`
	DodStatus   string `json:"dod_status"`
	Role        string `json:"role"`
}

// runtimeFile mirrors ~/.claude/runtime/<sessionId>.json written by statusline.sh
// with the live context-window usage of that session.
type runtimeFile struct {
	CtxPct int `json:"ctx_pct"`
}

// ListClaudeFleet scans the current user's ~/.claude directory and returns the
// fleet of Claude Code sessions known to the machine. It is a thin wrapper over
// ListClaudeFleetFrom resolving the home directory, plus it populates each
// member's PaneID ("%N") by resolving it against the fleet tmux socket.
//
// The socket comes from $FLEET_SOCKET, defaulting to "fleet". Resolution is
// best-effort: if tmux/the socket is unavailable, every PaneID is left "" and
// the fleet is still returned. PaneID is only populated here (the public
// registry entry point), not in ListClaudeFleetFrom, so consumers that call the
// core directly in a hot loop (and the unit tests) pay no tmux cost.
func ListClaudeFleet() ([]ClaudeFleet, error) {
	home, err := os.UserHomeDir()
	if err != nil {
		return nil, fmt.Errorf("resolve home dir: %w", err)
	}
	fleet, err := ListClaudeFleetFrom(filepath.Join(home, ".claude"))
	if err != nil {
		return nil, err
	}
	populatePaneIDs(fleet)
	return fleet, nil
}

// populatePaneIDs resolves each alive member's pane_id ("%N") against the fleet
// tmux socket ($FLEET_SOCKET, default "fleet") and writes it into PaneID. It
// mutates fleet in place. Best-effort: tmux/socket down -> every PaneID stays ""
// (ResolvePaneIDs returns an empty map), no crash. Only alive PIDs are queried;
// a dead PID has no pane to resolve.
func populatePaneIDs(fleet []ClaudeFleet) {
	socket := os.Getenv("FLEET_SOCKET")
	if socket == "" {
		socket = "fleet"
	}
	pids := make([]int, 0, len(fleet))
	for _, f := range fleet {
		if f.Alive {
			pids = append(pids, f.PID)
		}
	}
	byPID := ResolvePaneIDs(socket, pids)
	for i := range fleet {
		fleet[i].PaneID = byPID[fleet[i].PID]
	}
}

// ListClaudeFleetFrom scans claudeDir (e.g. ~/.claude) and returns the fleet of
// Claude Code sessions. It reads sessions/*.json, joins each against its
// goals/<sessionId>.json sidecar, validates liveness against /proc (guarding
// against PID recycling), and derives the display fields.
//
// Every session that produced a parseable JSON is returned; the Alive flag
// reflects whether the underlying process is actually running. The caller is
// expected to filter on Alive as needed. Records are ordered by status
// (idle, waiting, busy, other) and within a status by UpdatedAt descending.
func ListClaudeFleetFrom(claudeDir string) ([]ClaudeFleet, error) {
	sessionsDir := filepath.Join(claudeDir, "sessions")
	goalsDir := filepath.Join(claudeDir, "goals")
	runtimeDir := filepath.Join(claudeDir, "runtime")

	entries, err := os.ReadDir(sessionsDir)
	if err != nil {
		if os.IsNotExist(err) {
			return []ClaudeFleet{}, nil
		}
		return nil, fmt.Errorf("read sessions dir %q: %w", sessionsDir, err)
	}

	fleet := make([]ClaudeFleet, 0, len(entries))
	for _, entry := range entries {
		if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
			continue
		}

		raw, readErr := os.ReadFile(filepath.Join(sessionsDir, entry.Name()))
		if readErr != nil {
			continue
		}
		var sess sessionFile
		if json.Unmarshal(raw, &sess) != nil {
			continue
		}
		if sess.PID == 0 || sess.SessionID == "" {
			continue
		}

		f := ClaudeFleet{
			PID:        sess.PID,
			SessionID:  sess.SessionID,
			Status:     sess.Status,
			Cwd:        sess.Cwd,
			UpdatedAt:  sess.UpdatedAt,
			TmuxWindow: "",
		}

		// Liveness + anti-PID-recycling: the process must exist AND its
		// /proc starttime must match the procStart recorded in the JSON.
		f.Alive = procIsAlive(sess.PID, sess.ProcStart)

		// KITTY_PID from the process environ (0 if unreadable / absent).
		f.KittyPID = readKittyPID(sess.PID)

		// Join goal/phase/emojis/name + DoD contract/status from
		// goals/<sessionId>.json (optional).
		f.Goal, f.Phase, f.Emojis, f.Name, f.DodContract, f.DodStatus, f.Role = readGoal(goalsDir, sess.SessionID)

		// Context usage from runtime/<sessionId>.json (written by statusline).
		f.CtxPct = readCtxPct(runtimeDir, sess.SessionID)

		// Derived display fields.
		f.Target = deriveTarget(sess.SessionID, sess.Cwd)
		f.Rename = deriveRename(f.Goal, sess.Cwd)

		fleet = append(fleet, f)
	}

	sortFleet(fleet)
	return fleet, nil
}

// procIsAlive reports whether pid is running and its kernel starttime matches
// procStartJSON. An empty procStartJSON only requires the process to exist.
func procIsAlive(pid int, procStartJSON string) bool {
	real, ok := procStartTime(pid)
	if !ok {
		return false
	}
	if procStartJSON == "" {
		return true
	}
	return strings.TrimSpace(procStartJSON) == strings.TrimSpace(real)
}

// procStartTime returns field 22 (starttime, in clock ticks) of
// /proc/<pid>/stat. The comm field (field 2) is wrapped in parentheses and may
// itself contain spaces and ')' characters, so we parse the portion after the
// LAST ')' and index from there: starttime is index 20 of that remainder
// (fields 3..n), which is field 22 globally.
func procStartTime(pid int) (string, bool) {
	data, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid))
	if err != nil {
		return "", false
	}
	s := string(data)
	close := strings.LastIndex(s, ")")
	if close < 0 || close+1 >= len(s) {
		return "", false
	}
	rest := strings.Fields(s[close+1:])
	// rest[0] = state (field 3); starttime (field 22) is index 19 here:
	// field N maps to rest[N-3]. 22 - 3 = 19.
	const startTimeIdx = 19
	if len(rest) <= startTimeIdx {
		return "", false
	}
	return rest[startTimeIdx], true
}

// readKittyPID parses /proc/<pid>/environ (NUL-separated KEY=VALUE pairs) and
// returns the KITTY_PID value. Returns 0 if the environ is unreadable, the key
// is absent, or the value is not an integer.
func readKittyPID(pid int) int {
	data, err := os.ReadFile(fmt.Sprintf("/proc/%d/environ", pid))
	if err != nil {
		return 0
	}
	for _, kv := range strings.Split(string(data), "\x00") {
		if v, ok := strings.CutPrefix(kv, "KITTY_PID="); ok {
			n, convErr := strconv.Atoi(strings.TrimSpace(v))
			if convErr != nil {
				return 0
			}
			return n
		}
	}
	return 0
}

// readGoal reads goals/<sessionID>.json and returns its goal, phase, emojis,
// manual rename, DoD contract, DoD status and role. If the file is absent or
// unparseable, all are "".
func readGoal(goalsDir, sessionID string) (goal, phase, emojis, rename, dodContract, dodStatus, role string) {
	raw, err := os.ReadFile(filepath.Join(goalsDir, sessionID+".json"))
	if err != nil {
		return "", "", "", "", "", "", ""
	}
	var g goalFile
	if json.Unmarshal(raw, &g) != nil {
		return "", "", "", "", "", "", ""
	}
	return g.Goal, g.Phase, g.Emojis, g.Rename, g.DodContract, g.DodStatus, g.Role
}

// readCtxPct reads runtime/<sessionID>.json and returns the context-window used
// percentage. Returns -1 if the file is absent or unparseable (unknown).
func readCtxPct(runtimeDir, sessionID string) int {
	raw, err := os.ReadFile(filepath.Join(runtimeDir, sessionID+".json"))
	if err != nil {
		return -1
	}
	var r runtimeFile
	if json.Unmarshal(raw, &r) != nil {
		return -1
	}
	return r.CtxPct
}

// deriveTarget builds sessionID[:8] + "@" + basename(cwd). If sessionID is
// shorter than 8 runes it is used whole.
func deriveTarget(sessionID, cwd string) string {
	short := sessionID
	if r := []rune(sessionID); len(r) >= 8 {
		short = string(r[:8])
	}
	return short + "@" + filepath.Base(cwd)
}

// deriveRename returns goal truncated to 48 runes if non-empty, else
// basename(cwd).
func deriveRename(goal, cwd string) string {
	if goal != "" {
		return truncateRunes(goal, 48)
	}
	return filepath.Base(cwd)
}

// truncateRunes returns s capped at max runes (no ellipsis).
func truncateRunes(s string, max int) string {
	r := []rune(s)
	if len(r) <= max {
		return s
	}
	return string(r[:max])
}

// sortFleet orders the fleet by status rank then by UpdatedAt descending.
func sortFleet(fleet []ClaudeFleet) {
	rank := func(status string) int {
		switch status {
		case "idle":
			return 0
		case "waiting":
			return 1
		case "busy":
			return 2
		default:
			return 3
		}
	}
	sort.SliceStable(fleet, func(i, j int) bool {
		ri, rj := rank(fleet[i].Status), rank(fleet[j].Status)
		if ri != rj {
			return ri < rj
		}
		return fleet[i].UpdatedAt > fleet[j].UpdatedAt
	})
}
