// Package store — sme_tenant.go: persisted state for the SME tenant
// provisioning pipeline (issue #804).
//
// On a marketplace tenant-create event the catalyst-api orchestrator
// must drive a multi-step pipeline:
//
//	pending
//	   → vcluster_created
//	      → bp_charts_installed
//	         → dns_provisioned
//	            → certs_issued
//	               → keycloak_clients_provisioned
//	                  → tenant_registered
//	                     → done
//	   ↓ (5 transient failures)
//	failed
//
// Each step is independently idempotent: the orchestrator commits a
// per-tenant Kustomize overlay to the GitOps repo (see
// internal/handler/sme_tenant_gitops.go), Flux reconciles the
// resulting HelmReleases, the orchestrator polls Flux's reported
// state, and the state machine advances when each gate passes.
//
// The reconciler is event-driven (NATS subject
// `sme.tenant.reconcile-pending` per Inviolable Principle 1 / ADR-0001
// §6) — never a Kubernetes CronJob, never a goroutine `time.Tick`.
//
// Per docs/INVIOLABLE-PRINCIPLES.md #3, this implementation uses the
// same flat-file convention the deployment store + user-provision
// store already follow. When the unified-rbac slice is split out as
// its own deployable unit the schema migrates verbatim to Postgres.
package store

import (
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"sync"
	"time"
)

// SMETenantProvisionState is one of the canonical states the pipeline
// transitions through. Strings (not int constants) so the on-disk
// record is human-readable.
type SMETenantProvisionState string

const (
	// STSPending — the orchestrator persisted the row but no side
	// effects have been performed yet. The reconciler picks up
	// pending rows and re-runs from step 1.
	STSPending SMETenantProvisionState = "pending"
	// STSVClusterCreated — the per-tenant Kustomize overlay
	// containing the vcluster HelmRelease has been committed and
	// pushed to the GitOps repo. Flux reconciles within ~1 min.
	STSVClusterCreated SMETenantProvisionState = "vcluster_created"
	// STSBPChartsInstalled — the same overlay also installs the bp-*
	// charts (bp-keycloak, bp-cnpg, bp-wordpress-tenant, bp-openclaw,
	// bp-stalwart-tenant) inside the SME vcluster. The orchestrator
	// advances to this state once the parent overlay's Kustomization
	// is Ready=True.
	STSBPChartsInstalled SMETenantProvisionState = "bp_charts_installed"
	// STSDNSProvisioned — A/CNAME records for `console.<sme-host>`
	// (free-subdomain) or BYO CNAME validation has succeeded.
	STSDNSProvisioned SMETenantProvisionState = "dns_provisioned"
	// STSCertsIssued — wildcard cert (free-subdomain) or per-host
	// HTTP-01 (BYO) is Ready=True. For free-subdomain mode the
	// wildcard already covers the SME's *.<otech-fqdn> subtree so
	// the orchestrator advances immediately.
	STSCertsIssued SMETenantProvisionState = "certs_issued"
	// STSKeycloakClientsProvisioned — OIDC clients for WordPress,
	// OpenClaw, Stalwart, unified-RBAC SME-tier have been pre-created
	// in the SME-vcluster Keycloak. Group templates `sme-admin` /
	// `sme-user` are seeded.
	STSKeycloakClientsProvisioned SMETenantProvisionState = "keycloak_clients_provisioned"
	// STSTenantRegistered — the row in the host → tenant registry has
	// been written so the SPA's `/api/v1/tenant/discover` lookup
	// returns the new tenant. This is the gate that opens the SPA's
	// front door for the SME admin's first login.
	STSTenantRegistered SMETenantProvisionState = "tenant_registered"
	// STSDone — terminal happy state. Subscribers on
	// `sme.tenant.events` have been notified.
	STSDone SMETenantProvisionState = "done"
	// STSFailed — terminal sad state. LastError carries a structured
	// `<step>:<class>:<detail>` string per ADR-0003 §3.8.
	STSFailed SMETenantProvisionState = "failed"
	// STSDeleted — audit row preserved after a tenant tear-down. The
	// inverse pipeline (see ADR-0003 §3.7) marks rows here so the
	// reconciler doesn't pick them up again.
	STSDeleted SMETenantProvisionState = "deleted"
)

