package cline

import (
	"context"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"strings"

	"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil"
	"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)

// Cline's hook system is git-style: each lifecycle hook is an executable script
// placed in the workspace-local `.clinerules/hooks/` directory, named exactly
// after the hook event (no extension), reading a JSON payload on stdin and
// writing a JSON result on stdout (see docs.cline.bot hooks reference).
//
// AO installs one wrapper script per managed event. Each script forwards the
// hook payload to `ao hooks cline <subcommand>` and emits the no-op
// continuation result Cline expects. Scripts carry a marker line so install is
// idempotent and uninstall recognizes AO-owned scripts without an embedded
// template to diff against; user-authored hooks (lacking the marker) are never
// touched.
const (
	clineHooksDirName = ".clinerules"
	clineHooksSubDir  = "hooks"

	// clineHookCommandPrefix identifies the hook commands AO owns. The CLI hook
	// dispatcher routes "ao hooks cline <subcommand>" to DeriveActivityState.
	clineHookCommandPrefix = "ao hooks cline "

	// clineHookMarker tags AO-generated hook scripts so install/uninstall can
	// distinguish them from user-authored Cline hooks in the same directory.
	clineHookMarker = "# ao-managed-cline-hook"
)

// clineHookSpec describes one hook AO installs: the native Cline hook event
// (used as the script's filename) and the AO sub-command its wrapper forwards
// to (used by DeriveActivityState).
type clineHookSpec struct {
	// Event is the native Cline hook name, which is also the script filename.
	Event string
	// Subcommand is the fixed AO hook sub-command name the wrapper invokes.
	Subcommand string
}

// clineManagedHooks is the source of truth for the hooks AO installs. The
// native Cline events are mapped onto AO's fixed sub-command names so activity
// derivation stays uniform across adapters:
//   - TaskStart        -> session-start       (a new task begins: active)
//   - UserPromptSubmit -> user-prompt-submit  (user message submitted: active)
//   - PreToolUse       -> permission-request  (about to act: approval point)
//   - TaskCancel       -> stop                (task cancelled/aborted: idle)
var clineManagedHooks = []clineHookSpec{
	{Event: "TaskStart", Subcommand: "session-start"},
	{Event: "UserPromptSubmit", Subcommand: "user-prompt-submit"},
	{Event: "PreToolUse", Subcommand: "permission-request"},
	{Event: "TaskCancel", Subcommand: "stop"},
}

// GetAgentHooks installs AO's Cline hook scripts into the worktree-local
// `.clinerules/hooks/` directory. Existing user-authored hook scripts are
// preserved, and re-running install simply rewrites AO-owned scripts in place.
func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error {
	if err := ctx.Err(); err != nil {
		return err
	}
	if strings.TrimSpace(cfg.WorkspacePath) == "" {
		return errors.New("cline.GetAgentHooks: WorkspacePath is required")
	}

	hooksDir := clineHooksDir(cfg.WorkspacePath)
	if err := os.MkdirAll(hooksDir, 0o750); err != nil {
		return fmt.Errorf("cline.GetAgentHooks: create hook dir: %w", err)
	}

	// Only scripts AO actually wrote go into the workspace .gitignore: a
	// user-authored script at one of these paths must keep counting as dirt so
	// workspace teardown preserves it.
	written := make([]string, 0, len(clineManagedHooks))
	for _, spec := range clineManagedHooks {
		scriptPath := filepath.Join(hooksDir, spec.Event)
		// Never clobber a user-authored hook with the same event name.
		if fileExists(scriptPath) && !isManagedClineHook(scriptPath) {
			continue
		}
		script := renderClineHookScript(spec.Subcommand)
		if err := atomicWriteFile(scriptPath, []byte(script), 0o700); err != nil {
			return fmt.Errorf("cline.GetAgentHooks: write %s: %w", spec.Event, err)
		}
		written = append(written, spec.Event)
	}
	if err := hookutil.EnsureWorkspaceGitignore(hooksDir, written...); err != nil {
		return fmt.Errorf("cline.GetAgentHooks: gitignore: %w", err)
	}
	return nil
}

// UninstallHooks removes AO's Cline hook scripts from the workspace-local
// `.clinerules/hooks/` directory, leaving user-authored hooks untouched. A
// missing directory is a no-op.
func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error {
	if err := ctx.Err(); err != nil {
		return err
	}
	if strings.TrimSpace(workspacePath) == "" {
		return errors.New("cline.UninstallHooks: workspacePath is required")
	}

	hooksDir := clineHooksDir(workspacePath)
	if _, err := os.Stat(hooksDir); errors.Is(err, os.ErrNotExist) {
		return nil
	}

	for _, spec := range clineManagedHooks {
		scriptPath := filepath.Join(hooksDir, spec.Event)
		if !fileExists(scriptPath) || !isManagedClineHook(scriptPath) {
			continue
		}
		if err := os.Remove(scriptPath); err != nil && !errors.Is(err, os.ErrNotExist) {
			return fmt.Errorf("cline.UninstallHooks: remove %s: %w", spec.Event, err)
		}
	}
	return nil
}

// AreHooksInstalled reports whether any AO Cline hook script is present in the
// workspace-local hooks directory. A missing directory means none.
func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) {
	if err := ctx.Err(); err != nil {
		return false, err
	}
	if strings.TrimSpace(workspacePath) == "" {
		return false, errors.New("cline.AreHooksInstalled: workspacePath is required")
	}

	hooksDir := clineHooksDir(workspacePath)
	if _, err := os.Stat(hooksDir); errors.Is(err, os.ErrNotExist) {
		return false, nil
	}

	for _, spec := range clineManagedHooks {
		scriptPath := filepath.Join(hooksDir, spec.Event)
		if fileExists(scriptPath) && isManagedClineHook(scriptPath) {
			return true, nil
		}
	}
	return false, nil
}

func clineHooksDir(workspacePath string) string {
	return filepath.Join(workspacePath, clineHooksDirName, clineHooksSubDir)
}

// renderClineHookScript builds an executable wrapper that forwards the Cline
// hook payload (JSON on stdin) to the AO CLI hook dispatcher and prints the
// no-op continuation result Cline expects ({"cancel": false}). The marker line
// identifies it as AO-owned.
func renderClineHookScript(subcommand string) string {
	var b strings.Builder
	b.WriteString("#!/usr/bin/env bash\n")
	b.WriteString(clineHookMarker + "\n")
	// Forward stdin to the AO dispatcher; ignore its exit code so a missing/old
	// `ao` binary can never block Cline's own execution.
	b.WriteString(clineHookCommandPrefix + subcommand + " || true\n")
	// Cline requires a JSON result on stdout; never block the agent.
	b.WriteString(`echo '{"cancel": false}'` + "\n")
	return b.String()
}

func isManagedClineHook(scriptPath string) bool {
	data, err := os.ReadFile(scriptPath) //nolint:gosec // path built from caller-owned workspace dir
	if err != nil {
		return false
	}
	return strings.Contains(string(data), clineHookMarker)
}

// atomicWriteFile writes data to path via a temp file + rename, so a crash mid-
// write can't leave a truncated script that Cline then fails to execute.
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
	tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*")
	if err != nil {
		return err
	}
	tmpName := tmp.Name()
	defer func() { _ = os.Remove(tmpName) }()
	if _, err := tmp.Write(data); err != nil {
		_ = tmp.Close()
		return err
	}
	if err := tmp.Chmod(perm); err != nil {
		_ = tmp.Close()
		return err
	}
	if err := tmp.Close(); err != nil {
		return err
	}
	return os.Rename(tmpName, path)
}
