// Package memorywatcher observes the per-agent memory clone and turns settled
// filesystem activity into commits on the configured memory branch. It is
// intentionally isolated from the dispatcher: the dispatcher hands the watcher a
// ContextResolver callback so the watcher never imports dispatcher types
// directly, which keeps the package testable in isolation.
package memorywatcher

import (
	"errors"
	"fmt"
	"strings"
)

// ErrNothingToCommit mirrors vault.ErrNothingToCommit. The dispatcher's
// Committer adapter must translate the vault sentinel to this local one so
// the watcher does not import vault.
var ErrNothingToCommit = errors.New("memorywatcher: nothing to commit")

// ErrNothingToPush mirrors vault.ErrNothingToPush. The dispatcher's Pusher
// adapter must translate the vault sentinel to this local one.
var ErrNothingToPush = errors.New("memorywatcher: nothing to push")

// CommitContext is the conversation context attributed to the next memory
// commit. Populated by the dispatcher's ContextResolver at commit time.
//
// Empty Channel/Thread/Task produces a generic "local edits" commit message.
// Agent identity belongs in the commit author and memory binding, not in
// message trailers.
type CommitContext struct {
	// AgentID is the agent whose memory is being committed (e.g. agt_alice).
	AgentID string

	// ActorName is the display name of the actor whose input triggered the
	// wake (human or agent). Populates the commit Author field. Empty falls
	// back to the vault default author identity.
	ActorName string

	// ChannelID is the AgentTeam channel target, e.g. "#engineering". Empty when
	// the wake originated from a DM.
	ChannelID string
	// ChannelType is "channel", "thread", "dm", or "task". Empty means we
	// could not classify the wake (e.g., background sweep).
	ChannelType string

	// ThreadTarget is the thread-scoped target, e.g. "#engineering:abc123".
	// Empty when the wake was on a bare channel, DM, or task scope.
	ThreadTarget string
	// ThreadTitle is an optional human-readable thread subject. Surfaced in
	// the commit body when present so memory history reads naturally.
	ThreadTitle string

	// TaskScope is the raw task scope target, e.g. "#tsk_xxx". Empty when
	// the wake was not on a task scope.
	TaskScope string
	// TaskNumber is the human-readable task number (e.g. 9 for #t9). Zero
	// when not applicable.
	TaskNumber int64
	// TaskTitle is an optional short task title.
	TaskTitle string

	// TriggerSource is the internal provenance label that made this task target
	// active (for example "task_tool_call" or "task_dispatch"). It is useful in
	// the body, but must never be used as the "from ..." scope in the subject.
	TriggerSource string
}

// Trailers returns the canonical AgentTeam-* trailer block for this context.
// Trailers are emitted in a fixed order so callers can diff the resulting
// commit message deterministically. Empty context yields an empty slice.
func (c CommitContext) Trailers() []string {
	var out []string
	if c.ChannelID != "" {
		out = append(out, "AgentTeam-Channel: "+c.ChannelID)
	}
	if c.ThreadTarget != "" {
		out = append(out, "AgentTeam-Thread: "+c.ThreadTarget)
	}
	if task := c.taskRef(); task != "" {
		out = append(out, "AgentTeam-Task: "+task)
	}
	if c.TaskScope != "" {
		out = append(out, "AgentTeam-Task-Scope: "+c.TaskScope)
	}
	return out
}

// AmendKey returns the canonical amend discriminator for this context. Two
// contexts share an AmendKey when they originated from the same conversation
// surface — same channel, same thread (if any), and same task (if any).
// The watcher amends the previous commit when the new context's AmendKey
// equals the previous commit's trailers-derived key, so back-to-back edits
// in the same thread produce a single squashed commit.
func (c CommitContext) AmendKey() string {
	parts := []string{
		"ch=" + c.ChannelID,
		"th=" + c.ThreadTarget,
		"tk=" + c.taskAmendIdentity(),
	}
	return strings.Join(parts, "|")
}

// taskRef returns the human-readable task identity used in visible trailers.
// A task number is only unique inside its channel/thread container, so callers
// must combine this with ChannelID/ThreadTarget for a complete identity.
func (c CommitContext) taskRef() string {
	if c.TaskNumber > 0 {
		return fmt.Sprintf("#t%d", c.TaskNumber)
	}
	return c.TaskScope
}

