package daemon

import (
	"fmt"
	"log/slog"
	"os"
	"path/filepath"
	"strings"

	"banger/internal/guest"
	"banger/internal/model"
	"banger/internal/paths"
)

// Marker sentinels that fence the `Include` block banger writes into
// ~/.ssh/config when the user runs `banger ssh-config --install`.
const (
	bangerSSHIncludeBegin = "# BEGIN BANGER SSH INCLUDE"
	bangerSSHIncludeEnd   = "# END BANGER SSH INCLUDE"
)

// removeVMKnownHosts drops every host-key pin for vm from the
// banger-owned known_hosts. Best-effort — a failure here only
// matters if the same IP/name is reused by a fresh VM before the
// next daemon restart, and even then it just causes a
// TOFU-mismatch error that the user can clear manually. Logged at
// warn so it shows up if it ever actually breaks things.
func removeVMKnownHosts(knownHostsPath string, vm model.VMRecord, logger *slog.Logger) {
	if strings.TrimSpace(knownHostsPath) == "" {
		return
	}
	var hosts []string
	if ip := strings.TrimSpace(vm.Runtime.GuestIP); ip != "" {
		hosts = append(hosts, ip)
	}
	if dns := strings.TrimSpace(vm.Runtime.DNSName); dns != "" {
		hosts = append(hosts, dns)
	}
	if len(hosts) == 0 {
		return
	}
	if err := guest.RemoveKnownHosts(knownHostsPath, hosts...); err != nil && logger != nil {
		logger.Warn("remove known_hosts entries", "vm_id", vm.ID, "error", err.Error())
	}
}

// BangerSSHConfigPath is the file banger owns and keeps in sync with
// the current default key + known_hosts locations. Users who want the
// `ssh <name>.vm` shortcut opt in via `banger ssh-config --install`,
// which adds an Include line to ~/.ssh/config pointing at this file.
// The daemon never touches ~/.ssh/config on its own.
func BangerSSHConfigPath(layout paths.Layout) string {
	if strings.TrimSpace(layout.ConfigDir) == "" {
		return ""
	}
	return filepath.Join(layout.ConfigDir, "ssh_config")
}

func (d *Daemon) ensureVMSSHClientConfig() {
	if err := SyncVMSSHClientConfig(d.userLayout, d.config.SSHKeyPath); err != nil && d.logger != nil {
		d.logger.Warn("vm ssh client config sync failed", "error", err.Error())
	}
}

// syncVMSSHClientConfig writes banger's own ssh_config file with the
// current `Host *.vm` stanza. It does NOT touch ~/.ssh/config; that's
// the job of `banger ssh-config --install` (user-initiated).
//
// The file lives in the banger config dir so users who manage their
// SSH config declaratively can decide how (or whether) to pull it in.
func SyncVMSSHClientConfig(layout paths.Layout, keyPath string) error {
	keyPath = strings.TrimSpace(keyPath)
	if keyPath == "" {
		return nil
	}
	target := BangerSSHConfigPath(layout)
	if target == "" {
		return nil
	}
	if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
		return err
	}
	block := renderManagedVMSSHBlock(keyPath, layout.KnownHostsPath)
	return writeTextFileIfChanged(target, block, 0o644)
}

// InstallUserSSHInclude adds an `Include <bangerSSHConfigPath>` line
// to ~/.ssh/config inside a banger-owned marker block. Idempotent:
// running it twice leaves a single block.
func InstallUserSSHInclude(layout paths.Layout) error {
	bangerConfig := BangerSSHConfigPath(layout)
	if bangerConfig == "" {
		return fmt.Errorf("banger config dir is not configured")
	}
	userConfigPath, err := userSSHConfigPath()
	if err != nil {
		return err
	}
	existing, err := readTextFileIfExists(userConfigPath)
	if err != nil {
		return err
	}
	block := renderBangerSSHIncludeBlock(bangerConfig)
	updated, err := upsertManagedBlock(existing, bangerSSHIncludeBegin, bangerSSHIncludeEnd, block)
	if err != nil {
		return err
	}
	return writeTextFileIfChanged(userConfigPath, updated, 0o600)
}

// UninstallUserSSHInclude removes the Include block from
// ~/.ssh/config. Idempotent: missing file or missing block is a
// no-op.
func UninstallUserSSHInclude() error {
	userConfigPath, err := userSSHConfigPath()
	if err != nil {
		return err
	}
	existing, err := readTextFileIfExists(userConfigPath)
	if err != nil {
		return err
	}
	if existing == "" {
		return nil
	}
	updated, err := removeManagedBlock(existing, bangerSSHIncludeBegin, bangerSSHIncludeEnd)
	if err != nil {
		return err
	}
	return writeTextFileIfChanged(userConfigPath, updated, 0o600)
}

