package clisupport

import (
	"archive/zip"
	"bytes"
	"context"
	"errors"
	"fmt"
	"io"
	"net"
	"net/http"
	"os"
	"path/filepath"
	"regexp"
	"runtime"
	"runtime/debug"
	"strconv"
	"strings"
	"time"

	"github.com/shirou/gopsutil/v4/cpu"
	"github.com/shirou/gopsutil/v4/host"
	log "github.com/sirupsen/logrus"
	"github.com/spf13/cobra"
	"gopkg.in/yaml.v3"

	"github.com/crowdsecurity/go-cs-lib/trace"

	"github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/clibouncer"
	"github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/clicapi"
	"github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/clihub"
	"github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/clilapi"
	"github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/climachine"
	"github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/climetrics"
	"github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/clipapi"
	"github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/core/args"
	"github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/core/require"
	"github.com/crowdsecurity/crowdsec/pkg/csconfig"
	"github.com/crowdsecurity/crowdsec/pkg/cwhub"
	"github.com/crowdsecurity/crowdsec/pkg/cwversion"
	"github.com/crowdsecurity/crowdsec/pkg/database"
	"github.com/crowdsecurity/crowdsec/pkg/fflag"
)

const (
	SUPPORT_METRICS_DIR           = "metrics/"
	SUPPORT_VERSION_PATH          = "version.txt"
	SUPPORT_FEATURES_PATH         = "features.txt"
	SUPPORT_SYSTEM_PATH           = "system.yaml"
	SUPPORT_HUB                   = "hub.txt"
	SUPPORT_BOUNCERS_PATH         = "lapi/bouncers.txt"
	SUPPORT_AGENTS_PATH           = "lapi/agents.txt"
	SUPPORT_CROWDSEC_CONFIG_PATH  = "config/crowdsec.yaml"
	SUPPORT_LAPI_STATUS_PATH      = "lapi_status.txt"
	SUPPORT_CAPI_STATUS_PATH      = "capi_status.txt"
	SUPPORT_PAPI_STATUS_PATH      = "papi_status.txt"
	SUPPORT_ACQUISITION_DIR       = "config/acquis/"
	SUPPORT_CROWDSEC_PROFILE_PATH = "config/profiles.yaml"
	SUPPORT_CRASH_DIR             = "crash/"
	SUPPORT_LOG_DIR               = "log/"
	SUPPORT_PPROF_DIR             = "pprof/"
)

// StringHook collects log entries in a string
type StringHook struct {
	LogBuilder strings.Builder
	LogLevels  []log.Level
}

func (hook *StringHook) Levels() []log.Level {
	return hook.LogLevels
}

func (hook *StringHook) Fire(entry *log.Entry) error {
	logEntry, err := entry.String()
	if err != nil {
		return err
	}

	hook.LogBuilder.WriteString(logEntry)

	return nil
}

// from https://github.com/acarl005/stripansi
var reStripAnsi = regexp.MustCompile("[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))")

func stripAnsiString(str string) string {
	// the byte version doesn't strip correctly
	return reStripAnsi.ReplaceAllString(str, "")
}

type cliSupport struct {
	cfg csconfig.Getter
}

func (cli *cliSupport) dumpMetrics(ctx context.Context, db *database.Client, zw *zip.Writer) error {
	fmt.Fprintln(os.Stdout, "Collecting prometheus metrics")

	cfg := cli.cfg()

	if cfg.Cscli.PrometheusUrl == "" {
		log.Warn("can't collect metrics: prometheus_uri is not set")
	}

	humanMetrics := new(bytes.Buffer)

	ms := climetrics.NewMetricStore()

	if err := ms.Fetch(ctx, cfg.Cscli.PrometheusUrl, db); err != nil {
		return err
	}

	if err := ms.Format(humanMetrics, cfg.Cscli.Color, nil, "human", false); err != nil {
		return fmt.Errorf("could not format prometheus metrics: %w", err)
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, cfg.Cscli.PrometheusUrl, http.NoBody)
	if err != nil {
		return fmt.Errorf("could not create request to prometheus endpoint: %w", err)
	}

	client := &http.Client{}

	resp, err := client.Do(req)
	if err != nil {
		return fmt.Errorf("could not get metrics from prometheus endpoint: %w", err)
	}

	defer resp.Body.Close()

	cli.writeToZip(zw, SUPPORT_METRICS_DIR+"metrics.prometheus", time.Now(), resp.Body)

	stripped := stripAnsiString(humanMetrics.String())

	cli.writeToZip(zw, SUPPORT_METRICS_DIR+"metrics.human", time.Now(), strings.NewReader(stripped))

	return nil
}

