// This file distributes a classified pi extension (including MCP adapters) into
// pigo's plugins directory so internal/plugin.Discover picks it up (#158).
//
// plugin.Discover launches every *executable regular file* directly inside
// $PIGO_HOME/plugins, ignoring subdirectories. An npm extension, however, is a
// whole package tree with a "bin" entry pointing at its real entrypoint, and
// that entrypoint usually needs its sibling files present to run. So we cannot
// just drop a single file in.
//
// The layout we lay down reconciles the two:
//
//	$PIGO_HOME/plugins/<name>.pkg/     ← full extracted package (a dir; Discover skips it)
//	$PIGO_HOME/plugins/<name>          ← executable launcher (a file; Discover runs it)
//
// The launcher is a tiny shell script that execs the package's bin entrypoint,
// forwarding argv. The bin file is made executable and relies on its own
// shebang (matching how npm itself installs bins), so both Node scripts and
// native binaries work. Every file laid down is returned so the lockfile can
// remove exactly what was created on uninstall.
package pkgmgr

import (
	"encoding/json"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"runtime"
	"strings"

	"github.com/smallnest/pigo/internal/pihost"
)

// DistributeExtension copies the extension package at pkgDir into the plugins
// directory and writes a launcher that plugin.Discover will run. It returns the
// absolute paths of every file (and the payload dir) it created, for the
// lockfile. An empty plugins dir (home unavailable) is an error.
func DistributeExtension(pkgDir, name string) ([]string, error) {
	if runtime.GOOS == "windows" {
		return nil, fmt.Errorf("pkgmgr: extension install is not supported on windows yet")
	}
	pluginsDir := PluginsDir()
	if pluginsDir == "" {
		return nil, fmt.Errorf("pkgmgr: cannot resolve plugins dir (PIGO_HOME/home unavailable)")
	}

	binRel, err := extensionBin(pkgDir, name)
	if err != nil {
		return nil, err
	}

	if err := os.MkdirAll(pluginsDir, 0o755); err != nil {
		return nil, fmt.Errorf("pkgmgr: create plugins dir: %w", err)
	}

	payloadDir := filepath.Join(pluginsDir, name+".pkg")
	// A stale payload from a prior install must not shadow the new one.
	if err := os.RemoveAll(payloadDir); err != nil {
		return nil, fmt.Errorf("pkgmgr: clear old payload %q: %w", payloadDir, err)
	}
	created, err := copyTree(pkgDir, payloadDir)
	if err != nil {
		return nil, err
	}

	// Make the bin entrypoint executable; it carries its own shebang.
	binAbs := filepath.Join(payloadDir, filepath.FromSlash(binRel))
	if err := os.Chmod(binAbs, 0o755); err != nil {
		return nil, fmt.Errorf("pkgmgr: chmod bin %q: %w", binAbs, err)
	}

	launcher := filepath.Join(pluginsDir, name)

	// A pi extension is a JS module loaded by pi's runtime, not a native binary
	// or JSON-RPC server, so it cannot be exec'd directly. Instead we drop the
	// embedded Node host next to the payload and point the launcher at it. A
	// native/JSON-RPC plugin keeps the historical direct-exec launcher.
	if isPiExtension(pkgDir, binRel) {
		hostAbs := filepath.Join(payloadDir, ".pihost.mjs")
		if err := os.WriteFile(hostAbs, pihost.Script, 0o644); err != nil {
			return nil, fmt.Errorf("pkgmgr: write pi host %q: %w", hostAbs, err)
		}
		script := fmt.Sprintf(
			"#!/bin/sh\n"+
				"command -v node >/dev/null 2>&1 || { echo \"pigo: node not found on PATH; pi extension %s skipped\" >&2; exit 127; }\n"+
				"exec node %s %s \"$@\"\n",
			name, shellQuote(hostAbs), shellQuote(payloadDir),
		)
		if err := os.WriteFile(launcher, []byte(script), 0o755); err != nil {
			return nil, fmt.Errorf("pkgmgr: write launcher %q: %w", launcher, err)
		}
		created = append(created, payloadDir, hostAbs, launcher)
		return created, nil
	}

	script := fmt.Sprintf("#!/bin/sh\nexec %s \"$@\"\n", shellQuote(binAbs))
	if err := os.WriteFile(launcher, []byte(script), 0o755); err != nil {
		return nil, fmt.Errorf("pkgmgr: write launcher %q: %w", launcher, err)
	}

	created = append(created, payloadDir, launcher)
	return created, nil
}

