package config

import (
	"bufio"
	"crypto/rand"
	"encoding/hex"
	"fmt"
	"log"
	"os"
	"strconv"
	"strings"

	"golang.org/x/crypto/bcrypt"
)

const defaultConfigFile = "app_config.conf"

// ConfigFile returns the active config file path.
// Override with CONFIG_FILE env var (path only, no secrets in env).
func ConfigFile() string {
	if v := os.Getenv("CONFIG_FILE"); v != "" {
		return v
	}
	return defaultConfigFile
}

// Config holds all runtime settings loaded from app_config.conf.
type Config struct {
	Port                string
	CrowdSecAPIURL      string
	CrowdSecAPILogin    string
	CrowdSecAPIPassword string // sent as-is to LAPI — never hashed
	CscliPath           string
	UIUsername          string
	UIPassword          string // bcrypt hash after first run
	UISessionSecret     string
	PollIntervalSec     int
}

// FirstRunError is returned when the config file was just created and needs editing.
type FirstRunError struct{ Path string }

func (e *FirstRunError) Error() string {
	return fmt.Sprintf(
		"%s created — fill in crowdsec_api_login and crowdsec_api_password, then restart",
		e.Path,
	)
}

// HashPassword generates a bcrypt hash suitable for use as ui_password in the config.
func HashPassword(plaintext string) (string, error) {
	h, err := bcrypt.GenerateFromPassword([]byte(plaintext), bcrypt.DefaultCost)
	if err != nil {
		return "", err
	}
	return string(h), nil
}

// VerifyUIPassword checks plaintext against the stored ui_password (bcrypt or plaintext fallback).
func (c *Config) VerifyUIPassword(plaintext string) bool {
	if isBcryptHash(c.UIPassword) {
		return bcrypt.CompareHashAndPassword([]byte(c.UIPassword), []byte(plaintext)) == nil
	}
	// Fallback: constant-time plaintext compare (should not happen after first startup)
	a := []byte(plaintext)
	b := []byte(c.UIPassword)
	if len(a) != len(b) {
		return false
	}
	var diff byte
	for i := range a {
		diff |= a[i] ^ b[i]
	}
	return diff == 0
}

// Load reads app_config.conf (or CONFIG_FILE path), creating it on first run.
// If ui_password is plaintext, it is hashed with bcrypt and written back to the file.
func Load() (*Config, error) {
	path := ConfigFile()
	if _, err := os.Stat(path); os.IsNotExist(err) {
		if err := writeDefault(path); err != nil {
			return nil, fmt.Errorf("create %s: %w", path, err)
		}
		return nil, &FirstRunError{Path: path}
	}

	vals, err := parseFile(path)
	if err != nil {
		return nil, fmt.Errorf("parse %s: %w", path, err)
	}

	c := &Config{
		Port:                strVal(vals, "port", ":8080"),
		CrowdSecAPIURL:      strVal(vals, "crowdsec_api_url", "http://localhost:8080"),
		CrowdSecAPILogin:    vals["crowdsec_api_login"],
		CrowdSecAPIPassword: vals["crowdsec_api_password"],
		CscliPath:           strVal(vals, "cscli_path", "/usr/local/bin/cscli"),
		UIUsername:          strVal(vals, "ui_username", "admin"),
		UIPassword:          strVal(vals, "ui_password", "changeme"),
		UISessionSecret:     vals["ui_session_secret"],
		PollIntervalSec:     intVal(vals, "poll_interval_sec", 15),
	}

	if c.CrowdSecAPILogin == "" {
		return nil, fmt.Errorf("crowdsec_api_login is required in %s", path)
	}
	if c.CrowdSecAPIPassword == "" {
		return nil, fmt.Errorf("crowdsec_api_password is required in %s", path)
	}
	if len(c.UISessionSecret) < 32 {
		return nil, fmt.Errorf("ui_session_secret must be at least 32 characters in %s", path)
	}

	// Auto-hash ui_password if stored as plaintext.
	if c.UIPassword != "" && !isBcryptHash(c.UIPassword) {
		hash, err := bcrypt.GenerateFromPassword([]byte(c.UIPassword), bcrypt.DefaultCost)
		if err != nil {
			return nil, fmt.Errorf("hash ui_password: %w", err)
		}
		c.UIPassword = string(hash)
		if err := updateConfigValue(path, "ui_password", c.UIPassword); err != nil {
			log.Printf("[WARN] could not save hashed ui_password to %s: %v", path, err)
		} else {
			log.Printf("ui_password hashed and saved to %s", path)
		}
	}

	return c, nil
}