func (cli *cliSupport) dumpVersion(zw *zip.Writer) {
	fmt.Fprintln(os.Stdout, "Collecting version")

	cli.writeToZip(zw, SUPPORT_VERSION_PATH, time.Now(), strings.NewReader(cwversion.FullString()))
}

func (cli *cliSupport) dumpFeatures(zw *zip.Writer) {
	fmt.Fprintln(os.Stdout, "Collecting feature flags")

	w := new(bytes.Buffer)
	for _, k := range fflag.Crowdsec.GetEnabledFeatures() {
		fmt.Fprintln(w, k)
	}

	cli.writeToZip(zw, SUPPORT_FEATURES_PATH, time.Now(), w)
}

func (cli *cliSupport) dumpSystemInfo(zw *zip.Writer) error {
	fmt.Fprintln(os.Stdout, "Collecting system info (gopsutil + runtime)")

	type snapshot struct {
		Timestamp  time.Time `yaml:"timestamp"`
		GoOS       string    `yaml:"go_os"`
		GoARCH     string    `yaml:"go_arch"`
		GoVersion  string    `yaml:"go_version"`
		NumCPU     int       `yaml:"num_cpu"`
		Goroutines int       `yaml:"goroutines"`
		CgoCalls   int64     `yaml:"cgo_calls"`

		Host      *host.InfoStat `yaml:"host,omitempty"`
		CPUInfo   []cpu.InfoStat `yaml:"cpu_info,omitempty"`
		CPUCounts struct {
			Logical  int `yaml:"logical"`
			Physical int `yaml:"physical"`
		} `yaml:"cpu_counts"`
		BuildInfo *debug.BuildInfo `yaml:"build_info,omitempty"`
	}

	s := snapshot{
		Timestamp:  time.Now(),
		GoOS:       runtime.GOOS,
		GoARCH:     runtime.GOARCH,
		GoVersion:  runtime.Version(),
		NumCPU:     runtime.NumCPU(),
		Goroutines: runtime.NumGoroutine(),
		CgoCalls:   runtime.NumCgoCall(),
	}

	// best-effort, errors ignored

	if h, err := host.Info(); err == nil {
		s.Host = h
	}

	if ci, err := cpu.Info(); err == nil {
		s.CPUInfo = ci
	}

	if n, err := cpu.Counts(true); err == nil {
		s.CPUCounts.Logical = n
	}

	if n, err := cpu.Counts(false); err == nil {
		s.CPUCounts.Physical = n
	}

	if bi, ok := debug.ReadBuildInfo(); ok {
		s.BuildInfo = bi
	}

	buf, err := yaml.Marshal(&s)
	if err != nil {
		return fmt.Errorf("serialize system info to YAML: %w", err)
	}

	cli.writeToZip(zw, SUPPORT_SYSTEM_PATH, time.Now(), bytes.NewReader(buf))

	return nil
}

func (cli *cliSupport) dumpHubItems(zw *zip.Writer, hub *cwhub.Hub) error {
	fmt.Fprintln(os.Stdout, "Collecting hub")

	if hub == nil {
		return errors.New("no hub connection")
	}

	out := new(bytes.Buffer)
	ch := clihub.New(cli.cfg)

	if err := ch.List(out, hub, false); err != nil {
		return err
	}

	stripped := stripAnsiString(out.String())

	cli.writeToZip(zw, SUPPORT_HUB, time.Now(), strings.NewReader(stripped))

	return nil
}

func (cli *cliSupport) dumpBouncers(ctx context.Context, zw *zip.Writer, db *database.Client) error {
	fmt.Fprintln(os.Stdout, "Collecting bouncers")

	if db == nil {
		return errors.New("no database connection")
	}

	out := new(bytes.Buffer)
	cb := clibouncer.New(cli.cfg)

	if err := cb.List(ctx, out, db); err != nil {
		return err
	}

	stripped := stripAnsiString(out.String())

	cli.writeToZip(zw, SUPPORT_BOUNCERS_PATH, time.Now(), strings.NewReader(stripped))

	return nil
}

func (cli *cliSupport) dumpAgents(ctx context.Context, zw *zip.Writer, db *database.Client) error {
	fmt.Fprintln(os.Stdout, "Collecting agents")

	if db == nil {
		return errors.New("no database connection")
	}

	out := new(bytes.Buffer)
	cm := climachine.New(cli.cfg)

	if err := cm.List(ctx, out, db); err != nil {
		return err
	}

	stripped := stripAnsiString(out.String())

	cli.writeToZip(zw, SUPPORT_AGENTS_PATH, time.Now(), strings.NewReader(stripped))

	return nil
}