// taskAmendIdentity preserves amend continuity with older commits that used
// the raw task-scope target as AgentTeam-Task. When we know the raw scope, prefer it
// for the amend discriminator; otherwise fall back to the human task ref.
func (c CommitContext) taskAmendIdentity() string {
	if c.TaskScope != "" {
		return c.TaskScope
	}
	return c.taskRef()
}

// BuildCommitMessage formats the full commit message — subject + body +
// trailers — for the given context:
//
//	<subject>
//
//	<body: large → small scope, with titles>
//
//	AgentTeam-Channel: ...
//	AgentTeam-Thread: ...
//	AgentTeam-Task: ...
//
// The subject is short and channel/thread/task-aware. The body always
// includes the channel (or "Direct Message") line first; thread/task lines
// follow in that order. Titles are included when present so a memory history
// scan shows enough context to identify each commit without re-fetching the
// server.
func BuildCommitMessage(ctx CommitContext) string {
	subject := buildSubject(ctx)
	body := buildBody(ctx)
	trailers := ctx.Trailers()

	var b strings.Builder
	b.WriteString(subject)
	if body != "" {
		b.WriteString("\n\n")
		b.WriteString(body)
	}
	if len(trailers) > 0 {
		b.WriteString("\n\n")
		b.WriteString(strings.Join(trailers, "\n"))
	}
	b.WriteString("\n")
	return b.String()
}

// buildSubject returns the one-line subject. Shapes:
//   - "Update memory from task #t9 in #engineering:abc123" — task in thread
//   - "Update memory from task #t9 in #engineering"        — task in channel
//   - "Update memory from thread #engineering:abc123"      — thread
//   - "Update memory from channel #engineering"            — channel
//   - "Update memory from DM"                              — direct message
//   - "Update memory from local edits"                     — no context
func buildSubject(ctx CommitContext) string {
	switch {
	case ctx.taskRef() != "":
		subject := "Update memory from task " + ctx.taskRef()
		if container := ctx.containerTarget(); container != "" {
			subject += " in " + container
		}
		return subject
	case ctx.ThreadTarget != "":
		return "Update memory from thread " + ctx.ThreadTarget
	case ctx.ChannelID != "":
		return "Update memory from channel " + ctx.ChannelID
	case strings.EqualFold(ctx.ChannelType, "dm"):
		return "Update memory from DM"
	default:
		return "Update memory from local edits"
	}
}

func (c CommitContext) containerTarget() string {
	switch {
	case c.ThreadTarget != "":
		return c.ThreadTarget
	case c.ChannelID != "":
		return c.ChannelID
	case strings.EqualFold(c.ChannelType, "dm"):
		return "DM"
	default:
		return ""
	}
}

// buildBody returns the multi-line body section in large→small scope order.
// Returns empty string when there is no meaningful body to emit (e.g., a
// pure local-edits commit has no body beyond the subject).
func buildBody(ctx CommitContext) string {
	var lines []string

	switch {
	case ctx.ChannelID != "":
		lines = append(lines, fmt.Sprintf("Channel: %s", ctx.ChannelID))
	case strings.EqualFold(ctx.ChannelType, "dm"):
		lines = append(lines, "Conversation: Direct Message")
	}

	if ctx.ThreadTarget != "" {
		if ctx.ThreadTitle != "" {
			lines = append(lines, fmt.Sprintf("Thread: %s — %s", ctx.ThreadTarget, ctx.ThreadTitle))
		} else {
			lines = append(lines, fmt.Sprintf("Thread: %s", ctx.ThreadTarget))
		}
	}

	if ctx.TaskNumber > 0 || ctx.TaskScope != "" {
		// Task line always carries the channel per the agreed shape — the
		// task ref alone is not enough to identify which conversation the
		// memory change came from.
		taskLine := taskLine(ctx)
		lines = append(lines, taskLine)
	}

	if ctx.TriggerSource != "" {
		lines = append(lines, fmt.Sprintf("Trigger: %s", ctx.TriggerSource))
	}

	if len(lines) == 0 {
		return ""
	}
	return strings.Join(lines, "\n")
}