// CscliAvailable returns true if the cscli binary exists at the configured path.
func (c *Config) CscliAvailable() bool {
	_, err := os.Stat(c.CscliPath)
	return err == nil
}

func isBcryptHash(s string) bool {
	return strings.HasPrefix(s, "$2a$") ||
		strings.HasPrefix(s, "$2b$") ||
		strings.HasPrefix(s, "$2y$")
}

// updateConfigValue rewrites a single key's value in the config file, preserving all other lines.
func updateConfigValue(path, key, value string) error {
	data, err := os.ReadFile(path)
	if err != nil {
		return err
	}

	lines := strings.Split(string(data), "\n")
	updated := false
	for i, line := range lines {
		trimmed := strings.TrimSpace(line)
		if trimmed == "" || strings.HasPrefix(trimmed, "#") {
			continue
		}
		idx := strings.IndexByte(trimmed, '=')
		if idx < 0 {
			continue
		}
		if strings.TrimSpace(trimmed[:idx]) == key {
			lines[i] = key + " = " + value
			updated = true
			break
		}
	}
	if !updated {
		return fmt.Errorf("key %q not found in %s", key, path)
	}

	return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0600)
}

// writeDefault creates the config file with a random session secret and usage notes.
func writeDefault(path string) error {
	secret, err := randomHex(32)
	if err != nil {
		return err
	}

	content := fmt.Sprintf(`# CrowdSec Dashy — Configuration
# Auto-generated on first run. Edit required fields and restart.
# Lines starting with # are comments.
# ---------------------------------------------------------------

# Network — address:port the web UI listens on
port = :8080

# CrowdSec Local API
crowdsec_api_url      = http://localhost:8080
crowdsec_api_login    =
crowdsec_api_password =

# Path to cscli binary (required for bouncers, machines, hub, metrics)
# In Docker: bind-mount the binary into the container at this path.
# Leave empty or point to a missing path to disable CLI features gracefully.
cscli_path = /usr/local/bin/cscli

# Web UI — HTTP Basic Auth
# ui_password is auto-hashed with bcrypt on first startup.
# To pre-hash a password: crowdsec-dashy -pwhash "your-password"
ui_username = admin
ui_password = changeme

# Session signing secret — auto-generated, keep private
ui_session_secret = %s

# Dashboard live-poll interval (seconds)
poll_interval_sec = 15
`, secret)

	return os.WriteFile(path, []byte(content), 0600)
}

// parseFile reads key = value pairs, ignoring blank lines and # comments.
func parseFile(path string) (map[string]string, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, err
	}
	defer f.Close()

	vals := make(map[string]string)
	scanner := bufio.NewScanner(f)
	lineNum := 0
	for scanner.Scan() {
		lineNum++
		line := strings.TrimSpace(scanner.Text())
		if line == "" || strings.HasPrefix(line, "#") {
			continue
		}
		idx := strings.IndexByte(line, '=')
		if idx < 0 {
			return nil, fmt.Errorf("line %d: expected key = value", lineNum)
		}
		key := strings.TrimSpace(line[:idx])
		val := strings.TrimSpace(line[idx+1:])
		if key == "" {
			return nil, fmt.Errorf("line %d: empty key", lineNum)
		}
		vals[key] = val
	}
	return vals, scanner.Err()
}

func strVal(m map[string]string, key, def string) string {
	if v, ok := m[key]; ok && v != "" {
		return v
	}
	return def
}

func intVal(m map[string]string, key string, def int) int {
	v, ok := m[key]
	if !ok || v == "" {
		return def
	}
	n, err := strconv.Atoi(v)
	if err != nil || n <= 0 {
		return def
	}
	return n
}

func randomHex(n int) (string, error) {
	b := make([]byte, n)
	if _, err := rand.Read(b); err != nil {
		return "", fmt.Errorf("generate random bytes: %w", err)
	}
	return hex.EncodeToString(b), nil
}