// UserSSHIncludeInstalled reports whether ~/.ssh/config contains the
// banger Include block. Used by `ssh-config` (status readout) and
// `doctor`.
func UserSSHIncludeInstalled() (bool, error) {
	userConfigPath, err := userSSHConfigPath()
	if err != nil {
		return false, err
	}
	existing, err := readTextFileIfExists(userConfigPath)
	if err != nil {
		return false, err
	}
	return strings.Contains(existing, bangerSSHIncludeBegin), nil
}

func userSSHConfigPath() (string, error) {
	home, err := os.UserHomeDir()
	if err != nil {
		return "", err
	}
	return filepath.Join(home, ".ssh", "config"), nil
}

// renderManagedVMSSHBlock produces the body banger writes into its
// own ssh_config file. Host-key verification uses the banger-owned
// known_hosts — NOT the user's ~/.ssh/known_hosts, and NOT /dev/null.
// `accept-new` means first contact pins the key; any later mismatch
// fails the connect.
func renderManagedVMSSHBlock(keyPath, knownHostsPath string) string {
	keyPath = strings.TrimSpace(keyPath)
	knownHostsPath = strings.TrimSpace(knownHostsPath)
	lines := []string{
		"# Generated by banger. Edits will be overwritten on daemon start.",
		"# Enable the `ssh <name>.vm` shortcut via `banger ssh-config --install`.",
		"Host *.vm",
		"    User root",
		"    IdentityFile " + keyPath,
		"    IdentitiesOnly yes",
		"    BatchMode yes",
		"    PreferredAuthentications publickey",
		"    PasswordAuthentication no",
		"    KbdInteractiveAuthentication no",
	}
	if knownHostsPath != "" {
		lines = append(lines,
			"    UserKnownHostsFile "+knownHostsPath,
			"    StrictHostKeyChecking accept-new",
		)
	} else {
		// Missing known_hosts path is a configuration anomaly — fail
		// closed rather than silently disable verification.
		lines = append(lines, "    StrictHostKeyChecking yes")
	}
	lines = append(lines, "    LogLevel ERROR", "")
	return strings.Join(lines, "\n")
}

// renderBangerSSHIncludeBlock returns the marker-fenced block that
// `ssh-config --install` writes into ~/.ssh/config.
func renderBangerSSHIncludeBlock(bangerConfigPath string) string {
	lines := []string{
		bangerSSHIncludeBegin,
		"# Added by `banger ssh-config --install`. Remove with",
		"# `banger ssh-config --uninstall`, or delete the whole block.",
		"Include " + bangerConfigPath,
		bangerSSHIncludeEnd,
		"",
	}
	return strings.Join(lines, "\n")
}

// upsertManagedBlock replaces an existing marker-fenced block with
// `block` (including the begin/end markers), or appends `block` if
// no such block exists. `block` must contain the markers itself.
func upsertManagedBlock(existing, beginMarker, endMarker, block string) (string, error) {
	existing = normalizeConfigText(existing)
	block = normalizeConfigText(block)

	start := strings.Index(existing, beginMarker)
	if start >= 0 {
		end := strings.Index(existing[start:], endMarker)
		if end < 0 {
			return "", fmt.Errorf("managed block %q is missing end marker %q", beginMarker, endMarker)
		}
		end += start + len(endMarker)
		for end < len(existing) && existing[end] == '\n' {
			end++
		}
		existing = strings.TrimRight(existing[:start]+existing[end:], "\n")
	}

	if strings.TrimSpace(existing) == "" {
		return block, nil
	}
	return strings.TrimRight(existing, "\n") + "\n\n" + block, nil
}

// removeManagedBlock strips a marker-fenced block from existing text
// and returns the result (unchanged if no block is present). Missing
// end marker with present begin marker is treated as corruption.
func removeManagedBlock(existing, beginMarker, endMarker string) (string, error) {
	existing = normalizeConfigText(existing)
	start := strings.Index(existing, beginMarker)
	if start < 0 {
		return existing, nil
	}
	end := strings.Index(existing[start:], endMarker)
	if end < 0 {
		return "", fmt.Errorf("managed block %q is missing end marker %q", beginMarker, endMarker)
	}
	end += start + len(endMarker)
	for end < len(existing) && existing[end] == '\n' {
		end++
	}
	stripped := strings.TrimRight(existing[:start]+existing[end:], "\n")
	return normalizeConfigText(stripped), nil
}

func normalizeConfigText(text string) string {
	text = strings.ReplaceAll(text, "\r\n", "\n")
	text = strings.TrimRight(text, "\n")
	if text == "" {
		return ""
	}
	return text + "\n"
}

func readTextFileIfExists(path string) (string, error) {
	data, err := os.ReadFile(path)
	if err == nil {
		return string(data), nil
	}
	if os.IsNotExist(err) {
		return "", nil
	}
	return "", err
}

func writeTextFileIfChanged(path, content string, mode os.FileMode) error {
	content = normalizeConfigText(content)
	existing, err := readTextFileIfExists(path)
	if err != nil {
		return err
	}
	if existing == content {
		return nil
	}
	if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
		return err
	}
	return os.WriteFile(path, []byte(content), mode)
}
