package system

import (
	"context"
	"fmt"
	"os"
	"os/exec"
	"regexp"
	"slices"
	"strings"

	"nadir/internal/oscmd"

	"github.com/danielgtaylor/huma/v2"
)

type LocaleStatusBody struct {
	Lang      string `json:"lang" example:"it_IT.UTF-8" doc:"System locale (LANG)"`
	Language  string `json:"language" example:"en_US:" doc:"Fallback language list (LANGUAGE)"`
	VCKeymap  string `json:"vc_keymap" example:"it" doc:"Virtual console keymap"`
	X11Layout string `json:"x11_layout" example:"it" doc:"X11 keyboard layout"`
}

type GetLocaleOutput struct{ Body LocaleStatusBody }

type LocalesOutput struct {
	Body struct {
		Locales []string `json:"locales" doc:"Available locales"`
	}
}

type KeymapsOutput struct {
	Body struct {
		Keymaps []string `json:"keymaps" doc:"Available virtual console keymaps"`
		Reason  string   `json:"reason,omitempty" doc:"When keymaps is empty, why: e.g. \"kbd not installed on this server\""`
	}
}

type SetLocaleInput struct {
	Body struct {
		Lang     string  `json:"lang" example:"it_IT.UTF-8" doc:"Locale to set as LANG"`
		Language *string `json:"language,omitempty" example:"en_US:" doc:"Fallback language list (LANGUAGE)"`
	}
}

type SetKeymapInput struct {
	Body struct {
		Keymap string `json:"keymap" example:"it" doc:"Virtual console keymap"`
	}
}

type GenerateLocaleInput struct {
	Body struct {
		Locale string `json:"locale" example:"fr_FR.UTF-8" doc:"Locale to generate (e.g. fr_FR.UTF-8)"`
	}
}

// localeRe validates a locale identifier: language_TERRITORY with an optional
// .charmap suffix (e.g. fr_FR, fr_FR.UTF-8, en_US.ISO-8859-1).
var localeRe = regexp.MustCompile(`^[a-z]{2,3}_[A-Z]{2}(\.[A-Za-z0-9_-]+)?$`)

// languageRe validates the LANGUAGE fallback list: colon-separated locale names
// like "en_US:de_DE". Anchored so a leading "-" can't survive into argv.
var languageRe = regexp.MustCompile(`^[a-zA-Z0-9_.:-]*$`)

func localeStatus() (LocaleStatusBody, error) {
	lines, err := oscmd.RunLines("localectl", "status")
	if err != nil {
		return LocaleStatusBody{}, err
	}
	var b LocaleStatusBody
	for _, line := range lines {
		trimmed := strings.TrimSpace(line)
		if strings.HasPrefix(trimmed, "VC Keymap:") {
			b.VCKeymap = strings.TrimSpace(strings.TrimPrefix(trimmed, "VC Keymap:"))
			continue
		}
		if strings.HasPrefix(trimmed, "X11 Layout:") {
			b.X11Layout = strings.TrimSpace(strings.TrimPrefix(trimmed, "X11 Layout:"))
			continue
		}
		// Parse k=v fields on any other line (like System Locale block).
		parts := strings.Fields(trimmed)
		for _, part := range parts {
			part = strings.TrimPrefix(part, "System Locale:")
			part = strings.TrimSpace(part)
			if k, v, ok := strings.Cut(part, "="); ok {
				switch k {
				case "LANG":
					b.Lang = v
				case "LANGUAGE":
					b.Language = v
				}
			}
		}
	}
	return b, nil
}

