package packages

import (
	"context"
	"os/exec"
	"regexp"
	"strings"

	"nadir/internal/oscmd"

	"github.com/danielgtaylor/huma/v2"
	"github.com/danielgtaylor/huma/v2/sse"
)

const tagPackages = "Packages"

var (
	readErrors  = []int{401, 403, 500}
	writeErrors = []int{400, 401, 403, 500}
)

// pkgNameRe matches a plain package name (no version specifiers, no shell
// metacharacters). Combined with a leading-dash reject and "--" before the name
// on every command line, it keeps user input from being read as a flag.
var pkgNameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9@._+-]*$`)

// manager identifies the host package manager. Its name drives a switch in each
// operation rather than an interface - three concrete tools, no polymorphism.
type manager struct{ name string }

// detect picks the package manager. dnf/pacman are checked before apt because a
// few rpm-based boxes also ship an apt-get shim; the native tool should win.
func detect() manager {
	for _, m := range []struct{ name, bin string }{
		{"dnf", "dnf"},
		{"pacman", "pacman"},
		{"apt", "apt-get"},
	} {
		if _, err := exec.LookPath(m.bin); err == nil {
			return manager{name: m.name}
		}
	}
	return manager{}
}

type Package struct {
	Name    string `json:"name" example:"openssh-server" doc:"Package name"`
	Version string `json:"version" example:"9.6p1" doc:"Installed version, or the available version for updates"`
}

type ListOutput struct {
	Body struct {
		Manager  string    `json:"manager" example:"dnf" doc:"Detected package manager"`
		Packages []Package `json:"packages"`
	}
}

type InstallInput struct {
	Body struct {
		Name string `json:"name" example:"htop" doc:"Package to install"`
	}
}

type RemoveInput struct {
	Name string `path:"name" example:"htop" doc:"Package to remove"`
}

type UpgradeOneInput struct {
	Name string `path:"name" example:"htop" doc:"Package to upgrade"`
}

// SSE event types for streaming package operations.
type PkgOutputEvent struct {
	Line string `json:"line" doc:"One line of the package manager's terminal output"`
}

type PkgErrorEvent struct {
	Message string `json:"message"`
}

type PkgDoneEvent struct {
	Success bool   `json:"success" doc:"True if the package manager exited 0"`
	Error   string `json:"error,omitempty" doc:"Exit error when it failed"`
}

// pkgSem limits concurrent package manager operations. Package managers are
// heavy (dnf/apt/pacman grab a lock), so running more than a few in parallel
// just thrashes; 3 concurrent ops is generous for an admin panel.
var pkgSem = make(chan struct{}, 3)

// pkgEvents maps SSE event names to their payload types for the streaming
// install/remove/upgrade operations.
var pkgEvents = map[string]any{
	"output": PkgOutputEvent{},
	"done":   PkgDoneEvent{},
	"error":  PkgErrorEvent{},
}

func registerPackages(api huma.API, pm manager) {
	huma.Register(api, huma.Operation{
		OperationID: "packages-list-installed",
		Method:      "GET",
		Path:        "/api/packages",
		Summary:     "List installed packages",
		Tags:        []string{tagPackages},
		Metadata:    op("read"),
		Errors:      readErrors,
	}, func(ctx context.Context, _ *struct{}) (*ListOutput, error) {
		return listInstalled(pm)
	})

	huma.Register(api, huma.Operation{
		OperationID: "packages-list-updates",
		Method:      "GET",
		Path:        "/api/packages/updates",
		Summary:     "List available updates",
		Tags:        []string{tagPackages},
		Metadata:    op("read"),
		Errors:      readErrors,
	}, func(ctx context.Context, _ *struct{}) (*ListOutput, error) {
		return listUpdates(pm)
	})

	// Install/remove/upgrade stream the package manager's terminal output live
	// via SSE (one `output` event per line, a final `done` with exit status),
	// rather than blocking the request until a long operation finishes.
	sse.Register(api, huma.Operation{
		OperationID: "packages-install",
		Method:      "POST",
		Path:        "/api/packages",
		Summary:     "Install a package (streamed)",
		Description: "Installs a package, streaming the package manager's output as " +
			"`output` events and ending with a `done` event carrying the exit status.",
		Tags:     []string{tagPackages},
		Metadata: op("write"),
	}, pkgEvents, func(ctx context.Context, in *InstallInput, send sse.Sender) {
		if validateName(in.Body.Name) != nil {
			send.Data(PkgErrorEvent{Message: "invalid package name: " + in.Body.Name})
			return
		}
		bin, args := pm.installArgs(in.Body.Name)
		streamOp(ctx, send, bin, args)
	})

	sse.Register(api, huma.Operation{
		OperationID: "packages-remove",
		Method:      "DELETE",
		Path:        "/api/packages/{name}",
		Summary:     "Remove a package (streamed)",
		Tags:        []string{tagPackages},
		Metadata:    op("write"),
	}, pkgEvents, func(ctx context.Context, in *RemoveInput, send sse.Sender) {
		if validateName(in.Name) != nil {
			send.Data(PkgErrorEvent{Message: "invalid package name: " + in.Name})
			return
		}
		bin, args := pm.removeArgs(in.Name)
		streamOp(ctx, send, bin, args)
	})

	sse.Register(api, huma.Operation{
		OperationID: "packages-upgrade",
		Method:      "POST",
		Path:        "/api/packages/upgrade",
		Summary:     "Upgrade all packages (streamed)",
		Description: "Upgrades every package to its latest version, streaming the " +
			"package manager's output live.",
		Tags:     []string{tagPackages},
		Metadata: op("write"),
	}, pkgEvents, func(ctx context.Context, _ *struct{}, send sse.Sender) {
		bin, args := pm.upgradeArgs()
		streamOp(ctx, send, bin, args)
	})

	sse.Register(api, huma.Operation{
		OperationID: "packages-upgrade-one",
		Method:      "POST",
		Path:        "/api/packages/upgrade/{name}",
		Summary:     "Upgrade a single package (streamed)",
		Description: "Upgrades the named package to its latest version, streaming the " +
			"package manager's output live. apt uses `install --only-upgrade` so the " +
			"package must already be installed; dnf/pacman handle this natively.",
		Tags:     []string{tagPackages},
		Metadata: op("write"),
	}, pkgEvents, func(ctx context.Context, in *UpgradeOneInput, send sse.Sender) {
		if validateName(in.Name) != nil {
			send.Data(PkgErrorEvent{Message: "invalid package name: " + in.Name})
			return
		}
		bin, args := pm.upgradeOneArgs(in.Name)
		streamOp(ctx, send, bin, args)
	})
}

// streamOp runs a package write and streams its combined output to the client.
func streamOp(ctx context.Context, send sse.Sender, bin string, args []string) {
	if bin == "" {
		send.Data(PkgErrorEvent{Message: "no supported package manager found"})
		return
	}
	select {
	case pkgSem <- struct{}{}:
	default:
		send.Data(PkgErrorEvent{Message: "too many concurrent package operations, try again later"})
		return
	}
	defer func() { <-pkgSem }()
	// DEBIAN_FRONTEND keeps apt from blocking on an interactive prompt.
	lines, errc, err := oscmd.RunStreamCombined(ctx, []string{"DEBIAN_FRONTEND=noninteractive"}, bin, args...)
	if err != nil {
		send.Data(PkgErrorEvent{Message: err.Error()})
		return
	}
	for line := range lines {
		if send.Data(PkgOutputEvent{Line: line}) != nil {
			return // client gone; ctx cancel kills the process
		}
	}
	done := PkgDoneEvent{Success: true}
	if werr := <-errc; werr != nil {
		done.Success = false
		done.Error = werr.Error()
	}
	send.Data(done)
}

func validateName(name string) error {
	if !pkgNameRe.MatchString(name) {
		return huma.Error400BadRequest("invalid package name: " + name)
	}
	return nil
}

// --- reads -------------------------------------------------------------------

func listInstalled(pm manager) (*ListOutput, error) {
	var out string
	var err error
	switch pm.name {
	case "dnf":
		out, err = oscmd.Run("rpm", "-qa", "--qf", "%{NAME}\t%{VERSION}-%{RELEASE}\n")
	case "apt":
		out, err = oscmd.Run("dpkg-query", "-W", "-f=${Package}\t${Version}\n")
	case "pacman":
		out, err = oscmd.Run("pacman", "-Q")
	default:
		return nil, huma.Error500InternalServerError("no supported package manager found")
	}
	if err != nil {
		return nil, huma.Error500InternalServerError("listing installed packages failed", err)
	}
	pkgs := parseTabbed(out)
	if pm.name == "pacman" {
		pkgs = parseSpaced(out)
	}
	return result(pm, pkgs), nil
}

func listUpdates(pm manager) (*ListOutput, error) {
	switch pm.name {
	case "dnf":
		// check-update exits 100 when updates exist, 0 when none - both fine.
		out, code, err := oscmd.RunStatus("dnf", "-q", "check-update")
		if err != nil || (code != 0 && code != 100) {
			return nil, huma.Error500InternalServerError("dnf check-update failed", err)
		}
		return result(pm, parseDnf(out)), nil
	case "apt":
		out, err := oscmd.Run("apt", "list", "--upgradable", "-qq")
		if err != nil {
			return nil, huma.Error500InternalServerError("apt list failed", err)
		}
		return result(pm, parseApt(out)), nil
	case "pacman":
		// -Qu exits 1 when there is nothing to upgrade - not an error.
		out, code, err := oscmd.RunStatus("pacman", "-Qu")
		if err != nil || (code != 0 && code != 1) {
			return nil, huma.Error500InternalServerError("pacman -Qu failed", err)
		}
		return result(pm, parsePacmanUpdates(out)), nil
	default:
		return nil, huma.Error500InternalServerError("no supported package manager found")
	}
}

func result(pm manager, pkgs []Package) *ListOutput {
	out := &ListOutput{}
	out.Body.Manager = pm.name
	out.Body.Packages = pkgs
	return out
}

// --- writes ------------------------------------------------------------------

func (m manager) installArgs(name string) (string, []string) {
	switch m.name {
	case "dnf":
		return "dnf", []string{"install", "-y", name}
	case "apt":
		return "apt-get", []string{"install", "-y", name}
	case "pacman":
		return "pacman", []string{"-S", "--noconfirm", name}
	}
	return "", nil
}

func (m manager) removeArgs(name string) (string, []string) {
	switch m.name {
	case "dnf":
		return "dnf", []string{"remove", "-y", name}
	case "apt":
		return "apt-get", []string{"remove", "-y", name}
	case "pacman":
		return "pacman", []string{"-R", "--noconfirm", name}
	}
	return "", nil
}

func (m manager) upgradeArgs() (string, []string) {
	switch m.name {
	case "dnf":
		return "dnf", []string{"upgrade", "-y"}
	case "apt":
		return "apt-get", []string{"upgrade", "-y"}
	case "pacman":
		return "pacman", []string{"-Su", "--noconfirm"}
	}
	return "", nil
}

// upgradeOneArgs upgrades a single package to its latest version. apt's
// `install --only-upgrade` is the safe variant (won't install if absent);
// pacman -S re-syncs to latest; dnf upgrade is naturally scoped by name.
func (m manager) upgradeOneArgs(name string) (string, []string) {
	switch m.name {
	case "dnf":
		return "dnf", []string{"upgrade", "-y", name}
	case "apt":
		return "apt-get", []string{"install", "--only-upgrade", "-y", name}
	case "pacman":
		return "pacman", []string{"-S", "--noconfirm", name}
	}
	return "", nil
}

// --- parsers (pure, tested) --------------------------------------------------

// parseTabbed reads "name\tversion" lines (dpkg-query / rpm output).
func parseTabbed(out string) []Package {
	pkgs := []Package{}
	for line := range strings.SplitSeq(out, "\n") {
		if name, ver, ok := strings.Cut(line, "\t"); ok && name != "" {
			pkgs = append(pkgs, Package{Name: name, Version: ver})
		}
	}
	return pkgs
}

// parseSpaced reads "name version" lines (pacman -Q).
func parseSpaced(out string) []Package {
	pkgs := []Package{}
	for line := range strings.SplitSeq(out, "\n") {
		if f := strings.Fields(line); len(f) >= 2 {
			pkgs = append(pkgs, Package{Name: f[0], Version: f[1]})
		}
	}
	return pkgs
}

// parseDnf reads `dnf check-update` lines ("name.arch  version  repo"), skipping
// the section headers and blank lines that dnf5 emits.
func parseDnf(out string) []Package {
	pkgs := []Package{}
	for line := range strings.SplitSeq(out, "\n") {
		f := strings.Fields(line)
		// Real rows have 3 columns and an arch suffix on the name; headers like
		// "Upgrades" or "Obsoleting Packages" don't.
		if len(f) != 3 || !strings.Contains(f[0], ".") {
			continue
		}
		pkgs = append(pkgs, Package{Name: stripArch(f[0]), Version: f[1]})
	}
	return pkgs
}

// parseApt reads `apt list --upgradable` lines ("name/repo version arch [...]").
func parseApt(out string) []Package {
	pkgs := []Package{}
	for line := range strings.SplitSeq(out, "\n") {
		f := strings.Fields(line)
		if len(f) < 2 || !strings.Contains(f[0], "/") {
			continue
		}
		name, _, _ := strings.Cut(f[0], "/")
		pkgs = append(pkgs, Package{Name: name, Version: f[1]})
	}
	return pkgs
}

// parsePacmanUpdates reads `pacman -Qu` lines ("name oldver -> newver").
func parsePacmanUpdates(out string) []Package {
	pkgs := []Package{}
	for line := range strings.SplitSeq(out, "\n") {
		f := strings.Fields(line)
		if len(f) < 4 || f[2] != "->" {
			continue
		}
		pkgs = append(pkgs, Package{Name: f[0], Version: f[3]})
	}
	return pkgs
}

// stripArch removes the trailing ".arch" from an rpm "name.arch" token. The
// arch is always the final dotted segment, so cut at the last dot.
func stripArch(s string) string {
	if i := strings.LastIndex(s, "."); i > 0 {
		return s[:i]
	}
	return s
}