func (cli *cliSupport) dumpLAPIStatus(ctx context.Context, zw *zip.Writer, hub *cwhub.Hub) error {
	if hub == nil {
		return errors.New("hub is nil")
	}

	fmt.Fprintln(os.Stdout, "Collecting LAPI status")

	out := new(bytes.Buffer)
	cl := clilapi.New(cli.cfg)

	err := cl.Status(ctx, out, hub)
	if err != nil {
		fmt.Fprintf(out, "%s\n", err)
	}

	stripped := stripAnsiString(out.String())

	cli.writeToZip(zw, SUPPORT_LAPI_STATUS_PATH, time.Now(), strings.NewReader(stripped))

	return nil
}

func (cli *cliSupport) dumpCAPIStatus(ctx context.Context, zw *zip.Writer, hub *cwhub.Hub, db *database.Client) error {
	if hub == nil {
		return errors.New("hub is nil")
	}

	fmt.Fprintln(os.Stdout, "Collecting CAPI status")

	out := new(bytes.Buffer)
	cc := clicapi.New(cli.cfg)

	err := cc.Status(ctx, db, out, hub)
	if err != nil {
		fmt.Fprintf(out, "%s\n", err)
	}

	stripped := stripAnsiString(out.String())

	cli.writeToZip(zw, SUPPORT_CAPI_STATUS_PATH, time.Now(), strings.NewReader(stripped))

	return nil
}

func (cli *cliSupport) dumpPAPIStatus(ctx context.Context, zw *zip.Writer, db *database.Client) error {
	fmt.Fprintln(os.Stdout, "Collecting PAPI status")

	out := new(bytes.Buffer)
	cp := clipapi.New(cli.cfg)

	err := cp.Status(ctx, out, db)
	if err != nil {
		fmt.Fprintf(out, "%s\n", err)
	}

	stripped := stripAnsiString(out.String())

	cli.writeToZip(zw, SUPPORT_PAPI_STATUS_PATH, time.Now(), strings.NewReader(stripped))

	return nil
}

func (cli *cliSupport) dumpConfigYAML(zw *zip.Writer) error {
	fmt.Fprintln(os.Stdout, "Collecting crowdsec config")

	cfg := cli.cfg()

	config, err := os.ReadFile(cfg.FilePath)
	if err != nil {
		return fmt.Errorf("could not read config file: %w", err)
	}

	r := regexp.MustCompile(`(\s+password:|\s+user:|\s+host:)\s+.*`)

	redacted := r.ReplaceAll(config, []byte("$1 ****REDACTED****"))

	cli.writeToZip(zw, SUPPORT_CROWDSEC_CONFIG_PATH, time.Now(), bytes.NewReader(redacted))

	return nil
}

func (cli *cliSupport) dumpPprof(ctx context.Context, zw *zip.Writer, prometheusCfg csconfig.PrometheusCfg, endpoint string) error {
	fmt.Fprintf(os.Stdout, "Collecting pprof/%s data\n", endpoint)

	ctx, cancel := context.WithTimeout(ctx, 120*time.Second)
	defer cancel()

	req, err := http.NewRequestWithContext(
		ctx,
		http.MethodGet,
		fmt.Sprintf(
			"http://%s/debug/pprof/%s",
			net.JoinHostPort(
				prometheusCfg.ListenAddr,
				strconv.Itoa(prometheusCfg.ListenPort),
			),
			endpoint,
		),
		http.NoBody,
	)
	if err != nil {
		return fmt.Errorf("could not create request to pprof endpoint: %w", err)
	}

	client := &http.Client{}

	resp, err := client.Do(req)
	if err != nil {
		return fmt.Errorf("could not get pprof data from endpoint: %w", err)
	}

	defer resp.Body.Close()

	cli.writeToZip(zw, SUPPORT_PPROF_DIR+endpoint+".pprof", time.Now(), resp.Body)

	return nil
}

func (cli *cliSupport) dumpProfiles(zw *zip.Writer) {
	fmt.Fprintln(os.Stdout, "Collecting crowdsec profile")

	cfg := cli.cfg()
	cli.writeFileToZip(zw, SUPPORT_CROWDSEC_PROFILE_PATH, cfg.API.Server.ProfilesPath)
}

func (cli *cliSupport) dumpAcquisitionConfig(zw *zip.Writer) {
	fmt.Fprintln(os.Stdout, "Collecting acquisition config")

	cfg := cli.cfg()

	for _, filename := range cfg.Crowdsec.AcquisitionFiles {
		fname := strings.ReplaceAll(filename, string(filepath.Separator), "___")
		cli.writeFileToZip(zw, SUPPORT_ACQUISITION_DIR+fname, filename)
	}
}