func registerLocale(api huma.API) {
	huma.Register(api, huma.Operation{
		OperationID: "system-get-locale",
		Method:      "GET",
		Path:        "/api/system/locale",
		Summary:     "Get locale and keyboard layout",
		Description: "Returns the system locale (LANG), virtual console keymap, " +
			"and X11 layout from localectl.",
		Tags:     []string{tagSystem},
		Metadata: op("read"),
		Errors:   readErrors,
	}, func(ctx context.Context, _ *struct{}) (*GetLocaleOutput, error) {
		b, err := localeStatus()
		if err != nil {
			return nil, huma.Error500InternalServerError("localectl failed", err)
		}
		return &GetLocaleOutput{Body: b}, nil
	})

	huma.Register(api, huma.Operation{
		OperationID: "system-list-locales",
		Method:      "GET",
		Path:        "/api/system/locales",
		Summary:     "List available locales",
		Description: "Returns every locale the host can be configured to use, " +
			"suitable for populating a selector.",
		Tags:     []string{tagSystem},
		Metadata: op("read"),
		Errors:   readErrors,
	}, func(ctx context.Context, _ *struct{}) (*LocalesOutput, error) {
		locales, err := oscmd.RunLines("localectl", "list-locales")
		if err != nil {
			return nil, huma.Error500InternalServerError("localectl failed", err)
		}
		out := &LocalesOutput{}
		out.Body.Locales = locales
		return out, nil
	})

	huma.Register(api, huma.Operation{
		OperationID: "system-set-locale",
		Method:      "POST",
		Path:        "/api/system/locale",
		Summary:     "Set system locale (LANG)",
		Description: "Sets LANG via localectl. The value is validated against the " +
			"host's locale list, so an unknown locale returns 400.",
		Tags:     []string{tagSystem},
		Metadata: op("write"),
		Errors:   writeErrors,
	}, func(ctx context.Context, in *SetLocaleInput) (*oscmd.StatusOutput, error) {
		lang := strings.TrimSpace(in.Body.Lang)
		if lang == "" {
			return nil, huma.Error400BadRequest("empty locale")
		}
		locales, err := oscmd.RunLines("localectl", "list-locales")
		if err != nil {
			return nil, huma.Error500InternalServerError("localectl failed", err)
		}
		if !slices.Contains(locales, lang) {
			return nil, huma.Error400BadRequest("unknown locale: " + lang)
		}

		args := []string{"set-locale", "LANG=" + lang}
		if in.Body.Language != nil {
			langVal := strings.TrimSpace(*in.Body.Language)
			if !languageRe.MatchString(langVal) {
				return nil, huma.Error400BadRequest("invalid language format: " + langVal)
			}
			args = append(args, "LANGUAGE="+langVal)
		}

		if _, err := oscmd.Run("localectl", args...); err != nil {
			return nil, huma.Error500InternalServerError("set-locale failed", err)
		}
		return oscmd.OK(), nil
	})

	huma.Register(api, huma.Operation{
		OperationID: "system-list-keymaps",
		Method:      "GET",
		Path:        "/api/system/keymaps",
		Summary:     "List available console keymaps",
		Description: "Returns every virtual console keymap known to the host.",
		Tags:        []string{tagSystem},
		Metadata:    op("read"),
		Errors:      readErrors,
	}, func(ctx context.Context, _ *struct{}) (*KeymapsOutput, error) {
		// ponytail: minimal servers ship without kbd / /usr/share/keymaps, so
		// localectl errors instead of returning empty. Surface that as a `reason`
		// the frontend can display ("kbd not installed") instead of an opaque N/A.
		keymaps, err := oscmd.RunLines("localectl", "list-keymaps")
		out := &KeymapsOutput{}
		out.Body.Keymaps = keymaps
		if err != nil || len(keymaps) == 0 {
			out.Body.Reason = "kbd is not installed on this server (install the kbd / console-data package to enable keymap selection)"
		}
		return out, nil
	})

	huma.Register(api, huma.Operation{
		OperationID: "system-set-keymap",
		Method:      "POST",
		Path:        "/api/system/keymap",
		Summary:     "Set virtual console keymap",
		Description: "Sets the virtual console keymap via localectl. The value is " +
			"validated against the host's keymap list, so an unknown keymap " +
			"returns 400.",
		Tags:     []string{tagSystem},
		Metadata: op("write"),
		Errors:   writeErrors,
	}, func(ctx context.Context, in *SetKeymapInput) (*oscmd.StatusOutput, error) {
		km := strings.TrimSpace(in.Body.Keymap)
		if km == "" {
			return nil, huma.Error400BadRequest("empty keymap")
		}
		// list-keymaps failure means no keymap allowlist on this host (kbd absent);
		// fall through to unknown-keymap 400 instead of 500.
		keymaps, _ := oscmd.RunLines("localectl", "list-keymaps")
		if !slices.Contains(keymaps, km) {
			return nil, huma.Error400BadRequest("unknown keymap: " + km)
		}
		if _, err := oscmd.Run("localectl", "set-keymap", km); err != nil {
			return nil, huma.Error500InternalServerError("set-keymap failed", err)
		}
		return oscmd.OK(), nil
	})

	huma.Register(api, huma.Operation{
		OperationID: "system-generate-locale",
		Method:      "POST",
		Path:        "/api/system/locale/generate",
		Summary:     "Generate (install) a new locale",
		Description: "Generates a locale so it becomes available for use with set-locale. " +
			"On Debian/Ubuntu/Arch this uncomments the entry in /etc/locale.gen and runs " +
			"locale-gen; on RHEL/Fedora it uses localedef. Idempotent: if the locale is " +
			"already generated, returns 200 immediately.",
		Tags:     []string{tagSystem},
		Metadata: op("write"),
		Errors:   []int{400, 401, 403, 500, 501},
	}, func(ctx context.Context, in *GenerateLocaleInput) (*oscmd.StatusOutput, error) {
		locale := strings.TrimSpace(in.Body.Locale)
		if locale == "" {
			return nil, huma.Error400BadRequest("empty locale")
		}
		if !localeRe.MatchString(locale) {
			return nil, huma.Error400BadRequest("invalid locale format: "+locale,
				fmt.Errorf("expected language_TERRITORY[.charmap], e.g. fr_FR.UTF-8"))
		}

		// Idempotency: already generated → success.
		existing, err := oscmd.RunLines("localectl", "list-locales")
		if err != nil {
			return nil, huma.Error500InternalServerError("localectl failed", err)
		}
		if slices.Contains(existing, locale) {
			return oscmd.OK(), nil
		}

		// Detect which generation path the host supports.
		localeGenFile := "/etc/locale.gen"
		_, hasFile := os.Stat(localeGenFile)
		_, hasLocaleGen := exec.LookPath("locale-gen")
		_, hasLocaledef := exec.LookPath("localedef")

		switch {
		case hasFile == nil && hasLocaleGen == nil:
			if err := enableLocaleGen(localeGenFile, locale); err != nil {
				return nil, err
			}
		case hasLocaledef == nil:
			if err := generateLocaledef(locale); err != nil {
				return nil, err
			}
		default:
			return nil, huma.Error501NotImplemented(
				"locale generation not supported on this host (no locale-gen or localedef found)")
		}
		return oscmd.OK(), nil
	})
}