// SMEDomainMode discriminates the two domain-onboarding flows the
// pipeline supports per epic #795 [Q-mine-1]. Free-subdomain mode is
// the turnkey default; BYO mode requires the customer to point a CNAME
// at the otech ingress before the pipeline can complete.
type SMEDomainMode string

const (
	// SMEDomainFreeSubdomain — `<subdomain>.<otech-fqdn>`. The
	// orchestrator authoritatively owns the DNS records via
	// PowerDNS; cert issuance is covered by the otech-wide wildcard.
	SMEDomainFreeSubdomain SMEDomainMode = "free-subdomain"
	// SMEDomainBYO — customer-owned domain (`acme.com`). The customer
	// must CNAME `console.acme.com` → otech ingress before the
	// orchestrator can verify ownership; per-host HTTP-01 ACME runs
	// on the otech ingress to mint a per-host certificate.
	SMEDomainBYO SMEDomainMode = "byo"
)

// SMETenantProvisionRecord is the per-tenant state row.
//
// SMETenantID is the primary key — a UUID minted by the orchestrator
// on first POST. It is opaque to humans; the SME admin's mental model
// uses Subdomain ("acme") instead.
type SMETenantProvisionRecord struct {
	SMETenantID string                  `json:"sme_tenant_id"`
	State       SMETenantProvisionState `json:"state"`
	// Subdomain — the operator-supplied SME slug. In free-subdomain
	// mode the resulting console URL is `console.<subdomain>.<otech>`;
	// in BYO mode the slug is metadata only (the BYO host is the
	// canonical key).
	Subdomain string `json:"subdomain"`
	// DomainMode — see SMEDomainMode.
	DomainMode SMEDomainMode `json:"domain_mode"`
	// BYODomain — populated when DomainMode == SMEDomainBYO. The
	// orchestrator builds the host as `console.<byo_domain>`.
	BYODomain string `json:"byo_domain,omitempty"`
	// AdminEmail — the SME's first user. The orchestrator wires this
	// into the bp-wordpress-tenant chart values (admin email) and into
	// the welcome-email subject of the `sme.tenant.events` envelope.
	AdminEmail string `json:"admin_email"`
	// CompanyName — branding metadata (chart titles, welcome email
	// salutation). Optional — empty falls back to the subdomain slug.
	CompanyName string `json:"company_name,omitempty"`
	// OTECHFQDN — captured at create time so the reconciler doesn't
	// have to re-read env. Used to build derived hostnames + the cert
	// issuer ClusterRef and to scope the GitOps overlay path.
	OTECHFQDN string `json:"otech_fqdn"`
	// VClusterName — derived as `vc-<subdomain>`. Captured at create
	// so the orchestrator can reference it in GitOps manifest paths
	// without re-deriving on every reconcile.
	VClusterName string `json:"vcluster_name"`
	// TenantNamespace — `sme-<sme_tenant_id>` per #804 scope §1. The
	// bp-wordpress-tenant / bp-openclaw / bp-stalwart-tenant charts
	// install into this namespace.
	TenantNamespace string `json:"tenant_namespace"`
	// CommitSHA — sha returned by the GitOps push that committed the
	// per-tenant overlay; surfaced in the orchestrator API response so
	// the operator can deep-link the audit trail.
	CommitSHA string `json:"commit_sha,omitempty"`
	// RetryCount — bumped on every transient failure of the same
	// step. Promoted to STSFailed at 5.
	RetryCount int `json:"retry_count"`
	// LastError — structured `<step>:<class>:<detail>` per ADR-0003
	// §3.8. Truncated to 256 bytes.
	LastError string    `json:"last_error,omitempty"`
	CreatedAt time.Time `json:"created_at"`
	UpdatedAt time.Time `json:"updated_at"`
}

// smeTenantDir is the on-disk subdirectory under <store>/sme-tenant.
// One file per tenant (sme_tenant_id.json); a directory walk is fine
// at the cardinality this layer targets (hundreds, not millions).
const smeTenantDir = "sme-tenant"

// SMETenantProvisionStore is the directory-backed implementation.
//
// Concurrency: a single mutex guards the on-disk write+rename — reads
// load the file on every Get so a parallel writer can't return
// stale state.
type SMETenantProvisionStore struct {
	dir string
	mu  sync.Mutex
}

