package hooks

import (
	"encoding/json"
	"os"
	"path/filepath"
	"strings"

	"github.com/zzet/gortex/internal/platform"
)

// sessionState is the small per-session record the PreToolUse hook
// persists across individual tool calls. Claude Code invokes the hook
// as a fresh process per tool call, so any cross-call signal (has the
// agent consulted the graph yet? how long is the non-symbolic streak?)
// has to round-trip through disk keyed by session_id.
//
// Every field must be safe to read as its zero value — a missing or
// corrupt state file degrades to "fresh session", never an error.
type sessionState struct {
	// GraphConsulted records that the agent has invoked at least one
	// Gortex MCP tool this session. ModeConsultUnlock keys the
	// deny→additionalContext downgrade on it.
	GraphConsulted bool `json:"graph_consulted,omitempty"`
	// NonSymbolicStreak counts consecutive non-symbolic fallback tool
	// calls (Read / Grep / Glob) since the last symbolic call or nudge.
	// ModeAdaptiveNudge fires a soft-deny when it crosses the threshold.
	NonSymbolicStreak int `json:"non_symbolic_streak,omitempty"`
}

// hookSessionDirEnvVar lets tests redirect the per-session state
// directory, parallel to GORTEX_HOOK_LOG for telemetry.
const hookSessionDirEnvVar = "GORTEX_HOOK_SESSION_DIR"

// sessionStateDir returns the directory holding per-session state
// files. Honors GORTEX_HOOK_SESSION_DIR so tests can point it at a
// t.TempDir(). An absolute $XDG_CACHE_HOME is honoured; otherwise the
// directory stays under os.UserCacheDir() as before. Returns "" when no
// base directory can be resolved — all callers treat "" as "state
// disabled" and degrade gracefully.
func sessionStateDir() string {
	if p := strings.TrimSpace(os.Getenv(hookSessionDirEnvVar)); p != "" {
		return p
	}
	if v := os.Getenv("XDG_CACHE_HOME"); v == "" || !filepath.IsAbs(v) {
		if cacheDir, err := os.UserCacheDir(); err != nil || cacheDir == "" {
			return ""
		}
	}
	return filepath.Join(platform.OSCacheDir(), "sessions")
}

// sanitizeSessionID reduces an arbitrary session_id to a safe single
// path segment: only [A-Za-z0-9._-] survive, everything else becomes
// '_'. Guards against path traversal ("../") and separators in a
// session_id that originates from the untrusted hook payload.
func sanitizeSessionID(id string) string {
	id = strings.TrimSpace(id)
	if id == "" {
		return ""
	}
	var b strings.Builder
	for _, r := range id {
		switch {
		case r >= 'a' && r <= 'z',
			r >= 'A' && r <= 'Z',
			r >= '0' && r <= '9',
			r == '.' || r == '_' || r == '-':
			b.WriteRune(r)
		default:
			b.WriteByte('_')
		}
	}
	out := b.String()
	// A name that sanitizes to all dots ("." / "..") is still unsafe
	// as a path segment — neutralize it.
	if out == "" || strings.Trim(out, ".") == "" {
		return ""
	}
	return out
}

// sessionStatePath returns the JSON file path for a session, or "" when
// the session ID is empty/unusable or no base directory is available.
func sessionStatePath(sessionID string) string {
	safe := sanitizeSessionID(sessionID)
	if safe == "" {
		return ""
	}
	dir := sessionStateDir()
	if dir == "" {
		return ""
	}
	return filepath.Join(dir, safe+".json")
}

// loadSessionState reads the per-session record. Best-effort: an empty
// session ID, a missing file, or any read/decode error all yield a
// zero-value sessionState. The hook must never block on state I/O.
func loadSessionState(sessionID string) sessionState {
	path := sessionStatePath(sessionID)
	if path == "" {
		return sessionState{}
	}
	data, err := os.ReadFile(path)
	if err != nil {
		return sessionState{}
	}
	var st sessionState
	if err := json.Unmarshal(data, &st); err != nil {
		return sessionState{}
	}
	return st
}

// saveSessionState writes the per-session record. Best-effort, mirroring
// telemetry.go: every error is swallowed so a read-only cache dir or a
// full disk can never stop a tool call from proceeding.
func saveSessionState(sessionID string, st sessionState) {
	path := sessionStatePath(sessionID)
	if path == "" {
		return
	}
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		return
	}
	data, err := json.Marshal(st)
	if err != nil {
		return
	}
	_ = os.WriteFile(path, data, 0o644)
}
