// heavily inspired by gitea's model

package hook

import (
	"errors"
	"fmt"
	"io/fs"
	"log/slog"
	"os"
	"path/filepath"
	"strings"

	"github.com/go-git/go-git/v5"
)

var ErrNoGitRepo = errors.New("not a git repo")
var ErrCreatingHookDir = errors.New("failed to create hooks directory")
var ErrCreatingHook = errors.New("failed to create hook")
var ErrCreatingDelegate = errors.New("failed to create delegate hook")

type config struct {
	scanPath    string
	internalApi string
}

type setupOpt func(*config)

func WithScanPath(scanPath string) setupOpt {
	return func(c *config) {
		c.scanPath = scanPath
	}
}

func WithInternalApi(api string) setupOpt {
	return func(c *config) {
		c.internalApi = api
	}
}

func Config(opts ...setupOpt) config {
	config := config{}
	for _, o := range opts {
		o(&config)
	}
	return config
}

// setup hooks for all users
//
// directory structure is typically like so:
//
//	did:plc:repo1
//	did:plc:repo2
//	did:web:repo1
func Setup(config config) error {
	// iterate over all directories in current directory:
	repoDirs, err := os.ReadDir(config.scanPath)
	if errors.Is(err, fs.ErrNotExist) {
		return os.MkdirAll(config.scanPath, 0755)
	}
	if err != nil {
		return err
	}

	for _, repo := range repoDirs {
		if !repo.IsDir() {
			continue
		}

		did := repo.Name()
		if !strings.HasPrefix(did, "did:") {
			continue
		}

		userPath := filepath.Join(config.scanPath, did)
		if _, err := os.Stat(userPath); errors.Is(err, fs.ErrPermission) {
			slog.Warn("hook setup: skipping inaccessible repo", "path", userPath)
			continue
		}
		if err := SetupRepo(config, userPath); err != nil {
			if errors.Is(err, ErrNoGitRepo) {
				slog.Warn("hook setup: skipping non-repo entry", "path", userPath, "err", err)
				continue
			}
			return err
		}
	}

	return nil
}

// setup hook in /scanpath/did:plc:repo
func SetupRepo(config config, path string) error {
	if _, err := git.PlainOpen(path); err != nil {
		return fmt.Errorf("%s: %w", path, ErrNoGitRepo)
	}

	preReceiveD := filepath.Join(path, "hooks", "post-receive.d")
	if err := os.MkdirAll(preReceiveD, 0755); err != nil {
		return fmt.Errorf("%s: %w", preReceiveD, ErrCreatingHookDir)
	}

	notify := filepath.Join(preReceiveD, "40-notify.sh")
	if err := mkHook(config, notify); err != nil {
		return fmt.Errorf("%s: %w", notify, ErrCreatingHook)
	}

	delegate := filepath.Join(path, "hooks", "post-receive")
	if err := mkDelegate(delegate); err != nil {
		return fmt.Errorf("%s: %w", delegate, ErrCreatingDelegate)
	}

	return nil
}

func mkHook(config config, hookPath string) error {
	// use the absolute path to the underlying binary rather than a bare
	// `knot` lookup. on NixOS, bare `knot` resolves to /run/wrappers/bin/knot
	// which has restrictive perms (only the git group can exec it), so hooks
	// running as a virtual UID fail with EACCES. the underlying binary in
	// /nix/store is world-readable. hooks are regenerated on every deploy
	// so the store path stays fresh.
	executablePath, err := os.Executable()
	if err != nil {
		return err
	}

	hookContent := fmt.Sprintf(`#!/usr/bin/env bash
# AUTO GENERATED BY KNOT, DO NOT MODIFY
push_options=()
for ((i=0; i<GIT_PUSH_OPTION_COUNT; i++)); do
    option_var="GIT_PUSH_OPTION_$i"
    push_options+=(-push-option "${!option_var}")
done
%s hook -git-dir "$GIT_DIR" -user-did "$GIT_USER_DID" -user-handle "$GIT_USER_HANDLE" -internal-api "%s" "${push_options[@]}" post-receive
	`, executablePath, config.internalApi)

	if err := os.WriteFile(hookPath, []byte(hookContent), 0755); err != nil {
		return err
	}
	// os.WriteFile doesn't change the mode on existing files; chmod explicitly.
	return os.Chmod(hookPath, 0755)
}

func mkDelegate(path string) error {
	content := fmt.Sprintf(`#!/usr/bin/env bash
# AUTO GENERATED BY KNOT, DO NOT MODIFY
data=$(cat)
exitcodes=""
hookname=$(basename $0)
GIT_DIR="$PWD"

for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
  test -x "${hook}" && test -f "${hook}" || continue
  echo "${data}" | "${hook}"
  exitcodes="${exitcodes} $?"
done

for i in ${exitcodes}; do
  [ ${i} -eq 0 ] || exit ${i}
done
	`)

	if err := os.WriteFile(path, []byte(content), 0755); err != nil {
		return err
	}
	return os.Chmod(path, 0755)
}
