package main

import (
	"context"
	"encoding/base64"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/exec"
	"os/signal"
	"strconv"
	"strings"
	"syscall"

	// Embed Go's IANA tz database into the binary so time.LoadLocation works
	// without OS tzdata. The runtime image (alpine) doesn't ship /usr/share/
	// zoneinfo — without this import, every reminder timezone lookup fails
	// silently and the hourly reminder slot fires in UTC instead of the
	// user's chosen tz (t-paliad-064 root cause). Adds ~450KB to the binary.
	_ "time/tzdata"

	"mgit.msbls.de/m/paliad/internal/auth"
	"mgit.msbls.de/m/paliad/internal/branding"
	"mgit.msbls.de/m/paliad/internal/db"
	"mgit.msbls.de/m/paliad/internal/handlers"
	"mgit.msbls.de/m/paliad/internal/services"
)

func main() {
	port := os.Getenv("PORT")
	if port == "" {
		port = "8080"
	}

	// Surface the firm name in the boot log so a deployer can confirm
	// FIRM_NAME took effect without curl-ing a rendered page.
	log.Printf("branding: firm=%q (override with FIRM_NAME)", branding.Name)

	supabaseURL := os.Getenv("SUPABASE_URL")
	supabaseAnonKey := os.Getenv("SUPABASE_ANON_KEY")
	if supabaseURL == "" || supabaseAnonKey == "" {
		log.Fatal("SUPABASE_URL and SUPABASE_ANON_KEY must be set")
	}

	jwtSecret := os.Getenv("SUPABASE_JWT_SECRET")
	if jwtSecret == "" {
		log.Fatal("SUPABASE_JWT_SECRET must be set — session cookies cannot be trusted without signature verification")
	}

	client := auth.NewClient(supabaseURL, supabaseAnonKey, []byte(jwtSecret))

	giteaToken := os.Getenv("GITEA_TOKEN")
	if giteaToken == "" {
		log.Println("GITEA_TOKEN not set — file proxy will not be able to access private repos")
	}

	// MailService is wired regardless of DB availability — it no-ops when
	// SMTP env vars are unset, so the server stays runnable for knowledge-
	// platform-only deployments. Template-parse errors at boot are fatal.
	mailSvc, err := services.NewMailService()
	if err != nil {
		log.Fatalf("mail service init: %v", err)
	}

	// Shared context for background goroutines (CalDAV sync + reminder job).
	bgCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer stop()

	// DATABASE_URL is optional during the Phase A → Phase D transition. The
	// existing knowledge-platform features (Kostenrechner, Glossar, etc.) work
	// without a DB. matter-management endpoints return 503 until DATABASE_URL is set.
	dbURL := os.Getenv("DATABASE_URL")
	var svcBundle *handlers.Services
	var caldavSvc *services.CalDAVService

	if dbURL != "" {
		log.Println("applying database migrations…")
		if err := db.ApplyMigrations(dbURL); err != nil {
			log.Fatalf("migration failed: %v", err)
		}
		log.Println("database migrations applied")

		pool, err := db.OpenPool(dbURL)
		if err != nil {
			log.Fatalf("open db pool: %v", err)
		}

		// Refresh paliad.deadline_search whenever migrations run, so
		// search reflects any newly-seeded rule / concept / trigger.
		// Migration 047 created the matview already-populated; this
		// is only a no-op for the boot that introduced it. CONCURRENTLY
		// keeps reads online and stays well under 100 ms at < 1k rows.
		if err := services.RefreshSearchView(bgCtx, pool); err != nil {
			log.Printf("refresh deadline_search: %v", err)
		}
		holidays := services.NewHolidayService(pool)
		courts := services.NewCourtService(pool)
		users := services.NewUserService(pool)
		projectSvc := services.NewProjectService(pool, users)
		teamSvc := services.NewTeamService(pool, projectSvc)
		partnerUnitSvc := services.NewPartnerUnitService(pool, users)
		rules := services.NewDeadlineRuleService(pool)

		// Phase F: optional CalDAV cipher. If CALDAV_ENCRYPTION_KEY is unset
		// the service exists but Enabled() reports false; handlers return 501.
		// If the env var is malformed, fail fast — silently skipping would
		// leave plaintext-credential bugs hidden.
		cipher, err := services.LoadCalDAVCipher()
		if err != nil {
			log.Fatalf("CALDAV_ENCRYPTION_KEY: %v", err)
		}
		if cipher == nil {
			log.Println("CALDAV_ENCRYPTION_KEY not set — CalDAV endpoints will return 501")
		} else {
			log.Println("CalDAV encryption configured (AES-256-GCM)")
		}

		appointmentSvc := services.NewAppointmentService(pool, projectSvc)
		bindingSvc := services.NewCalendarBindingService(pool)
		targetSvc := services.NewAppointmentTargetService(pool)
		caldavSvc = services.NewCalDAVService(pool, cipher, appointmentSvc, bindingSvc, targetSvc)
		// Wire the push hook so user-driven mutations sync to the external
		// calendar without waiting for the next 60-second tick.
		appointmentSvc.SetCalDAVPusher(caldavSvc)

		baseURL := os.Getenv("PALIAD_BASE_URL")
		inviteSvc := services.NewInviteService(pool, mailSvc, handlers.AllowedEmailDomains, baseURL)
		reminderSvc := services.NewReminderService(pool, mailSvc, users, baseURL)

		// t-paliad-223 Slice B (#49) — Supabase Admin API client for the
		// new "Konto direkt anlegen" path on /admin/team. The key is
		// optional: when unset the client still wires (so dependents
		// don't panic) but every call short-circuits with
		// ErrSupabaseAdminUnavailable so the rest of the server stays
		// runnable.
		supabaseAdminClient := services.LoadSupabaseAdminClient()
		if supabaseAdminClient.Enabled() {
			log.Println("supabase admin API configured — /admin/team Add-User path active")
		} else {
			log.Println("SUPABASE_SERVICE_ROLE_KEY not set — /admin/team Add-User path will return 503")
		}
		users.SetAddUserDeps(supabaseAdminClient, mailSvc, baseURL)

		// Wire EmailTemplateService onto the MailService so DB-backed admin
		// edits propagate without a process restart. The constructor is split
		// from MailService creation because the DB pool isn't available yet
		// at the point we build mailSvc above.
		emailTemplateSvc := services.NewEmailTemplateService(pool)
		mailSvc.SetTemplateService(emailTemplateSvc)

		eventTypeSvc := services.NewEventTypeService(pool, users)
		deadlineSvc := services.NewDeadlineService(pool, projectSvc, eventTypeSvc)
		partySvc := services.NewPartyService(pool, projectSvc)
		// t-paliad-238 — dedicated submission draft editor. The variable
		// bag service is shared between the renderer (export) and the
		// preview HTML path. Resurrected from t-paliad-215 Slice 1 backend
		// (commits 3677c81 + 1765d5e + 8ea3509).
		submissionVarsSvc := services.NewSubmissionVarsService(pool, projectSvc, partySvc, users)
		submissionRenderer := services.NewSubmissionRenderer()
		submissionDraftSvc := services.NewSubmissionDraftService(pool, projectSvc, submissionVarsSvc, submissionRenderer)
		// t-paliad-313 Composer Slice A — base catalog + section seeding.
		// AttachComposer wires both into the draft service so Create
		// seeds base_id + submission_sections rows on new drafts. v1
		// fallback path stays active for pre-Composer drafts (base_id
		// NULL, no section rows).
		submissionBaseSvc := services.NewBaseService(pool)
		submissionSectionSvc := services.NewSectionService(pool)
		submissionDraftSvc.AttachComposer(submissionBaseSvc, submissionSectionSvc, branding.Name)
		// t-paliad-313 Slice B — render-pipeline assembler. Reuses the
		// existing SubmissionRenderer for the final placeholder pass so
		// the {{rule.X}} alias contract stays preserved inside the
		// composed body.
		submissionComposerSvc := services.NewSubmissionComposer(submissionRenderer)
		// t-paliad-315 Slice C — building-block library.
		submissionBuildingBlockSvc := services.NewBuildingBlockService(pool, branding.Name)
		// t-paliad-349 docforge slice 4/6 — uploaded-template store
		// (Postgres bytea) backing the authoring surface.
		templateStoreSvc := services.NewPgTemplateStore(pool)
		// t-paliad-225 Slice A — user-authored checklist templates.
		// Slice B adds checklist_shares grants + admin promotion.
		checklistCatalogSvc := services.NewChecklistCatalogService(pool)
		sysAuditSvc := services.NewSystemAuditLogService(pool)
		checklistTemplateSvc := services.NewChecklistTemplateService(pool, checklistCatalogSvc, sysAuditSvc, users)
		svcBundle = &handlers.Services{
			Pool:           pool,
			Project:        projectSvc,
			Team:           teamSvc,
			PartnerUnit:    partnerUnitSvc,
			Party:          partySvc,
			SubmissionDraft:         submissionDraftSvc,
			SubmissionBase:          submissionBaseSvc,
			SubmissionSection:       submissionSectionSvc,
			SubmissionComposer:      submissionComposerSvc,
			SubmissionBuildingBlock: submissionBuildingBlockSvc,
			TemplateStore:           templateStoreSvc,
			Deadline:       deadlineSvc,
			Appointment:    appointmentSvc,
			CalDAV:         caldavSvc,
			CalDAVBindings: bindingSvc,
			Rules:          rules,
			Calculator:     services.NewDeadlineCalculator(holidays),
			Users:          users,
			Fristenrechner: services.NewFristenrechnerService(rules, holidays, courts),
			EventDeadline: services.NewEventDeadlineService(
				pool,
				services.NewDeadlineCalculator(holidays),
				holidays,
				courts,
				services.NewFristenrechnerService(rules, holidays, courts),
			),
			EventTrigger: services.NewEventTriggerService(pool, rules, holidays, courts),
			RuleEditor:   services.NewRuleEditorService(pool, rules),
			Courts:         courts,
			DeadlineSearch: services.NewDeadlineSearchService(pool),
			EventCategory:  nil, // wired below; cross-link order matters
			EventType:      eventTypeSvc,
			Dashboard:      services.NewDashboardService(pool, users),
			Note:           services.NewNoteService(pool, projectSvc, appointmentSvc),
			ChecklistInst:  services.NewChecklistInstanceService(pool, projectSvc, checklistCatalogSvc),
			ChecklistCatalog: checklistCatalogSvc,
			ChecklistTemplate: checklistTemplateSvc,
			ChecklistShare: services.NewChecklistShareService(pool, checklistTemplateSvc, sysAuditSvc, users),
			ChecklistPromotion: services.NewChecklistPromotionService(pool, checklistTemplateSvc, sysAuditSvc, users),
			Mail:           mailSvc,
			Invite:         inviteSvc,
			Agenda:         services.NewAgendaService(pool, users, eventTypeSvc),
			Audit:          services.NewAuditService(pool),
			EmailTemplate:  emailTemplateSvc,
			Link:           services.NewLinkService(pool),
			Event:          services.NewEventService(pool, deadlineSvc, appointmentSvc),
			Approval:       services.NewApprovalService(pool, users),
			Derivation:     services.NewDerivationService(pool, projectSvc, partnerUnitSvc),
			UserView:       services.NewUserViewService(pool),
			Broadcast:      services.NewBroadcastService(pool, mailSvc, users, teamSvc, emailTemplateSvc),
			Pin:            services.NewPinService(pool, projectSvc),
			CardLayout:              services.NewCardLayoutService(pool),
			DashboardLayout:         services.NewDashboardLayoutService(pool),
			FirmDashboardDefault:    services.NewFirmDashboardDefaultService(pool),
			Projection:      services.NewProjectionService(pool, projectSvc, deadlineSvc, appointmentSvc, services.NewFristenrechnerService(rules, holidays, courts), rules),
			// t-paliad-214 Slice 1 — personal-scope data export. firm name
			// is captured into __meta of every export and printed in the
			// embedded README.
			Export: services.NewExportService(pool, branding.Name),
			// t-paliad-265 / m/paliad#96 — per-event-card optional choices.
			EventChoice: services.NewEventChoiceService(pool, projectSvc, users),
			// Slice D (m/paliad#124 §5, mig 145) — named scenario compositions.
			Scenario: services.NewScenarioService(pool, projectSvc, rules),
			// m/paliad#149 Phase 2 P0 (mig 154) — per-project scenario_flags
			// SSoT. Drives Verfahrensablauf + Mode B result-view conditional
			// rendering and per-rule selection state (`rule:<uuid>` keys).
			ScenarioFlags: services.NewScenarioFlagsService(pool, projectSvc),
			// t-paliad-340 / m/paliad#153 B0 (mig 157) — Litigation Builder.
			// CRUD over the new normalised scenarios + scenario_proceedings
			// + scenario_events + scenario_shares tables. B4 adds the
			// Akte-mode dual-write: project-backed scenarios write through
			// to paliad.projects.scenario_flags + paliad.deadlines via the
			// injected project + scenarioFlags services.
			ScenarioBuilder: services.NewScenarioBuilderService(pool, projectSvc, services.NewScenarioFlagsService(pool, projectSvc), services.NewFristenrechnerService(rules, holidays, courts)),
		}

		// t-paliad-246 Slice A — Backup Mode runner. Wired only when
		// PALIAD_EXPORT_DIR is set (LocalDiskStore needs a target
		// directory). Without it the /admin/backups handlers return 503
		// in the same shape as Paliadin's gate. The directory is created
		// (0700) on first use; a malformed path fails fast at boot so
		// misconfig surfaces before the server starts taking traffic.
		if exportDir := strings.TrimSpace(os.Getenv("PALIAD_EXPORT_DIR")); exportDir != "" {
			store, err := services.NewLocalDiskStore(exportDir)
			if err != nil {
				log.Fatalf("PALIAD_EXPORT_DIR: %v", err)
			}
			svcBundle.Backup = services.NewBackupRunner(pool, svcBundle.Export, store)
			log.Printf("backup: LocalDiskStore at %s (/admin/backups active)", exportDir)
		} else {
			log.Println("PALIAD_EXPORT_DIR not set — /admin/backups will return 503")
		}

		// t-paliad-219 Slice A3 — stitch DashboardService → ApprovalService
		// for the inbox-approvals widget. Done post-construction to avoid
		// a circular constructor dependency (ApprovalService doesn't need
		// the dashboard, and DashboardService can render its other widgets
		// without approvals — so keeping this a setter keeps both
		// constructors simple).
		svcBundle.Dashboard.SetApprovalService(svcBundle.Approval)
		// Slice C wires PinService into DashboardService for the
		// pinned-projects widget. Pin pre-dates t-paliad-219; no new
		// schema, no circular dependency (Pin doesn't know about the
		// dashboard).
		svcBundle.Dashboard.SetPinService(svcBundle.Pin)
		// Slice C wires the firm-wide dashboard default into the
		// per-user layout service so GetOrSeed/ResetToDefault prefer
		// the admin-set firm default over the code-resident factory.
		// Nil-safe: empty firm row falls back to the factory layout.
		svcBundle.DashboardLayout.SetFirmDefaultService(svcBundle.FirmDashboardDefault)

		// t-paliad-230 — submission generator (format-only). No
		// service wiring needed: handlers/submissions.go reuses the
		// existing files.go HL Patents Style cache and calls
		// services.ConvertDotmToDocx (stateless function).

		// Paliadin backend selection.
		//
		// PALIADIN_BACKEND (t-paliad-194 / m/paliad#38):
		//   "aichat"               → AichatPaliadinService (HTTP client of the
		//                              centralized aichat backend on mRiver,
		//                              shipped in m/mAi#207 Phase A).
		//   "legacy" / unset / etc → fall through to the pre-aichat tree:
		//     PALIADIN_REMOTE_HOST set     → RemotePaliadinService (ssh shim)
		//     else: local tmux available    → LocalPaliadinService (PoC path)
		//     else                          → DisabledPaliadinService
		//
		// The aichat path is opt-in for the migration window so a flip
		// back is one env-var change. Once aichat soaks, legacy can be
		// retired in a follow-up slice.
		//
		// All four implementations satisfy services.Paliadin; the per-
		// request handler gate (requirePaliadinOwner) is unchanged.
		switch strings.ToLower(strings.TrimSpace(os.Getenv("PALIADIN_BACKEND"))) {
		case "aichat":
			cfg, err := buildAichatPaliadinConfig(jwtSecret)
			if err != nil {
				log.Fatalf("paliadin: aichat config: %v", err)
			}
			svcBundle.Paliadin = services.NewAichatPaliadinService(pool, users, cfg)
			log.Printf("paliadin: aichat mode → %s persona=%s (owner=%s, rls=%s)",
				cfg.BaseURL, cfg.Persona, services.PaliadinOwnerEmail,
				rlsModeLabel(cfg.JWTSecret))
		default:
			if remoteHost := os.Getenv("PALIADIN_REMOTE_HOST"); remoteHost != "" {
				cfg, err := buildPaliadinRemoteConfig(remoteHost)
				if err != nil {
					log.Fatalf("paliadin: remote config: %v", err)
				}
				svcBundle.Paliadin = services.NewRemotePaliadinService(pool, users, cfg)
				log.Printf("paliadin: remote mode → ssh %s@%s:%d (owner=%s)",
					cfg.SSHUser, cfg.SSHHost, cfg.SSHPort, services.PaliadinOwnerEmail)
			} else if _, err := exec.LookPath("tmux"); err == nil {
				sessionPrefix := os.Getenv("PALIADIN_SESSION_PREFIX")
				responseDir := os.Getenv("PALIADIN_RESPONSE_DIR")
				local := services.NewLocalPaliadinService(pool, users, sessionPrefix, responseDir)
				// Late-response janitor — patches rows when Claude writes the
				// response file after the 60 s pollForResponse window expires.
				// Runs for the process lifetime; cleaned up when bgCtx
				// cancels on SIGTERM.
				local.StartJanitor(bgCtx)
				svcBundle.Paliadin = local
				log.Printf("paliadin: local tmux mode (owner=%s, janitor=on)", services.PaliadinOwnerEmail)
			} else {
				svcBundle.Paliadin = services.NewDisabledPaliadinService(pool, users)
				log.Printf("paliadin: disabled (no PALIADIN_REMOTE_HOST, no local tmux; owner=%s)",
					services.PaliadinOwnerEmail)
			}
		}
		// Wire ApprovalService into the entity services so Create / Update /
		// Complete / Delete consult paliad.approval_policies (t-paliad-138).
		// Without this wiring, the policies and request tables exist but no
		// mutation path consults them — paliad behaves as before.
		deadlineSvc.SetApprovalService(svcBundle.Approval)
		appointmentSvc.SetApprovalService(svcBundle.Approval)
		// v3 (t-paliad-133): wire EventCategoryService and cross-link
		// it into DeadlineSearchService so ?event_category_slug= can
		// resolve to a concept-id allow-list during search.
		eventCategorySvc := services.NewEventCategoryService(pool)
		svcBundle.EventCategory = eventCategorySvc
		svcBundle.DeadlineSearch.SetEventCategoryService(eventCategorySvc)
		log.Println("Phase B services initialised")

		// Spawn background goroutines: CalDAV sync (one per enabled user)
		// and the hourly reminder scanner. Both live for the process
		// lifetime; the signal-scoped context cleans them up on SIGTERM.
		if err := caldavSvc.Start(bgCtx); err != nil {
			log.Printf("CalDAV start: %v", err)
		}
		reminderSvc.Start(bgCtx)
		// Slice B.4 (mig 140, t-paliad-305): legacy paliad.deadline_rules
		// dropped. The B.2 dual-write drift-check loop is retired — the
		// procedural_events / sequencing_rules / legal_sources tables
		// are now the source of truth and there is no parallel side to
		// compare against. Pre-drop drift was verified clean in mig 140.
		go func() {
			<-bgCtx.Done()
			log.Println("background services: shutdown signal received")
			caldavSvc.Stop()
		}()
	} else {
		log.Println("DATABASE_URL not set — matter-management endpoints will return 503")
	}

	mux := http.NewServeMux()
	handlers.Register(mux, client, giteaToken, svcBundle)

	log.Printf("paliad server starting on :%s", port)
	if err := http.ListenAndServe(":"+port, mux); err != nil {
		log.Fatal(err)
	}
}

