package http

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	stdhttp "net/http"
	"strings"
	"time"

	"github.com/oklog/ulid/v2"

	"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
	"gitea.dcglab.co.uk/steve/restic-manager/internal/auth"
	"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)

// enrollRequest is the body posted by the agent installer. The token
// was issued by the operator via the UI ("Add host" → P1-27); the
// host metadata comes from the agent's own sysinfo collection.
type enrollRequest struct {
	Token         string       `json:"token"`
	HostName      string       `json:"hostname"`
	OS            api.HostOS   `json:"os"`
	Arch          api.HostArch `json:"arch"`
	AgentVersion  string       `json:"agent_version"`
	ResticVersion string       `json:"restic_version"`
}

// enrollResponse hands the agent the credentials it'll use forever.
// AgentToken is shown exactly once; the server stores its hash.
//
// CertPinSHA256 is reserved for future use. The server is HTTP-only
// and sits behind a reverse proxy that owns the TLS cert; pinning is
// configured at the agent install step (`-cert-pin`) by the operator
// pasting in the proxy's cert hash. The field stays in the response
// shape so we can populate it later if the topology changes.
type enrollResponse struct {
	HostID        string `json:"host_id"`
	AgentToken    string `json:"agent_token"`
	CertPinSHA256 string `json:"cert_pin_sha256,omitempty"`
}

// enrollOperatorRequest creates a one-time enrollment token for an
// operator who is about to install an agent. Authenticated UI route.
//
// Repo creds are required at token-mint time so the agent can run a
// backup the moment it comes online. The trio is JSON-encoded,
// AEAD-encrypted with token_hash as additional data, and stashed on
// the token row. ConsumeEnrollmentToken re-encrypts under host_id
// and writes the host_credentials row in the same tx as token-burn.
type enrollOperatorRequest struct {
	HostName     string   `json:"hostname"`
	Tags         []string `json:"tags,omitempty"`
	RepoURL      string   `json:"repo_url"`
	RepoUsername string   `json:"repo_username"`
	RepoPassword string   `json:"repo_password"`
	// InitialPaths seeds the host's initial manual schedule on
	// consume — operator can edit/extend from the host's Schedules
	// tab afterwards. Empty list = no initial schedule (operator
	// must add one before backups can run).
	InitialPaths []string `json:"initial_paths,omitempty"`
}

type enrollOperatorResponse struct {
	Token     string    `json:"token"`
	ExpiresAt time.Time `json:"expires_at"`
}

// repoCredsBlob is the JSON shape of the encrypted repo-creds blob.
// Lives only inside AEAD ciphertext — never on the wire as plaintext
// outside the WS config.update push.
type repoCredsBlob struct {
	RepoURL      string `json:"repo_url"`
	RepoUsername string `json:"repo_username"`
	RepoPassword string `json:"repo_password"`
}