// isPiExtension reports whether the entrypoint should be run under pigo's Node
// host rather than exec'd directly. The pi.extensions declaration is
// authoritative and checked first; otherwise the resolved bin extension
// (.js/.mjs/.cjs) identifies a JS module that needs the host. Native binaries
// and self-hosted JSON-RPC servers match neither and keep the direct launcher.
func isPiExtension(pkgDir, binRel string) bool {
	if data, err := os.ReadFile(filepath.Join(pkgDir, "package.json")); err == nil {
		var pj struct {
			Pi struct {
				Extensions []string `json:"extensions"`
			} `json:"pi"`
		}
		if json.Unmarshal(data, &pj) == nil {
			for _, p := range pj.Pi.Extensions {
				if p != "" {
					return true
				}
			}
		}
	}
	switch strings.ToLower(filepath.Ext(binRel)) {
	case ".js", ".mjs", ".cjs":
		return true
	}
	return false
}

// extensionBin resolves the package's entrypoint (relative to the package root)
// from package.json. It prefers npm's "bin" field (a string, or a {command:
// path} object keyed by the package name), matching how npm installs bins. When
// there is no "bin" — the common case for pi extensions, which declare their
// entrypoint in the pi metadata rather than as an npm bin — it falls back to the
// first path listed in "pi.extensions", then to "main".
func extensionBin(pkgDir, name string) (string, error) {
	data, err := os.ReadFile(filepath.Join(pkgDir, "package.json"))
	if err != nil {
		return "", fmt.Errorf("pkgmgr: read package.json: %w", err)
	}
	var pj struct {
		Bin  json.RawMessage `json:"bin"`
		Main string          `json:"main"`
		Pi   struct {
			Extensions []string `json:"extensions"`
		} `json:"pi"`
	}
	if err := json.Unmarshal(data, &pj); err != nil {
		return "", fmt.Errorf("pkgmgr: parse package.json: %w", err)
	}

	// 1. npm "bin": string form.
	var s string
	if err := json.Unmarshal(pj.Bin, &s); err == nil && s != "" {
		return s, nil
	}
	// npm "bin": object form {command: path}.
	var m map[string]string
	if err := json.Unmarshal(pj.Bin, &m); err == nil && len(m) > 0 {
		if p, ok := m[name]; ok && p != "" {
			return p, nil
		}
		// npm-scoped name: bin key is often the unscoped base name.
		if p, ok := m[filepath.Base(name)]; ok && p != "" {
			return p, nil
		}
		for _, p := range m {
			if p != "" {
				return p, nil
			}
		}
	}

	// 2. pi metadata entrypoint: pi.extensions is the pi-ecosystem convention.
	for _, p := range pj.Pi.Extensions {
		if p != "" {
			return p, nil
		}
	}

	// 3. npm "main" as a last resort.
	if pj.Main != "" {
		return pj.Main, nil
	}

	return "", fmt.Errorf("pkgmgr: extension %q has no bin, pi.extensions, or main entrypoint in package.json", name)
}

// copyTree recursively copies src into dst (created), preserving file modes and
// relative structure. It returns the absolute paths of every regular file it
// wrote (not directories), so callers can record precisely what was laid down.
func copyTree(src, dst string) ([]string, error) {
	var files []string
	err := filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		rel, err := filepath.Rel(src, path)
		if err != nil {
			return err
		}
		target := filepath.Join(dst, rel)
		switch {
		case info.IsDir():
			return os.MkdirAll(target, 0o755)
		case info.Mode().IsRegular():
			if err := copyFile(path, target, info.Mode()); err != nil {
				return err
			}
			files = append(files, target)
			return nil
		default:
			// Skip symlinks/devices — npm packages are files + dirs.
			return nil
		}
	})
	if err != nil {
		return nil, fmt.Errorf("pkgmgr: copy package tree: %w", err)
	}
	return files, nil
}

// copyFile copies a single regular file from src to dst with the given mode.
func copyFile(src, dst string, mode os.FileMode) error {
	if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
		return err
	}
	in, err := os.Open(src)
	if err != nil {
		return err
	}
	defer in.Close()
	out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
	if err != nil {
		return err
	}
	if _, err := io.Copy(out, in); err != nil {
		out.Close()
		return err
	}
	return out.Close()
}

// shellQuote wraps s in single quotes for safe embedding in a /bin/sh script,
// escaping any embedded single quotes.
func shellQuote(s string) string {
	quoted := make([]byte, 0, len(s)+2)
	quoted = append(quoted, '\'')
	for i := 0; i < len(s); i++ {
		if s[i] == '\'' {
			quoted = append(quoted, '\'', '\\', '\'', '\'')
			continue
		}
		quoted = append(quoted, s[i])
	}
	quoted = append(quoted, '\'')
	return string(quoted)
}