// buildPaliadinRemoteConfig assembles a RemotePaliadinConfig from
// environment variables, materialising the SSH private key and
// known_hosts blobs into chmod-600/644 tmpfiles for OpenSSH to read.
//
// The blobs travel as Dokploy secrets (multi-line env vars). We never
// persist them to disk — tmpfiles live for the process lifetime in
// /tmp and disappear on container restart. Re-creating them every boot
// is fine; the keys themselves rotate independently via Dokploy
// secret updates.
//
// Required: PALIADIN_REMOTE_HOST, PALIADIN_SSH_PRIVATE_KEY, PALIADIN_KNOWN_HOSTS.
// Optional: PALIADIN_REMOTE_USER (default "m"), PALIADIN_REMOTE_PORT
// (default 22022 — bypasses Tailscale SSH on :22, see design §4.5).
func buildPaliadinRemoteConfig(host string) (services.RemotePaliadinConfig, error) {
	cfg := services.RemotePaliadinConfig{
		SSHHost:       host,
		SSHUser:       cmpOr(os.Getenv("PALIADIN_REMOTE_USER"), "m"),
		SSHPort:       22022,
		SessionPrefix: os.Getenv("PALIADIN_SESSION_PREFIX"),
	}
	if p := os.Getenv("PALIADIN_REMOTE_PORT"); p != "" {
		n, err := strconv.Atoi(p)
		if err != nil || n <= 0 || n > 65535 {
			return cfg, fmt.Errorf("PALIADIN_REMOTE_PORT %q: not a valid port", p)
		}
		cfg.SSHPort = n
	}

	// Dokploy stores compose env vars in a single-line .env file: multi-line
	// PEM bodies get truncated to the first line. Base64-encode the
	// private key in the secret to survive that round-trip; here we
	// detect base64 vs raw PEM and decode either way.
	keyBlob, err := decodePaliadinPrivateKey(os.Getenv("PALIADIN_SSH_PRIVATE_KEY"))
	if err != nil {
		return cfg, fmt.Errorf("PALIADIN_SSH_PRIVATE_KEY: %w", err)
	}
	keyPath, err := writeSecretFile("paliadin-id_ed25519-", keyBlob, 0o600)
	if err != nil {
		return cfg, fmt.Errorf("PALIADIN_SSH_PRIVATE_KEY: %w", err)
	}
	if keyPath == "" {
		return cfg, fmt.Errorf("PALIADIN_REMOTE_HOST set but PALIADIN_SSH_PRIVATE_KEY empty")
	}
	cfg.SSHKeyPath = keyPath

	knownHostsPath, err := writeSecretFile("paliadin-known_hosts-", os.Getenv("PALIADIN_KNOWN_HOSTS"), 0o644)
	if err != nil {
		return cfg, fmt.Errorf("PALIADIN_KNOWN_HOSTS: %w", err)
	}
	if knownHostsPath == "" {
		return cfg, fmt.Errorf("PALIADIN_REMOTE_HOST set but PALIADIN_KNOWN_HOSTS empty")
	}
	cfg.KnownHostsPath = knownHostsPath

	return cfg, nil
}