func (cli *cliSupport) dumpLogs(zw *zip.Writer) error {
	fmt.Fprintln(os.Stdout, "Collecting CrowdSec logs")

	cfg := cli.cfg()

	logDir := cfg.Common.LogDir

	logFiles, err := filepath.Glob(filepath.Join(logDir, "crowdsec*.log"))
	if err != nil {
		return fmt.Errorf("could not list log files: %w", err)
	}

	for _, filename := range logFiles {
		cli.writeFileToZip(zw, SUPPORT_LOG_DIR+filepath.Base(filename), filename)
	}

	return nil
}

func (cli *cliSupport) dumpCrash(zw *zip.Writer) error {
	fmt.Fprintln(os.Stdout, "Collecting crash dumps")

	traceFiles, err := trace.List()
	if err != nil {
		return fmt.Errorf("could not list crash dumps: %w", err)
	}

	for _, filename := range traceFiles {
		cli.writeFileToZip(zw, SUPPORT_CRASH_DIR+filepath.Base(filename), filename)
	}

	return nil
}

func New(cfg csconfig.Getter) *cliSupport {
	return &cliSupport{
		cfg: cfg,
	}
}

func (cli *cliSupport) NewCommand() *cobra.Command {
	cmd := &cobra.Command{
		Use:               "support [action]",
		Short:             "Provide commands to help during support",
		DisableAutoGenTag: true,
		Args:              args.NoArgs,
		RunE: func(cmd *cobra.Command, _ []string) error {
			return cmd.Usage()
		},
	}

	cmd.AddCommand(cli.NewDumpCmd())

	return cmd
}

// writeToZip adds a file to the zip archive, from a reader
func (*cliSupport) writeToZip(zipWriter *zip.Writer, filename string, mtime time.Time, reader io.Reader) {
	header := &zip.FileHeader{
		Name:     filename,
		Method:   zip.Deflate,
		Modified: mtime,
	}

	fw, err := zipWriter.CreateHeader(header)
	if err != nil {
		log.Errorf("could not add zip entry for %s: %s", filename, err)
		return
	}

	_, err = io.Copy(fw, reader)
	if err != nil {
		log.Errorf("could not write zip entry for %s: %s", filename, err)
	}
}

// writeFileToZip adds a file to the zip archive, from a file, and retains the mtime
func (cli *cliSupport) writeFileToZip(zw *zip.Writer, filename string, fromFile string) {
	mtime := time.Now()

	fi, err := os.Stat(fromFile)
	if err == nil {
		mtime = fi.ModTime()
	}

	fin, err := os.Open(fromFile)
	if err != nil {
		log.Errorf("could not open file %s: %s", fromFile, err)
		return
	}
	defer fin.Close()

	cli.writeToZip(zw, filename, mtime, fin)
}