// taskLine formats the Task: body line. The shape varies based on what we
// actually know about the task. Examples:
//
//	Task: #t9 in #engineering — Fix reply routing
//	Task: #t9 in #engineering
//	Task: #tsk_xxx in #engineering
//	Task: #t9 — Fix reply routing        (no channel — rare fallback)
func taskLine(ctx CommitContext) string {
	var head string
	switch {
	case ctx.TaskNumber > 0 && ctx.TaskScope != "":
		head = fmt.Sprintf("#t%d (%s)", ctx.TaskNumber, ctx.TaskScope)
	case ctx.TaskNumber > 0:
		head = fmt.Sprintf("#t%d", ctx.TaskNumber)
	case ctx.TaskScope != "":
		head = ctx.TaskScope
	}

	var where string
	if container := ctx.containerTarget(); container != "" {
		where = " in " + container
	}
	if where == "" && strings.EqualFold(ctx.ChannelType, "dm") {
		where = " in DM"
	}

	var title string
	if ctx.TaskTitle != "" {
		title = " — " + ctx.TaskTitle
	}

	return fmt.Sprintf("Task: %s%s%s", head, where, title)
}

// AmendKeyFromTrailers reconstructs an AmendKey from a previously-committed
// message's trailers. Used by the watcher to decide whether the incoming
// context should amend or create a new commit. Returns empty string when
// none of the AgentTeam-* trailers are present (e.g. an unrelated prior commit).
func AmendKeyFromTrailers(trailers map[string]string) string {
	if len(trailers) == 0 {
		return ""
	}
	task := trailers["AgentTeam-Task-Scope"]
	if task == "" {
		task = trailers["AgentTeam-Task"]
	}
	parts := []string{
		"ch=" + trailers["AgentTeam-Channel"],
		"th=" + trailers["AgentTeam-Thread"],
		"tk=" + task,
	}
	// Empty when no relevant trailers were present.
	if trailers["AgentTeam-Channel"] == "" && trailers["AgentTeam-Thread"] == "" && trailers["AgentTeam-Task"] == "" && trailers["AgentTeam-Task-Scope"] == "" {
		return ""
	}
	return strings.Join(parts, "|")
}

// parseTrailers extracts RFC822-style "Key: Value" lines from the trailer
// block of a commit message. The trailer block is the last paragraph of the
// message, AND every line in it must match `^[A-Za-z0-9-]+: .*`. If any
// line in the last paragraph does not look like a trailer, the paragraph is
// treated as body text and an empty map is returned.
//
// This is deliberately stricter than git's trailer parser: we only ever
// emit well-formed AgentTeam-* trailers ourselves, so we don't need to handle
// continuation lines or mixed body/trailer paragraphs.
func parseTrailers(message string) map[string]string {
	if message == "" {
		return nil
	}
	message = strings.TrimRight(message, "\n")
	// Last paragraph is everything after the final blank line.
	idx := strings.LastIndex(message, "\n\n")
	var last string
	if idx < 0 {
		last = message
	} else {
		last = message[idx+2:]
	}
	if last == "" {
		return nil
	}

	out := make(map[string]string)
	for _, line := range strings.Split(last, "\n") {
		key, value, ok := splitTrailer(line)
		if !ok {
			return nil
		}
		out[key] = value
	}
	if len(out) == 0 {
		return nil
	}
	return out
}

// splitTrailer parses a single "Key: Value" line. Returns ok=false when the
// line does not match the trailer shape. The first character of the key must
// be a letter; subsequent characters may be alphanumeric or dash. This
// rejects degenerate inputs like "-: value" while accepting "AgentTeam-Channel".
func splitTrailer(line string) (key, value string, ok bool) {
	i := strings.Index(line, ":")
	if i <= 0 {
		return "", "", false
	}
	key = line[:i]
	value = strings.TrimSpace(line[i+1:])
	if len(key) == 0 {
		return "", "", false
	}
	for idx, r := range key {
		isLetter := (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z')
		isDigit := r >= '0' && r <= '9'
		isDash := r == '-'
		if idx == 0 {
			if !isLetter {
				return "", "", false
			}
			continue
		}
		if !isLetter && !isDigit && !isDash {
			return "", "", false
		}
	}
	return key, value, true
}