// decodePaliadinPrivateKey accepts either a raw PEM (multi-line) or a
// base64-encoded PEM. Returns the raw PEM bytes ready to write to a
// keyfile. Empty input → ("", nil) so the caller can distinguish
// "secret not set" from "decode failed".
//
// Why base64: Dokploy stores compose env vars in a one-line-per-key
// .env file, which silently truncates multi-line values to their first
// line. Empirically, a multi-line `-----BEGIN OPENSSH PRIVATE KEY-----`
// arrived inside the container as just the BEGIN header (36 bytes).
// Base64-encoding the key in the Dokploy secret survives that
// round-trip. We still accept raw PEM for local-dev convenience.
func decodePaliadinPrivateKey(blob string) (string, error) {
	blob = strings.TrimSpace(blob)
	if blob == "" {
		return "", nil
	}
	// Raw PEM: starts with ----- and contains a newline. Use as-is.
	if strings.HasPrefix(blob, "-----") && strings.Contains(blob, "\n") {
		return blob + "\n", nil
	}
	// Otherwise treat as base64. Strip any whitespace OpenSSH keygen
	// helpers might insert (line breaks every 64 chars in some tools).
	clean := strings.Map(func(r rune) rune {
		if r == ' ' || r == '\n' || r == '\r' || r == '\t' {
			return -1
		}
		return r
	}, blob)
	decoded, err := base64.StdEncoding.DecodeString(clean)
	if err != nil {
		return "", fmt.Errorf("not raw PEM (no newline) and base64 decode failed: %w", err)
	}
	out := string(decoded)
	if !strings.HasPrefix(out, "-----BEGIN") {
		return "", fmt.Errorf("decoded body does not look like a PEM key (no -----BEGIN prefix)")
	}
	if !strings.HasSuffix(out, "\n") {
		out += "\n"
	}
	return out, nil
}