// NewSMETenantProvisionStore returns a store rooted at
// <dir>/sme-tenant. dir must already exist; the subdirectory is
// created at 0o700 perms.
func NewSMETenantProvisionStore(dir string) (*SMETenantProvisionStore, error) {
	if dir == "" {
		return nil, errors.New("sme-tenant store: directory is required")
	}
	root := filepath.Join(dir, smeTenantDir)
	if err := os.MkdirAll(root, 0o700); err != nil {
		return nil, fmt.Errorf("sme-tenant store: mkdir %q: %w", root, err)
	}
	return &SMETenantProvisionStore{dir: root}, nil
}

// Put upserts a record. CreatedAt is preserved across upserts; the
// caller is responsible for setting State/Subdomain/etc. before
// calling. UpdatedAt is bumped automatically.
func (s *SMETenantProvisionStore) Put(rec SMETenantProvisionRecord) error {
	if strings.TrimSpace(rec.SMETenantID) == "" {
		return errors.New("sme-tenant: sme_tenant_id is required")
	}
	if rec.State == "" {
		rec.State = STSPending
	}
	now := time.Now().UTC()
	rec.UpdatedAt = now

	s.mu.Lock()
	defer s.mu.Unlock()

	path := filepath.Join(s.dir, sanitizeID(rec.SMETenantID)+".json")
	if rec.CreatedAt.IsZero() {
		if existing, err := readSMETenantRec(path); err == nil {
			rec.CreatedAt = existing.CreatedAt
		} else {
			rec.CreatedAt = now
		}
	}

	data, err := json.MarshalIndent(rec, "", "  ")
	if err != nil {
		return fmt.Errorf("sme-tenant: marshal: %w", err)
	}
	tmp := path + ".tmp"
	if err := os.WriteFile(tmp, data, 0o600); err != nil {
		return fmt.Errorf("sme-tenant: write tmp: %w", err)
	}
	return os.Rename(tmp, path)
}

// Get returns the record for the given tenant id.
func (s *SMETenantProvisionStore) Get(smeTenantID string) (SMETenantProvisionRecord, bool) {
	path := filepath.Join(s.dir, sanitizeID(smeTenantID)+".json")
	rec, err := readSMETenantRec(path)
	if err != nil {
		return SMETenantProvisionRecord{}, false
	}
	return rec, true
}

// List returns every persisted record sorted by CreatedAt descending
// (newest first). Used by the orchestrator's reconciler to find rows
// that aren't terminal.
func (s *SMETenantProvisionStore) List() []SMETenantProvisionRecord {
	entries, err := os.ReadDir(s.dir)
	if err != nil {
		return nil
	}
	out := make([]SMETenantProvisionRecord, 0, len(entries))
	for _, e := range entries {
		if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
			continue
		}
		path := filepath.Join(s.dir, e.Name())
		if rec, err := readSMETenantRec(path); err == nil {
			out = append(out, rec)
		}
	}
	sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
	return out
}

// ListPending returns every record whose State is not in
// {STSDone, STSFailed, STSDeleted}. The reconciler uses this to find
// rows that need another pass.
func (s *SMETenantProvisionStore) ListPending() []SMETenantProvisionRecord {
	all := s.List()
	out := make([]SMETenantProvisionRecord, 0, len(all))
	for _, rec := range all {
		switch rec.State {
		case STSDone, STSFailed, STSDeleted:
			continue
		}
		out = append(out, rec)
	}
	return out
}

// Delete removes a record. Idempotent — deleting a missing id returns
// nil. Used by tests; production code marks State=STSDeleted for the
// audit trail rather than removing rows.
func (s *SMETenantProvisionStore) Delete(smeTenantID string) error {
	path := filepath.Join(s.dir, sanitizeID(smeTenantID)+".json")
	err := os.Remove(path)
	if err != nil && !errors.Is(err, os.ErrNotExist) {
		return fmt.Errorf("sme-tenant: remove: %w", err)
	}
	return nil
}

func readSMETenantRec(path string) (SMETenantProvisionRecord, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return SMETenantProvisionRecord{}, err
	}
	var rec SMETenantProvisionRecord
	if err := json.Unmarshal(data, &rec); err != nil {
		return SMETenantProvisionRecord{}, err
	}
	return rec, nil
}