// handleAgentEnroll consumes a one-time token, persists a Host row,
// and returns persistent agent credentials. Open endpoint (no
// session) — the token is the credential.
func (s *Server) handleAgentEnroll(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	var req enrollRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error())
		return
	}
	if req.Token == "" || req.HostName == "" || req.OS == "" || req.Arch == "" {
		writeJSONError(w, stdhttp.StatusBadRequest, "missing_field",
			"token, hostname, os, arch all required")
		return
	}

	hostID := ulid.Make().String()

	// Atomically: validate + consume token, then create the host.
	// We do these in two statements; if create-host fails, the token
	// is already burned. That's acceptable — operator just regens.
	tokHash := auth.HashToken(req.Token)

	// Pull every operator-supplied attachment off the token row in one
	// query: encrypted repo creds (rebound under the new host_id) plus
	// the default-paths list. Both transferred onto the new host row
	// after consume.
	attachments, encForHost, err := s.rebindTokenAttachments(r.Context(), tokHash, hostID)
	if err != nil {
		slog.Warn("enrollment: rebind token attachments failed", "err", err)
		writeJSONError(w, stdhttp.StatusUnauthorized, "invalid_token",
			"token unknown, expired, or already used")
		return
	}

	if err := s.deps.Store.ConsumeEnrollmentToken(r.Context(), tokHash, hostID); err != nil {
		slog.Warn("enrollment: consume token failed", "err", err)
		writeJSONError(w, stdhttp.StatusUnauthorized, "invalid_token",
			"token unknown, expired, or already used")
		return
	}

	// Mint the persistent agent bearer.
	agentToken, err := auth.NewToken()
	if err != nil {
		writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
		return
	}

	host := store.Host{
		ID:            hostID,
		Name:          strings.TrimSpace(req.HostName),
		OS:            string(req.OS),
		Arch:          string(req.Arch),
		AgentVersion:  req.AgentVersion,
		ResticVersion: req.ResticVersion,
		EnrolledAt:    time.Now().UTC(),
	}
	if err := s.deps.Store.CreateHost(r.Context(), host,
		auth.HashToken(agentToken), ""); err != nil {
		writeJSONError(w, stdhttp.StatusConflict, "host_exists", err.Error())
		return
	}

	// Seed the host's "default" source group with whatever paths the
	// operator typed into Add-host (empty allowed; group is editable
	// from the Sources tab post-enroll). Also seed the host's
	// repo-maintenance row with default cadences so forget/prune/check
	// start ticking on their own. Auto-init dispatch lands in Phase 6
	// of the redesign.
	if err := s.deps.Store.CreateSourceGroup(r.Context(), &store.SourceGroup{
		ID:       ulid.Make().String(),
		HostID:   hostID,
		Name:     "default",
		Includes: attachments.InitialPaths,
	}); err != nil {
		slog.Warn("enrollment: seed default source group failed",
			"host_id", hostID, "err", err)
	}
	if err := s.deps.Store.CreateDefaultRepoMaintenance(r.Context(), hostID); err != nil {
		slog.Warn("enrollment: seed repo maintenance failed",
			"host_id", hostID, "err", err)
	}

	// Promote the encrypted repo creds onto the freshly-created host
	// row. If this fails for any reason we log loudly but still
	// return the bearer — the operator recovers via PUT
	// /api/hosts/{id}/repo-credentials. Failing the whole enrolment
	// here would leave a half-burned token + an orphan host.
	if encForHost != "" {
		if err := s.deps.Store.SetHostCredentials(r.Context(), hostID, store.CredKindRepo, encForHost); err != nil {
			slog.Error("enrollment: set host credentials failed",
				"host_id", hostID, "err", err)
		}
	}

	auditPayload, _ := json.Marshal(struct {
		Hostname     string `json:"hostname"`
		OS           string `json:"os"`
		Arch         string `json:"arch"`
		HasRepoCreds bool   `json:"has_repo_creds"`
	}{
		Hostname:     host.Name,
		OS:           host.OS,
		Arch:         host.Arch,
		HasRepoCreds: encForHost != "",
	})
	_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
		ID:         ulid.Make().String(),
		Actor:      "system",
		Action:     "host.enrolled",
		TargetKind: ptr("host"),
		TargetID:   &hostID,
		TS:         host.EnrolledAt,
		Payload:    auditPayload,
	})

	writeJSON(w, stdhttp.StatusCreated, enrollResponse{
		HostID:     hostID,
		AgentToken: agentToken,
		// CertPinSHA256: the server is HTTP-only and sits behind a
		// reverse proxy that owns the cert. The operator pastes the
		// proxy's cert hash into the install command (`-cert-pin`)
		// when they want pinning; the server cannot introspect a
		// cert it doesn't terminate.
	})
}

// handleCreateEnrollmentToken (operator-facing) — generates a
// short-lived token for a new host. Authenticated; admin/operator only.
//
// TODO: gate by authn middleware once login session lookup lands.
// For Phase 1's first slice, we accept the bootstrap-shipped admin
// session cookie and trust it, validating the cookie via store.
func (s *Server) handleCreateEnrollmentToken(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	if !s.authedUser(r) {
		writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "")
		return
	}

	var req enrollOperatorRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error())
		return
	}
	token, expiresAt, err := s.mintEnrollmentToken(r.Context(), req.RepoURL, req.RepoUsername, req.RepoPassword, req.InitialPaths)
	switch {
	case err == nil:
		writeJSON(w, stdhttp.StatusCreated, enrollOperatorResponse{Token: token, ExpiresAt: expiresAt})
	case errors.Is(err, errMissingRepoCreds):
		writeJSONError(w, stdhttp.StatusBadRequest, "missing_field", "repo_url and repo_password are required so the agent can run backups on first connect")
	default:
		writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
	}
}