// enableLocaleGen uncomments the locale in /etc/locale.gen and runs locale-gen.
// This is the Debian/Ubuntu/Arch path.
func enableLocaleGen(path, locale string) error {
	data, err := os.ReadFile(path)
	if err != nil {
		return huma.Error500InternalServerError("reading locale.gen failed", err)
	}
	newContent, found := uncommentLocaleGen(string(data), locale)
	if !found {
		return huma.Error400BadRequest("locale not available for generation: " + locale)
	}
	// Write atomically: a crash mid-write would leave /etc/locale.gen truncated
	// and break every subsequent locale-gen on the host.
	tmp := path + ".nadir.tmp"
	if err := os.WriteFile(tmp, []byte(newContent), 0644); err != nil {
		return huma.Error500InternalServerError("writing locale.gen failed", err)
	}
	if err := os.Rename(tmp, path); err != nil {
		os.Remove(tmp)
		return huma.Error500InternalServerError("replacing locale.gen failed", err)
	}
	if _, err := oscmd.Run("locale-gen"); err != nil {
		return huma.Error500InternalServerError("locale-gen failed", err)
	}
	return nil
}

// uncommentLocaleGen finds a commented-out line for the given locale in a
// locale.gen file and uncomments it. Returns the modified content and whether
// a matching line was found. Pure function for testability.
func uncommentLocaleGen(content, locale string) (string, bool) {
	lines := strings.Split(content, "\n")
	found := false
	for i, line := range lines {
		trimmed := strings.TrimSpace(line)
		// Match lines like "# fr_FR.UTF-8 UTF-8" or "#fr_FR.UTF-8 UTF-8".
		if !strings.HasPrefix(trimmed, "#") {
			// Also check if already uncommented (idempotent at the file level).
			if strings.HasPrefix(trimmed, locale+" ") || trimmed == locale {
				found = true
			}
			continue
		}
		uncommented := strings.TrimSpace(strings.TrimPrefix(trimmed, "#"))
		if strings.HasPrefix(uncommented, locale+" ") || uncommented == locale {
			lines[i] = uncommented
			found = true
		}
	}
	return strings.Join(lines, "\n"), found
}

// generateLocaledef generates a locale using localedef. This is the RHEL/Fedora
// path where there is no /etc/locale.gen.
func generateLocaledef(locale string) error {
	// Parse "fr_FR.UTF-8" into language_territory="fr_FR" and charmap="UTF-8".
	// If there is no dot, default to UTF-8 (the common case on modern systems).
	langTerritory, charmap, _ := strings.Cut(locale, ".")
	if charmap == "" {
		charmap = "UTF-8"
	}
	if _, err := oscmd.Run("localedef", "-i", langTerritory, "-f", charmap, locale); err != nil {
		return huma.Error500InternalServerError("localedef failed", err)
	}
	return nil
}