func (cli *cliSupport) dump(ctx context.Context, outFile string, fast bool) error {
	var skipCAPI, skipLAPI, skipAgent bool

	collector := &StringHook{
		LogLevels: log.AllLevels,
	}
	log.AddHook(collector)

	cfg := cli.cfg()

	if outFile == "" {
		outFile = filepath.Join(os.TempDir(), "crowdsec-support.zip")
	}

	w := bytes.NewBuffer(nil)
	zipWriter := zip.NewWriter(w)

	db, err := require.DBClient(ctx, cfg.DbConfig)
	if err != nil {
		log.Warn(err)
	}

	if err = cfg.LoadAPIServer(true, false); err != nil {
		log.Warnf("could not load LAPI, skipping CAPI check")

		skipCAPI = true
	}

	if err = cfg.LoadCrowdsec(); err != nil {
		log.Warnf("could not load agent config, skipping crowdsec config check")

		skipAgent = true
	}

	hub, err := require.Hub(cfg, nil)
	if err != nil {
		log.Warn("Could not init hub, running on LAPI? Hub related information will not be collected")
		// XXX: lapi status check requires scenarios, will return an error
	}

	if cfg.API.Client == nil || cfg.API.Client.Credentials == nil {
		log.Warn("no agent credentials found, skipping LAPI connectivity check")

		skipLAPI = true
	}

	if cfg.API.Server == nil || cfg.API.Server.OnlineClient == nil || cfg.API.Server.OnlineClient.Credentials == nil {
		log.Warn("no CAPI credentials found, skipping CAPI connectivity check")

		skipCAPI = true
	}

	if err = cli.dumpMetrics(ctx, db, zipWriter); err != nil {
		log.Warn(err)
	}

	if err = cli.dumpSystemInfo(zipWriter); err != nil {
		log.Warnf("could not collect system information: %s", err)
	}

	if err = cli.dumpConfigYAML(zipWriter); err != nil {
		log.Warnf("could not collect main config file: %s", err)
	}

	if err = cli.dumpHubItems(zipWriter, hub); err != nil {
		log.Warnf("could not collect hub information: %s", err)
	}

	if err = cli.dumpBouncers(ctx, zipWriter, db); err != nil {
		log.Warnf("could not collect bouncers information: %s", err)
	}

	if err = cli.dumpAgents(ctx, zipWriter, db); err != nil {
		log.Warnf("could not collect agents information: %s", err)
	}

	if !skipCAPI {
		if err = cli.dumpCAPIStatus(ctx, zipWriter, hub, db); err != nil {
			log.Warnf("could not collect CAPI status: %s", err)
		}

		if err = cli.dumpPAPIStatus(ctx, zipWriter, db); err != nil {
			log.Warnf("could not collect PAPI status: %s", err)
		}
	}

	if !skipLAPI {
		if err = cli.dumpLAPIStatus(ctx, zipWriter, hub); err != nil {
			log.Warnf("could not collect LAPI status: %s", err)
		}

		// call pprof separately, one might fail for timeout

		if err = cli.dumpPprof(ctx, zipWriter, *cfg.Prometheus, "goroutine"); err != nil {
			log.Warnf("could not collect pprof goroutine data: %s", err)
		}

		if err = cli.dumpPprof(ctx, zipWriter, *cfg.Prometheus, "heap"); err != nil {
			log.Warnf("could not collect pprof heap data: %s", err)
		}

		if !fast {
			fmt.Fprintln(os.Stdout, "The next operation will monitor the CPU usage for 30 seconds. Thank you for your patience.")

			if err = cli.dumpPprof(ctx, zipWriter, *cfg.Prometheus, "profile"); err != nil {
				log.Warnf("could not collect pprof cpu data: %s", err)
			}
		}

		cli.dumpProfiles(zipWriter)
	}

	if !skipAgent {
		cli.dumpAcquisitionConfig(zipWriter)
	}

	if err = cli.dumpCrash(zipWriter); err != nil {
		log.Warnf("could not collect crash dumps: %s", err)
	}

	if err = cli.dumpLogs(zipWriter); err != nil {
		log.Warnf("could not collect log files: %s", err)
	}

	cli.dumpVersion(zipWriter)
	cli.dumpFeatures(zipWriter)

	// log of the dump process, without color codes
	collectedOutput := stripAnsiString(collector.LogBuilder.String())

	cli.writeToZip(zipWriter, "dump.log", time.Now(), strings.NewReader(collectedOutput))

	err = zipWriter.Close()
	if err != nil {
		return fmt.Errorf("could not finalize zip file: %w", err)
	}

	if outFile == "-" {
		_, err = os.Stdout.Write(w.Bytes())
		return err
	}

	err = os.WriteFile(outFile, w.Bytes(), 0o600)
	if err != nil {
		return fmt.Errorf("could not write zip file to %s: %w", outFile, err)
	}

	fmt.Fprintf(os.Stdout, "Written zip file to %s\n", outFile)

	return nil
}

func (cli *cliSupport) NewDumpCmd() *cobra.Command {
	var (
		outFile string
		fast    bool
	)

	cmd := &cobra.Command{
		Use:   "dump",
		Short: "Dump all your configuration to a zip file for easier support",
		Long: `Dump the following information:
- Crowdsec version
- OS version and runtime system information
- Enabled feature flags
- Latest Crowdsec logs (log processor, LAPI, remediation components)
- Installed collections, parsers, scenarios...
- Bouncers and machines list
- CAPI/LAPI status
- Crowdsec config (sensitive information like username and password are redacted)
- Crowdsec metrics
- Stack trace in case of process crash`,
		Example: `cscli support dump
cscli support dump -f /tmp/crowdsec-support.zip
`,
		Args:              args.NoArgs,
		DisableAutoGenTag: true,
		RunE: func(cmd *cobra.Command, _ []string) error {
			output := cli.cfg().Cscli.Output
			if output != "human" {
				return fmt.Errorf("output format %s not supported for this command", output)
			}
			return cli.dump(cmd.Context(), outFile, fast)
		},
	}

	cmd.Flags().StringVarP(&outFile, "outFile", "f", "", "File to dump the information to")
	cmd.Flags().BoolVar(&fast, "fast", false, "Skip slow operations, like cpu profiling")

	return cmd
}
