// This file implements Runner.Run: executing a single hook command via the
// system shell with the event payload on stdin, a per-hook timeout, bounded
// output capture, and exit-code classification. It is the one place that forks
// a process, so all isolation guarantees (timeout kill, output cap, error
// containment) live here.
package hooks

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"os"
	"os/exec"
	"strings"
	"time"
)

// MaxOutputBytes caps the bytes captured from a hook's stdout and stderr each
// (FR-13). Output beyond this is dropped and the truncation is flagged; the
// captured prefix is still parsed so a hook that prints a small JSON decision
// followed by noise still works.
const MaxOutputBytes = 1 << 20 // 1 MB

// blockExitCode is the exit code that signals a block (Claude Code semantics):
// the command exited 2, stderr carries the reason.
const blockExitCode = 2

// Runner executes hook commands. Shell defaults to "sh" with a "-c" flag; it is
// a field so tests can substitute a shell and future platforms can override it.
// ProjectDir is the working directory hook commands run in (the project root).
// ExtraEnv is appended to the process environment (PIGO_* variables).
type Runner struct {
	Shell      string
	ProjectDir string
	WarnLog    io.Writer
}

// Run executes one hook, writing input as a single-line JSON document to the
// command's stdin. It returns the parsed HookOutput and a non-nil error only
// for an execution *failure* (could not start, timed out, or exited non-zero
// and non-2). A clean exit 0 or a block (exit 2) both return err == nil; the
// caller distinguishes a block via HookOutput.blocks(). The PIGO_* environment
// variables are injected on top of the current process environment.
func (r *Runner) Run(ctx context.Context, h HookConfig, input HookInput) (HookOutput, error) {
	payload, err := json.Marshal(input)
	if err != nil {
		return HookOutput{}, fmt.Errorf("marshal hook input: %w", err)
	}

	timeout := time.Duration(h.TimeoutSeconds()) * time.Second
	runCtx, cancel := context.WithTimeout(ctx, timeout)
	defer cancel()

	shell := r.Shell
	if shell == "" {
		shell = "sh"
	}
	cmd := exec.CommandContext(runCtx, shell, "-c", h.Command)
	cmd.Dir = r.ProjectDir
	cmd.Env = r.env(input)
	cmd.Stdin = bytes.NewReader(payload)
	// On timeout, CommandContext kills the shell, but a grandchild it spawned
	// (e.g. `sh -c "sleep 5"` forking sleep) can inherit the stdout/stderr pipes
	// and keep them open, blocking cmd.Run until that grandchild exits on its own
	// — so Run would return only after the full command duration, not the timeout.
	// WaitDelay bounds that wait: after the process is killed, Go force-closes the
	// I/O pipes so Run returns promptly.
	cmd.WaitDelay = time.Second

	var stdout, stderr cappedBuffer
	stdout.limit = MaxOutputBytes
	stderr.limit = MaxOutputBytes
	cmd.Stdout = &stdout
	cmd.Stderr = &stderr

	runErr := cmd.Run()

	if stdout.truncated() || stderr.truncated() {
		warnf(r.WarnLog, "pigo: hooks: output from command %q exceeded %d bytes and was truncated\n", h.Command, MaxOutputBytes)
	}

	// Timeout: the context deadline fired and the process was killed.
	if runCtx.Err() == context.DeadlineExceeded {
		return HookOutput{}, fmt.Errorf("hook timed out after %s", timeout)
	}

	exitCode := 0
	if runErr != nil {
		var ee *exec.ExitError
		if errors.As(runErr, &ee) {
			exitCode = ee.ExitCode()
		} else {
			// Could not start (ENOENT etc.) or was killed.
			return HookOutput{}, fmt.Errorf("hook failed to run: %w", runErr)
		}
	}

	switch exitCode {
	case 0:
		out, _ := parseHookOutput(stdout.Bytes())
		return out, nil
	case blockExitCode:
		// Block: prefer a JSON decision if present, else synthesize one from
		// stderr as the reason.
		if out, ok := parseHookOutput(stdout.Bytes()); ok {
			if out.Reason == "" {
				out.Reason = strings.TrimSpace(string(stderr.Bytes()))
			}
			out.Decision = "block"
			return out, nil
		}
		return HookOutput{Decision: "block", Reason: strings.TrimSpace(string(stderr.Bytes()))}, nil
	default:
		return HookOutput{}, fmt.Errorf("hook exited with code %d: %s", exitCode, strings.TrimSpace(string(stderr.Bytes())))
	}
}

// env builds the command environment: the current process environment plus the
// PIGO_* variables derived from the input.
func (r *Runner) env(input HookInput) []string {
	env := append([]string(nil), os.Environ()...)
	env = append(env,
		"PIGO_SESSION_ID="+input.SessionID,
		"PIGO_PROJECT_DIR="+r.ProjectDir,
		"PIGO_EVENT_TYPE="+input.EventType,
	)
	return env
}

// cappedBuffer is an io.Writer that stores at most limit bytes and counts how
// many it dropped, so hook output cannot exhaust memory (FR-13).
type cappedBuffer struct {
	buf     bytes.Buffer
	limit   int
	dropped int
}

func (c *cappedBuffer) Write(p []byte) (int, error) {
	if c.limit <= 0 {
		return c.buf.Write(p)
	}
	room := c.limit - c.buf.Len()
	if room <= 0 {
		c.dropped += len(p)
		return len(p), nil
	}
	if len(p) > room {
		c.buf.Write(p[:room])
		c.dropped += len(p) - room
		return len(p), nil
	}
	return c.buf.Write(p)
}

// Bytes returns the captured (possibly truncated) output.
func (c *cappedBuffer) Bytes() []byte { return c.buf.Bytes() }

// truncated reports whether any output was dropped by the cap.
func (c *cappedBuffer) truncated() bool { return c.dropped > 0 }