// errMissingRepoCreds is returned by mintEnrollmentToken when the
// operator hasn't supplied the URL+password pair the agent needs.
// Sentinel error so HTML and JSON handlers can map it to their own
// surface (form re-render with banner / 400 with code).
var errMissingRepoCreds = errAuth("missing_repo_creds")

// mintEnrollmentToken creates a fresh one-time enrollment token and
// stashes the AEAD-encrypted repo creds on its row. Returns the raw
// token (shown to the operator exactly once) and the expiry time.
//
// Shared by the JSON endpoint and the HTML "Add host" flow.
func (s *Server) mintEnrollmentToken(ctx context.Context, repoURL, repoUsername, repoPassword string, initialPaths []string) (string, time.Time, error) {
	if repoURL == "" || repoPassword == "" {
		return "", time.Time{}, errMissingRepoCreds
	}
	token, err := auth.NewToken()
	if err != nil {
		return "", time.Time{}, err
	}
	tokHash := auth.HashToken(token)

	enc, err := s.encryptRepoCreds(repoCredsBlob{
		RepoURL: repoURL, RepoUsername: repoUsername, RepoPassword: repoPassword,
	}, []byte("token:"+tokHash))
	if err != nil {
		return "", time.Time{}, err
	}

	if initialPaths == nil {
		initialPaths = []string{}
	}
	pathsJSON, err := json.Marshal(initialPaths)
	if err != nil {
		return "", time.Time{}, fmt.Errorf("marshal initial_paths: %w", err)
	}

	const ttl = time.Hour
	if err := s.deps.Store.CreateEnrollmentToken(ctx, tokHash, ttl, enc, string(pathsJSON)); err != nil {
		return "", time.Time{}, err
	}
	return token, time.Now().Add(ttl).UTC(), nil
}

// rebindTokenAttachments fetches every operator-supplied attachment
// off the token row, re-encrypting the repo-creds blob under the
// new host_id (the additional-data binding moves with the cred so
// a token-row dump can't be replayed against a different host's
// row). Returns the attachments (sans the rebind work), the
// re-encrypted ciphertext for SetHostCredentials, and any error.
func (s *Server) rebindTokenAttachments(ctx context.Context, tokHash, hostID string) (store.EnrollmentTokenAttachments, string, error) {
	att, err := s.deps.Store.GetEnrollmentTokenAttachments(ctx, tokHash)
	if err != nil {
		return store.EnrollmentTokenAttachments{}, "", err
	}
	if att.EncRepoCreds == "" {
		return att, "", nil
	}
	plain, err := s.deps.AEAD.Decrypt(att.EncRepoCreds, []byte("token:"+tokHash))
	if err != nil {
		return att, "", fmt.Errorf("decrypt token creds: %w", err)
	}
	out, err := s.deps.AEAD.Encrypt(plain, []byte("host:"+hostID))
	if err != nil {
		return att, "", fmt.Errorf("re-encrypt for host: %w", err)
	}
	return att, out, nil
}

// encryptRepoCreds JSON-encodes blob and seals it with the given
// additional-data context.
func (s *Server) encryptRepoCreds(blob repoCredsBlob, ad []byte) (string, error) {
	body, err := json.Marshal(blob)
	if err != nil {
		return "", fmt.Errorf("marshal repo creds: %w", err)
	}
	return s.deps.AEAD.Encrypt(body, ad)
}

// authedUser returns true iff the request carries a valid session
// cookie. Minimal stub for now; full RBAC middleware lands with
// P4-03.
func (s *Server) authedUser(r *stdhttp.Request) bool {
	c, err := r.Cookie(sessionCookieName)
	if err != nil {
		return false
	}
	_, err = s.deps.Store.LookupSession(r.Context(), auth.HashToken(c.Value))
	return err == nil
}

func ptr(s string) *string { return &s }