// writeSecretFile writes blob to a tmpfile with the given mode and
// returns its path. Returns ("", nil) when blob is empty so callers
// can distinguish "not set" from real I/O errors.
func writeSecretFile(prefix, blob string, mode os.FileMode) (string, error) {
	if blob == "" {
		return "", nil
	}
	f, err := os.CreateTemp("", prefix+"*")
	if err != nil {
		return "", err
	}
	if _, err := f.WriteString(blob); err != nil {
		_ = f.Close()
		_ = os.Remove(f.Name())
		return "", err
	}
	if err := f.Close(); err != nil {
		return "", err
	}
	if err := os.Chmod(f.Name(), mode); err != nil {
		return "", err
	}
	return f.Name(), nil
}

func cmpOr(s, fallback string) string {
	if s != "" {
		return s
	}
	return fallback
}

// buildAichatPaliadinConfig assembles an AichatPaliadinConfig from the
// environment for PALIADIN_BACKEND=aichat (t-paliad-194 / m/paliad#38).
//
// Required:
//
//	AICHAT_URL    — service root (e.g. http://100.99.98.203:8765).
//	AICHAT_TOKEN  — raw bearer token paliad's app_id is registered
//	                under in aichat's tokens.yaml (see m/mAi
//	                docs/reference/aichat-deploy.md).
//
// Optional:
//
//	AICHAT_PERSONA — persona id; defaults to "paliadin".
//
// jwtSecret comes from the same SUPABASE_JWT_SECRET that auth.NewClient
// already requires at boot — never empty when we reach this code path.
// It's threaded in so the aichat service can mint per-turn user-scoped
// JWTs (folded-in t-paliad-156 work).
func buildAichatPaliadinConfig(jwtSecret string) (services.AichatPaliadinConfig, error) {
	cfg := services.AichatPaliadinConfig{
		BaseURL:     strings.TrimRight(os.Getenv("AICHAT_URL"), "/"),
		BearerToken: os.Getenv("AICHAT_TOKEN"),
		Persona:     cmpOr(os.Getenv("AICHAT_PERSONA"), services.DefaultAichatPersona),
		JWTSecret:   []byte(jwtSecret),
	}
	if cfg.BaseURL == "" {
		return cfg, fmt.Errorf("AICHAT_URL must be set when PALIADIN_BACKEND=aichat")
	}
	if cfg.BearerToken == "" {
		return cfg, fmt.Errorf("AICHAT_TOKEN must be set when PALIADIN_BACKEND=aichat")
	}
	return cfg, nil
}

// rlsModeLabel labels the boot log so the operator can confirm whether
// the per-user JWT mint is active. "per-user" means we're handing the
// claude pane user-scoped claims; "service-role" means we're not (no
// SUPABASE_JWT_SECRET) and the skill will reject queries rather than
// run as supabase_admin.
func rlsModeLabel(secret []byte) string {
	if len(secret) == 0 {
		return "service-role"
	}
	return "per-user"
}
