//go:build darwin

package darwin

import (
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"syscall"
	"time"
)

const pfAnchorContent = `# =============================================================
# Soft blocklist for the "agent" user
# Block known-bad ports/protocols; allow everything else
# Generated by hazmat init
# =============================================================

# --- Block exfiltration / C2 protocols ---

# SMTP (email exfiltration)
block return out quick proto tcp from any to any port { 25, 465, 587 } user agent

# IRC (C2 channel)
block return out quick proto tcp from any to any port { 6660, 6661, 6662, 6663, 6664, 6665, 6666, 6667, 6668, 6669, 6697 } user agent

# FTP (legacy file transfer)
block return out quick proto tcp from any to any port { 20, 21 } user agent

# Telnet (insecure remote access)
block return out quick proto tcp from any to any port 23 user agent

# SMB (lateral movement)
block return out quick proto tcp from any to any port 445 user agent

# RDP (remote desktop)
block return out quick proto tcp from any to any port 3389 user agent

# VNC (remote desktop)
block return out quick proto tcp from any to any port { 5900, 5901 } user agent

# Tor (anonymous exfiltration)
block return out quick proto tcp from any to any port { 9050, 9150 } user agent

# SOCKS proxy
block return out quick proto tcp from any to any port 1080 user agent

# VPN protocols
block return out quick proto tcp from any to any port { 1194, 1723 } user agent
block return out quick proto udp from any to any port { 1194, 1723, 4500 } user agent

# XMPP (messaging / C2)
block return out quick proto tcp from any to any port { 5222, 5269 } user agent

# --- Block ICMP tunneling ---
block return out quick proto icmp from any to any user agent

# --- Allow everything else ---
pass out quick user agent
`

const pfDaemonPlistContent = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.local.pf-agent</string>
    <key>ProgramArguments</key>
    <array>
        <string>/sbin/pfctl</string>
        <string>-f</string>
        <string>/etc/pf.conf</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>StandardErrorPath</key>
    <string>/var/log/pf-agent.log</string>
</dict>
</plist>
`

// pfConfAddition is appended to /etc/pf.conf to register the anchor.
// Leading newline provides a blank-line separator from existing content.
const pfConfAddition = `
# Claude Code hazmat user blocklist
anchor "agent"
load anchor "agent" from "/etc/pf.anchors/agent"
`

const hostsBlocklistContent = `
# === AI Agent Blocklist ===
# Tunnel services
0.0.0.0 ngrok.io
0.0.0.0 ngrok.com
0.0.0.0 ngrok-free.app
0.0.0.0 tunnel.cloudflare.com
0.0.0.0 trycloudflare.com
0.0.0.0 serveo.net
0.0.0.0 localtunnel.me
0.0.0.0 localhost.run
0.0.0.0 localxpose.io
0.0.0.0 pagekite.me
0.0.0.0 bore.digital
0.0.0.0 localtonet.com
0.0.0.0 zrok.io
0.0.0.0 devtunnels.ms
0.0.0.0 loca.lt
0.0.0.0 tunnelmole.com
0.0.0.0 playit.gg
0.0.0.0 pinggy.io
0.0.0.0 lokal.so
0.0.0.0 telebit.cloud
0.0.0.0 loophole.cloud
# Paste services
0.0.0.0 pastebin.com
0.0.0.0 paste.ee
0.0.0.0 ghostbin.com
0.0.0.0 hastebin.com
0.0.0.0 dpaste.org
0.0.0.0 justpaste.it
0.0.0.0 rentry.co
0.0.0.0 ix.io
# File sharing
0.0.0.0 transfer.sh
0.0.0.0 file.io
0.0.0.0 gofile.io
0.0.0.0 catbox.moe
0.0.0.0 filebin.net
# Webhook capture
0.0.0.0 webhook.site
0.0.0.0 requestbin.com
0.0.0.0 pipedream.com
0.0.0.0 hookbin.com
0.0.0.0 beeceptor.com
# Known supply-chain C2 domains
# axios compromise (2026) - axios/axios#10604
0.0.0.0 sfrclak.com
# === End AI Agent Blocklist ===
`

type ServiceEnv struct {
	AgentUser                   string
	PfAnchorFile                string
	PfDaemonPlist               string
	SudoersFile                 string
	AgentMaintenanceSudoersFile string
	HostsMarker                 string
	SystemLaunchHelper          string
	HostPfctlPath               string
	HostLaunchctlPath           string
	LaunchHelperPath            func() string
	BrewLaunchHelperPaths       []string
}

type ServiceRunner interface {
	Sudo(reason string, args ...string) error
	SudoWriteFile(reason, path, content string) error
	SudoAppendFile(reason, path, content string) error
	PfctlLoad(reason string) error
	LaunchctlBootstrap(reason, plist string) error
}

type ServiceBackend struct {
	env ServiceEnv
}

func NewServiceBackend(env ServiceEnv) ServiceBackend {
	return ServiceBackend{env: env}
}

func (b ServiceBackend) SetupLaunchHelper(ui StatusUI, runner ServiceRunner, ensureAgentCanTraverse func(string) error) error {
	env := b.env
	ui.Step("Verify sandbox-launch helper")

	helperPath := env.launchHelperPath()

	// If not at the final path, try to install from brew.
	if _, err := os.Stat(helperPath); err != nil {
		if helperPath != env.SystemLaunchHelper {
			return launchHelperMissingError(helperPath)
		}
		src := b.FindBrewLaunchHelper()
		if src == "" {
			return launchHelperMissingError(helperPath)
		}
		ui.WarnMsg(fmt.Sprintf("Installing %s from %s", helperPath, src))
		if err := runner.Sudo("create "+filepath.Dir(helperPath),
			"mkdir", "-p", filepath.Dir(helperPath)); err != nil {
			return fmt.Errorf("create %s: %w", filepath.Dir(helperPath), err)
		}
		if err := runner.Sudo("install hazmat-launch to "+helperPath,
			"install", "-m", "0755", "-o", "root", src, helperPath); err != nil {
			return fmt.Errorf("install hazmat-launch: %w", err)
		}
	}

	info, err := os.Stat(helperPath)
	if err != nil {
		return fmt.Errorf("%s not found after install attempt", helperPath)
	}
	if !info.Mode().IsRegular() {
		return fmt.Errorf("%s is not a regular file", helperPath)
	}
	if info.Mode().Perm()&0o111 == 0 {
		return fmt.Errorf("%s is not executable", helperPath)
	}

	if helperPath == env.SystemLaunchHelper {
		stat, ok := info.Sys().(*syscall.Stat_t)
		if !ok || stat.Uid != 0 {
			return fmt.Errorf("%s must be owned by root (uid 0), got uid %d", helperPath, stat.Uid)
		}
		ui.Ok(fmt.Sprintf("%s present (root-owned, executable)", helperPath))
		return nil
	}

	if ensureAgentCanTraverse != nil {
		if err := ensureAgentCanTraverse(helperPath); err != nil {
			return err
		}
	}

	ui.Ok(fmt.Sprintf("%s present (user-local, executable)", helperPath))
	return nil
}

func (ServiceBackend) SetupSudoers(ui StatusUI, currentUser, agentUser string, install func() error) error {
	ui.Step(fmt.Sprintf("Configure passwordless sudo for launch helper (%s → %s)", currentUser, agentUser))
	if install != nil {
		return install()
	}
	return fmt.Errorf("install launch sudoers callback is not configured")
}

func (b ServiceBackend) SetupPfFirewall(ui StatusUI, runner ServiceRunner) error {
	env := b.env
	ui.Step("Configure pf port blocklist")

	if _, err := os.Stat(env.PfAnchorFile); err == nil {
		ui.SkipDone(fmt.Sprintf("pf anchor file already exists at %s", env.PfAnchorFile))
		ui.WarnMsg(fmt.Sprintf("To replace it, delete first: sudo rm %s", env.PfAnchorFile))
	} else {
		if err := runner.SudoWriteFile("write pf firewall anchor rules", env.PfAnchorFile, pfAnchorContent); err != nil {
			return fmt.Errorf("write pf anchor: %w", err)
		}
		ui.Ok(fmt.Sprintf("Created pf anchor at %s", env.PfAnchorFile))
	}

	// os.ReadFile is a read; it bypasses Runner.
	pfConf, err := os.ReadFile("/etc/pf.conf")
	if err != nil {
		return fmt.Errorf("read /etc/pf.conf: %w", err)
	}
	if strings.Contains(string(pfConf), `anchor "agent"`) {
		ui.SkipDone("Anchor already registered in /etc/pf.conf")
	} else {
		ts := time.Now().Format("20060102150405")
		if err := runner.Sudo("backup pf.conf before modification", "cp", "/etc/pf.conf", "/etc/pf.conf.backup."+ts); err != nil {
			return fmt.Errorf("backup pf.conf: %w", err)
		}
		ui.Ok("Backed up /etc/pf.conf")

		if err := runner.SudoAppendFile("register pf anchor in system config", "/etc/pf.conf", pfConfAddition); err != nil {
			return fmt.Errorf("append to pf.conf: %w", err)
		}
		ui.Ok("Registered anchor in /etc/pf.conf")
	}

	if err := runner.PfctlLoad("reload firewall rules"); err != nil {
		ui.WarnMsg(fmt.Sprintf("pfctl failed to load rules: %v", err))
	} else {
		ui.Ok("pf rules loaded")
	}
	// pfctl -e returns 1 if already enabled; ignore it.
	runner.Sudo("enable packet filter", env.HostPfctlPath, "-e") //nolint:errcheck
	ui.Ok("pf enabled")
	return nil
}

func (b ServiceBackend) SetupDNSBlocklist(ui StatusUI, runner ServiceRunner) error {
	env := b.env
	ui.Step("Configure DNS domain blocklist")

	// os.ReadFile is a read; it bypasses Runner.
	hosts, err := os.ReadFile("/etc/hosts")
	if err != nil {
		return fmt.Errorf("read /etc/hosts: %w", err)
	}

	if strings.Contains(string(hosts), env.HostsMarker) {
		ui.SkipDone("DNS blocklist already present in /etc/hosts")
		ui.WarnMsg(fmt.Sprintf("To replace it, remove the block between '%s' markers first.", env.HostsMarker))
		return nil
	}

	if err := runner.SudoAppendFile("add DNS blocklist to system hosts file", "/etc/hosts", hostsBlocklistContent); err != nil {
		return fmt.Errorf("append to /etc/hosts: %w", err)
	}

	// Cache flush is fire-and-forget; ignore errors.
	runner.Sudo("flush DNS cache", "dscacheutil", "-flushcache")             //nolint:errcheck
	runner.Sudo("restart mDNSResponder", "killall", "-HUP", "mDNSResponder") //nolint:errcheck
	ui.Ok("DNS blocklist added to /etc/hosts and cache flushed")
	ui.WarnMsg("This is system-wide. /etc/hosts does not block subdomains.")
	ui.WarnMsg("For wildcard blocking (*.ngrok.io), use dnsmasq — see soft-pf-blocklist.md")
	return nil
}

func (b ServiceBackend) SetupLaunchDaemon(ui StatusUI, runner ServiceRunner) error {
	env := b.env
	ui.Step("Persist firewall across reboots")

	if _, err := os.Stat(env.PfDaemonPlist); err == nil {
		ui.SkipDone(fmt.Sprintf("LaunchDaemon already exists at %s", env.PfDaemonPlist))
		return nil
	}

	if err := runner.SudoWriteFile("install firewall persistence daemon", env.PfDaemonPlist, pfDaemonPlistContent); err != nil {
		return fmt.Errorf("write LaunchDaemon plist: %w", err)
	}
	if err := runner.Sudo("set launch daemon permissions", "chmod", "644", env.PfDaemonPlist); err != nil {
		return fmt.Errorf("chmod plist: %w", err)
	}
	if err := runner.LaunchctlBootstrap("start firewall persistence daemon", env.PfDaemonPlist); err != nil {
		ui.WarnMsg(fmt.Sprintf("launchctl bootstrap: %v — plist written but not loaded", err))
	} else {
		ui.Ok("LaunchDaemon installed — pf rules will reload on boot")
	}
	return nil
}

func (b ServiceBackend) RollbackLaunchDaemon(ui StatusUI, runner ServiceRunner) {
	env := b.env
	ui.Step("Remove LaunchDaemon")

	if _, err := os.Stat(env.PfDaemonPlist); os.IsNotExist(err) {
		ui.SkipDone("LaunchDaemon plist not present")
		return
	}

	// bootout may fail if the daemon was never loaded; ignore the error.
	runner.Sudo("unload firewall persistence daemon", env.HostLaunchctlPath, "bootout", "system", env.PfDaemonPlist) //nolint:errcheck
	ui.Ok("LaunchDaemon unloaded (or was not loaded)")

	if err := runner.Sudo("remove firewall persistence plist", "rm", "-f", env.PfDaemonPlist); err != nil {
		ui.WarnMsg(fmt.Sprintf("Could not remove %s: %v", env.PfDaemonPlist, err))
	} else {
		ui.Ok(fmt.Sprintf("Removed %s", env.PfDaemonPlist))
	}
}

func (b ServiceBackend) RollbackPfFirewall(ui StatusUI, runner ServiceRunner) {
	env := b.env
	ui.Step("Remove pf anchor")

	if _, err := os.Stat(env.PfAnchorFile); os.IsNotExist(err) {
		ui.SkipDone("pf anchor file not present")
	} else if err := runner.Sudo("remove pf anchor file", "rm", "-f", env.PfAnchorFile); err != nil {
		ui.WarnMsg(fmt.Sprintf("Could not remove %s: %v", env.PfAnchorFile, err))
	} else {
		ui.Ok(fmt.Sprintf("Removed pf anchor file %s", env.PfAnchorFile))
	}

	pfConf := "/etc/pf.conf"
	data, err := os.ReadFile(pfConf)
	if err != nil {
		ui.WarnMsg(fmt.Sprintf("Could not read %s: %v", pfConf, err))
		return
	}

	if !strings.Contains(string(data), `anchor "agent"`) {
		ui.SkipDone("/etc/pf.conf does not reference the agent anchor")
		return
	}

	// Prefer restoring from the timestamped backup made during setup.
	backup := latestPfConfBackup()
	if backup != "" {
		if err := runner.Sudo("restore original pf.conf from backup", "cp", "-f", backup, pfConf); err != nil {
			ui.WarnMsg(fmt.Sprintf("Could not restore %s from backup %s: %v — stripping lines in place", pfConf, backup, err))
			stripPfAnchorLines(ui, runner, pfConf, data)
		} else {
			ui.Ok(fmt.Sprintf("Restored %s from backup %s", pfConf, backup))
		}
	} else {
		ui.WarnMsg("No timestamped backup of /etc/pf.conf found — stripping anchor lines in place")
		stripPfAnchorLines(ui, runner, pfConf, data)
	}

	if err := runner.PfctlLoad("reload pf rules after anchor removal"); err != nil {
		ui.WarnMsg(fmt.Sprintf("pfctl reload failed: %v", err))
	} else {
		ui.Ok("pf rules reloaded")
	}
}

func (b ServiceBackend) RollbackDNSBlocklist(ui StatusUI, runner ServiceRunner) {
	env := b.env
	ui.Step("Remove DNS blocklist")

	data, err := os.ReadFile("/etc/hosts")
	if err != nil {
		ui.WarnMsg(fmt.Sprintf("Could not read /etc/hosts: %v", err))
		return
	}

	if !strings.Contains(string(data), env.HostsMarker) {
		ui.SkipDone("DNS blocklist not present in /etc/hosts")
		return
	}

	// Remove lines from hostsMarker through hostsEndMarker, inclusive.
	// The block was appended with a leading newline; trim trailing blank lines.
	const endMarker = "# === End AI Agent Blocklist ==="
	var kept []string
	inside := false
	for _, line := range strings.Split(string(data), "\n") {
		if strings.TrimSpace(line) == env.HostsMarker {
			inside = true
			continue
		}
		if inside {
			if strings.TrimSpace(line) == endMarker {
				inside = false
			}
			continue
		}
		kept = append(kept, line)
	}

	cleaned := strings.TrimRight(strings.Join(kept, "\n"), "\n") + "\n"
	if err := runner.SudoWriteFile("remove DNS blocklist from /etc/hosts", "/etc/hosts", cleaned); err != nil {
		ui.WarnMsg(fmt.Sprintf("Could not update /etc/hosts: %v", err))
		return
	}
	ui.Ok("Removed DNS blocklist from /etc/hosts")

	// Flush DNS cache; fire-and-forget.
	runner.Sudo("flush DNS cache after blocklist removal", "dscacheutil", "-flushcache")             //nolint:errcheck
	runner.Sudo("restart mDNSResponder after blocklist removal", "killall", "-HUP", "mDNSResponder") //nolint:errcheck
	ui.Ok("DNS cache flushed")
}

func (b ServiceBackend) RollbackSudoers(ui StatusUI, runner ServiceRunner) {
	env := b.env
	ui.Step("Remove sudoers entries")

	removedAny := false
	for _, path := range []string{env.SudoersFile, env.AgentMaintenanceSudoersFile} {
		if _, err := os.Stat(path); os.IsNotExist(err) {
			continue
		}
		if err := runner.Sudo("remove sudoers entry", "rm", "-f", path); err != nil {
			ui.WarnMsg(fmt.Sprintf("Could not remove %s: %v", path, err))
			continue
		}
		ui.Ok(fmt.Sprintf("Removed %s", path))
		removedAny = true
	}
	if !removedAny {
		ui.SkipDone("Sudoers files not present")
	}
}

func (b ServiceBackend) FindBrewLaunchHelper() string {
	for _, path := range b.env.BrewLaunchHelperPaths {
		if info, err := os.Stat(path); err == nil && info.Mode().IsRegular() {
			return path
		}
	}
	return ""
}

func (e ServiceEnv) launchHelperPath() string {
	if e.LaunchHelperPath != nil {
		return e.LaunchHelperPath()
	}
	return e.SystemLaunchHelper
}

func launchHelperMissingError(helperPath string) error {
	return fmt.Errorf("%s not found.\n\n"+
		"Install Hazmat before running setup:\n\n"+
		"  make install\n\n"+
		"Or install system-wide:\n\n"+
		"  sudo make install-system\n\n"+
		"Then re-run: hazmat init", helperPath)
}

// latestPfConfBackup returns the most-recent /etc/pf.conf.backup.YYYYMMDDHHMMSS, or "".
func latestPfConfBackup() string {
	entries, err := filepath.Glob("/etc/pf.conf.backup.*")
	if err != nil || len(entries) == 0 {
		return ""
	}
	// filepath.Glob returns lexicographically sorted entries; the last has the
	// highest timestamp (YYYYMMDDHHMMSS sorts correctly as a string).
	return entries[len(entries)-1]
}

// stripPfAnchorLines rewrites pfConf with the agent anchor stanza removed.
func stripPfAnchorLines(ui StatusUI, runner ServiceRunner, pfConf string, data []byte) {
	var kept []string
	for _, line := range strings.Split(string(data), "\n") {
		if strings.Contains(line, `anchor "agent"`) ||
			strings.Contains(line, `load anchor "agent"`) ||
			strings.TrimSpace(line) == "# Claude Code sandbox user blocklist" {
			continue
		}
		kept = append(kept, line)
	}
	cleaned := strings.TrimRight(strings.Join(kept, "\n"), "\n") + "\n"
	if err := runner.SudoWriteFile("strip agent anchor lines from pf.conf", pfConf, cleaned); err != nil {
		ui.WarnMsg(fmt.Sprintf("Could not update %s: %v", pfConf, err))
	} else {
		ui.Ok("Removed agent anchor lines from /etc/pf.conf")
	}
}
