package finetune

import (
	"cmp"
	"context"
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"regexp"
	"slices"
	"strings"
	"sync"
	"time"

	"github.com/google/uuid"
	"github.com/mudler/LocalAI/core/config"
	"github.com/mudler/LocalAI/core/gallery/importers"
	"github.com/mudler/LocalAI/core/schema"
	"github.com/mudler/LocalAI/core/services/distributed"
	"github.com/mudler/LocalAI/core/services/messaging"
	"github.com/mudler/LocalAI/core/services/syncstate"
	pb "github.com/mudler/LocalAI/pkg/grpc/proto"
	"github.com/mudler/LocalAI/pkg/model"
	"github.com/mudler/LocalAI/pkg/utils"
	"github.com/mudler/xlog"
	"gopkg.in/yaml.v3"
)

// FineTuneService manages fine-tuning jobs and their lifecycle.
type FineTuneService struct {
	appConfig    *config.ApplicationConfig
	modelLoader  *model.ModelLoader
	configLoader *config.ModelConfigLoader

	// mu serializes the read-modify-write of job values. The SyncedMap guards its
	// own map structure, but a job is a pointer mutated in place (e.g. the export
	// goroutine), so the service still needs a lock to keep those field updates
	// and the subsequent Set atomic with respect to readers.
	mu sync.Mutex

	// jobs is the cross-replica job store: an in-memory map kept consistent across
	// replicas via NATS, optionally read-through to PostgreSQL in distributed mode.
	jobs *syncstate.SyncedMap[string, *schema.FineTuneJob]
}

// NewFineTuneService creates a new FineTuneService. In distributed mode pass the
// shared NATS client and PostgreSQL store so jobs stay consistent across
// replicas; pass nil for both in standalone mode, where the disk Loader hydrates
// the map and there is nothing to broadcast.
func NewFineTuneService(
	appConfig *config.ApplicationConfig,
	modelLoader *model.ModelLoader,
	configLoader *config.ModelConfigLoader,
	nats messaging.MessagingClient,
	store *distributed.FineTuneStore,
) *FineTuneService {
	s := &FineTuneService{
		appConfig:    appConfig,
		modelLoader:  modelLoader,
		configLoader: configLoader,
	}

	// Only attach a Store interface when a concrete store exists, otherwise the
	// SyncedMap would see a non-nil interface wrapping a nil pointer and try to
	// hydrate/write through a nil DB.
	var syncStore syncstate.Store[string, *schema.FineTuneJob]
	if store != nil {
		syncStore = &fineTuneStoreAdapter{store: store}
	}

	s.jobs = syncstate.New(syncstate.Config[string, *schema.FineTuneJob]{
		Name:   "finetune.jobs",
		Key:    func(j *schema.FineTuneJob) string { return j.ID },
		Nats:   nats,
		Store:  syncStore,
		Loader: s.loadJobsFromDisk, // ignored when Store is set (distributed mode)
	})

	// Hydrate + subscribe. A hydrate failure must not take the server down: log
	// and continue degraded (standalone), mirroring the OpCache wiring.
	if err := s.jobs.Start(appConfig.Context); err != nil {
		xlog.Warn("FineTune SyncedMap start failed; running degraded", "error", err)
	}
	return s
}

// Close releases the SyncedMap subscription and background workers.
func (s *FineTuneService) Close() error {
	return s.jobs.Close()
}

// fineTuneBaseDir returns the base directory for fine-tune job data.
func (s *FineTuneService) fineTuneBaseDir() string {
	return filepath.Join(s.appConfig.DataPath, "fine-tune")
}

// jobDir returns the directory for a specific job.
func (s *FineTuneService) jobDir(jobID string) string {
	return filepath.Join(s.fineTuneBaseDir(), jobID)
}

// saveJobState persists a job's state to disk as state.json.
func (s *FineTuneService) saveJobState(job *schema.FineTuneJob) {
	dir := s.jobDir(job.ID)
	if err := os.MkdirAll(dir, 0750); err != nil {
		xlog.Error("Failed to create job directory", "job_id", job.ID, "error", err)
		return
	}

	data, err := json.MarshalIndent(job, "", "  ")
	if err != nil {
		xlog.Error("Failed to marshal job state", "job_id", job.ID, "error", err)
		return
	}

	statePath := filepath.Join(dir, "state.json")
	if err := os.WriteFile(statePath, data, 0640); err != nil {
		xlog.Error("Failed to write job state", "job_id", job.ID, "error", err)
	}
}

// loadJobsFromDisk scans the fine-tune directory for persisted jobs and returns
// them. It is the SyncedMap Loader used in standalone mode (no DB); the returned
// slice hydrates the map on Start.
func (s *FineTuneService) loadJobsFromDisk(_ context.Context) ([]*schema.FineTuneJob, error) {
	baseDir := s.fineTuneBaseDir()
	entries, err := os.ReadDir(baseDir)
	if err != nil {
		// Directory doesn't exist yet — that's fine, start empty.
		return nil, nil
	}

	var jobs []*schema.FineTuneJob
	for _, entry := range entries {
		if !entry.IsDir() {
			continue
		}
		statePath := filepath.Join(baseDir, entry.Name(), "state.json")
		data, err := os.ReadFile(statePath)
		if err != nil {
			continue
		}

		var job schema.FineTuneJob
		if err := json.Unmarshal(data, &job); err != nil {
			xlog.Warn("Failed to parse job state", "path", statePath, "error", err)
			continue
		}

		// Jobs that were running when we shut down are now stale
		if job.Status == "queued" || job.Status == "loading_model" || job.Status == "loading_dataset" || job.Status == "training" || job.Status == "saving" {
			job.Status = "stopped"
			job.Message = "Server restarted while job was running"
		}

		// Exports that were in progress are now stale
		if job.ExportStatus == "exporting" {
			job.ExportStatus = "failed"
			job.ExportMessage = "Server restarted while export was running"
		}

		jobs = append(jobs, &job)
	}

	if len(jobs) > 0 {
		xlog.Info("Loaded persisted fine-tune jobs", "count", len(jobs))
	}
	return jobs, nil
}

// StartJob starts a new fine-tuning job.
func (s *FineTuneService) StartJob(ctx context.Context, userID string, req schema.FineTuneJobRequest) (*schema.FineTuneJobResponse, error) {
	s.mu.Lock()
	defer s.mu.Unlock()

	jobID := uuid.New().String()

	backendName := req.Backend
	if backendName == "" {
		backendName = "trl"
	}

	// Always use DataPath for output — not user-configurable
	outputDir := filepath.Join(s.fineTuneBaseDir(), jobID)

	// Build gRPC request
	grpcReq := &pb.FineTuneRequest{
		Model:                     req.Model,
		TrainingType:              req.TrainingType,
		TrainingMethod:            req.TrainingMethod,
		AdapterRank:               req.AdapterRank,
		AdapterAlpha:              req.AdapterAlpha,
		AdapterDropout:            req.AdapterDropout,
		TargetModules:             req.TargetModules,
		LearningRate:              req.LearningRate,
		NumEpochs:                 req.NumEpochs,
		BatchSize:                 req.BatchSize,
		GradientAccumulationSteps: req.GradientAccumulationSteps,
		WarmupSteps:               req.WarmupSteps,
		MaxSteps:                  req.MaxSteps,
		SaveSteps:                 req.SaveSteps,
		WeightDecay:               req.WeightDecay,
		GradientCheckpointing:     req.GradientCheckpointing,
		Optimizer:                 req.Optimizer,
		Seed:                      req.Seed,
		MixedPrecision:            req.MixedPrecision,
		DatasetSource:             req.DatasetSource,
		DatasetSplit:              req.DatasetSplit,
		OutputDir:                 outputDir,
		JobId:                     jobID,
		ResumeFromCheckpoint:      req.ResumeFromCheckpoint,
		ExtraOptions:              req.ExtraOptions,
	}

	// Serialize reward functions into extra_options for the backend
	if len(req.RewardFunctions) > 0 {
		rfJSON, err := json.Marshal(req.RewardFunctions)
		if err != nil {
			return nil, fmt.Errorf("failed to serialize reward functions: %w", err)
		}
		if grpcReq.ExtraOptions == nil {
			grpcReq.ExtraOptions = make(map[string]string)
		}
		grpcReq.ExtraOptions["reward_funcs"] = string(rfJSON)
	}

	// Load the fine-tuning backend (per-job model ID so multiple jobs can run concurrently)
	modelID := backendName + "-finetune-" + jobID
	backendModel, err := s.modelLoader.Load(
		model.WithBackendString(backendName),
		model.WithModel(backendName),
		model.WithModelID(modelID),
	)
	if err != nil {
		return nil, fmt.Errorf("failed to load backend %s: %w", backendName, err)
	}

	// Start fine-tuning via gRPC
	result, err := backendModel.StartFineTune(ctx, grpcReq)
	if err != nil {
		return nil, fmt.Errorf("failed to start fine-tuning: %w", err)
	}
	if !result.Success {
		return nil, fmt.Errorf("fine-tuning failed to start: %s", result.Message)
	}

	// Track the job
	job := &schema.FineTuneJob{
		ID:             jobID,
		UserID:         userID,
		Model:          req.Model,
		Backend:        backendName,
		ModelID:        modelID,
		TrainingType:   req.TrainingType,
		TrainingMethod: req.TrainingMethod,
		Status:         "queued",
		OutputDir:      outputDir,
		ExtraOptions:   req.ExtraOptions,
		CreatedAt:      time.Now().UTC().Format(time.RFC3339),
		Config:         &req,
	}
	// Set write-through persists to PostgreSQL (distributed) and broadcasts to
	// peer replicas; the disk state.json is written separately for restart
	// recovery / standalone hydrate.
	if err := s.jobs.Set(ctx, job); err != nil {
		return nil, fmt.Errorf("failed to persist job: %w", err)
	}
	s.saveJobState(job)

	return &schema.FineTuneJobResponse{
		ID:      jobID,
		Status:  "queued",
		Message: result.Message,
	}, nil
}

// GetJob returns a fine-tuning job by ID.
func (s *FineTuneService) GetJob(userID, jobID string) (*schema.FineTuneJob, error) {
	s.mu.Lock()
	defer s.mu.Unlock()

	job, ok := s.jobs.Get(jobID)
	if !ok {
		return nil, fmt.Errorf("job not found: %s", jobID)
	}
	if userID != "" && job.UserID != userID {
		return nil, fmt.Errorf("job not found: %s", jobID)
	}
	return job, nil
}

// ListJobs returns all jobs for a user, sorted by creation time (newest first).
func (s *FineTuneService) ListJobs(userID string) []*schema.FineTuneJob {
	s.mu.Lock()
	defer s.mu.Unlock()

	var result []*schema.FineTuneJob
	for _, job := range s.jobs.List() {
		if userID == "" || job.UserID == userID {
			result = append(result, job)
		}
	}

	slices.SortFunc(result, func(a, b *schema.FineTuneJob) int {
		return cmp.Compare(b.CreatedAt, a.CreatedAt)
	})

	return result
}

// StopJob stops a running fine-tuning job.
func (s *FineTuneService) StopJob(ctx context.Context, userID, jobID string, saveCheckpoint bool) error {
	s.mu.Lock()
	job, ok := s.jobs.Get(jobID)
	if !ok {
		s.mu.Unlock()
		return fmt.Errorf("job not found: %s", jobID)
	}
	if userID != "" && job.UserID != userID {
		s.mu.Unlock()
		return fmt.Errorf("job not found: %s", jobID)
	}
	s.mu.Unlock()

	// Kill the backend process directly
	stopModelID := job.ModelID
	if stopModelID == "" {
		stopModelID = job.Backend + "-finetune"
	}
	s.modelLoader.ShutdownModel(stopModelID)

	s.mu.Lock()
	job.Status = "stopped"
	job.Message = "Training stopped by user"
	if err := s.jobs.Set(ctx, job); err != nil {
		xlog.Warn("Failed to persist stopped job", "job_id", jobID, "error", err)
	}
	s.saveJobState(job)
	s.mu.Unlock()

	return nil
}

// DeleteJob removes a fine-tuning job and its associated data from disk.
func (s *FineTuneService) DeleteJob(userID, jobID string) error {
	s.mu.Lock()
	job, ok := s.jobs.Get(jobID)
	if !ok {
		s.mu.Unlock()
		return fmt.Errorf("job not found: %s", jobID)
	}
	if userID != "" && job.UserID != userID {
		s.mu.Unlock()
		return fmt.Errorf("job not found: %s", jobID)
	}

	// Reject deletion of actively running jobs
	activeStatuses := map[string]bool{
		"queued": true, "loading_model": true, "loading_dataset": true,
		"training": true, "saving": true,
	}
	if activeStatuses[job.Status] {
		s.mu.Unlock()
		return fmt.Errorf("cannot delete job %s: currently %s (stop it first)", jobID, job.Status)
	}
	if job.ExportStatus == "exporting" {
		s.mu.Unlock()
		return fmt.Errorf("cannot delete job %s: export in progress", jobID)
	}

	exportModelName := job.ExportModelName
	// Delete write-through removes the DB row (distributed) and broadcasts the
	// removal to peer replicas. DeleteJob has no ctx, so use Background.
	if err := s.jobs.Delete(context.Background(), jobID); err != nil {
		xlog.Warn("Failed to delete job from store", "job_id", jobID, "error", err)
	}
	s.mu.Unlock()

	// Remove job directory (state.json, checkpoints, output)
	jobDir := s.jobDir(jobID)
	if err := os.RemoveAll(jobDir); err != nil {
		xlog.Warn("Failed to remove job directory", "job_id", jobID, "path", jobDir, "error", err)
	}

	// If an exported model exists, clean it up too
	if exportModelName != "" {
		modelsPath := s.appConfig.SystemState.Model.ModelsPath
		modelDir := filepath.Join(modelsPath, exportModelName)
		configPath := filepath.Join(modelsPath, exportModelName+".yaml")

		if err := os.RemoveAll(modelDir); err != nil {
			xlog.Warn("Failed to remove exported model directory", "path", modelDir, "error", err)
		}
		if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) {
			xlog.Warn("Failed to remove exported model config", "path", configPath, "error", err)
		}

		// Reload model configs
		if err := s.configLoader.LoadModelConfigsFromPath(modelsPath, s.appConfig.ToConfigLoaderOptions()...); err != nil {
			xlog.Warn("Failed to reload configs after delete", "error", err)
		}
	}

	xlog.Info("Deleted fine-tune job", "job_id", jobID)
	return nil
}

// StreamProgress opens a gRPC progress stream and calls the callback for each update.
func (s *FineTuneService) StreamProgress(ctx context.Context, userID, jobID string, callback func(event *schema.FineTuneProgressEvent)) error {
	s.mu.Lock()
	job, ok := s.jobs.Get(jobID)
	if !ok {
		s.mu.Unlock()
		return fmt.Errorf("job not found: %s", jobID)
	}
	if userID != "" && job.UserID != userID {
		s.mu.Unlock()
		return fmt.Errorf("job not found: %s", jobID)
	}
	s.mu.Unlock()

	streamModelID := job.ModelID
	if streamModelID == "" {
		streamModelID = job.Backend + "-finetune"
	}
	backendModel, err := s.modelLoader.Load(
		model.WithBackendString(job.Backend),
		model.WithModel(job.Backend),
		model.WithModelID(streamModelID),
	)
	if err != nil {
		return fmt.Errorf("failed to load backend: %w", err)
	}

	return backendModel.FineTuneProgress(ctx, &pb.FineTuneProgressRequest{
		JobId: jobID,
	}, func(update *pb.FineTuneProgressUpdate) {
		// Update job status and persist
		s.mu.Lock()
		if j, ok := s.jobs.Get(jobID); ok {
			// Don't let progress updates overwrite terminal states
			isTerminal := j.Status == "stopped" || j.Status == "completed" || j.Status == "failed"
			if !isTerminal {
				j.Status = update.Status
			}
			if update.Message != "" {
				j.Message = update.Message
			}
			if err := s.jobs.Set(ctx, j); err != nil {
				xlog.Warn("Failed to persist progress update", "job_id", jobID, "error", err)
			}
			s.saveJobState(j)
		}
		s.mu.Unlock()

		// Convert extra metrics
		extraMetrics := make(map[string]float32)
		for k, v := range update.ExtraMetrics {
			extraMetrics[k] = v
		}

		event := &schema.FineTuneProgressEvent{
			JobID:           update.JobId,
			CurrentStep:     update.CurrentStep,
			TotalSteps:      update.TotalSteps,
			CurrentEpoch:    update.CurrentEpoch,
			TotalEpochs:     update.TotalEpochs,
			Loss:            update.Loss,
			LearningRate:    update.LearningRate,
			GradNorm:        update.GradNorm,
			EvalLoss:        update.EvalLoss,
			EtaSeconds:      update.EtaSeconds,
			ProgressPercent: update.ProgressPercent,
			Status:          update.Status,
			Message:         update.Message,
			CheckpointPath:  update.CheckpointPath,
			SamplePath:      update.SamplePath,
			ExtraMetrics:    extraMetrics,
		}
		callback(event)
	})
}

// ListCheckpoints lists checkpoints for a job.
func (s *FineTuneService) ListCheckpoints(ctx context.Context, userID, jobID string) ([]*pb.CheckpointInfo, error) {
	s.mu.Lock()
	job, ok := s.jobs.Get(jobID)
	if !ok {
		s.mu.Unlock()
		return nil, fmt.Errorf("job not found: %s", jobID)
	}
	if userID != "" && job.UserID != userID {
		s.mu.Unlock()
		return nil, fmt.Errorf("job not found: %s", jobID)
	}
	s.mu.Unlock()

	ckptModelID := job.ModelID
	if ckptModelID == "" {
		ckptModelID = job.Backend + "-finetune"
	}
	backendModel, err := s.modelLoader.Load(
		model.WithBackendString(job.Backend),
		model.WithModel(job.Backend),
		model.WithModelID(ckptModelID),
	)
	if err != nil {
		return nil, fmt.Errorf("failed to load backend: %w", err)
	}

	resp, err := backendModel.ListCheckpoints(ctx, &pb.ListCheckpointsRequest{
		OutputDir: job.OutputDir,
	})
	if err != nil {
		return nil, fmt.Errorf("failed to list checkpoints: %w", err)
	}

	return resp.Checkpoints, nil
}

// sanitizeModelName replaces non-alphanumeric characters with hyphens and lowercases.
func sanitizeModelName(s string) string {
	re := regexp.MustCompile(`[^a-zA-Z0-9\-]`)
	s = re.ReplaceAllString(s, "-")
	s = regexp.MustCompile(`-+`).ReplaceAllString(s, "-")
	s = strings.Trim(s, "-")
	return strings.ToLower(s)
}

// ExportModel starts an async model export from a checkpoint and returns the intended model name immediately.
func (s *FineTuneService) ExportModel(ctx context.Context, userID, jobID string, req schema.ExportRequest) (string, error) {
	s.mu.Lock()
	job, ok := s.jobs.Get(jobID)
	if !ok {
		s.mu.Unlock()
		return "", fmt.Errorf("job not found: %s", jobID)
	}
	if userID != "" && job.UserID != userID {
		s.mu.Unlock()
		return "", fmt.Errorf("job not found: %s", jobID)
	}
	if job.ExportStatus == "exporting" {
		s.mu.Unlock()
		return "", fmt.Errorf("export already in progress for job %s", jobID)
	}
	s.mu.Unlock()

	// Compute model name
	modelName := req.Name
	if modelName == "" {
		base := sanitizeModelName(job.Model)
		if base == "" {
			base = "model"
		}
		shortID := jobID
		if len(shortID) > 8 {
			shortID = shortID[:8]
		}
		modelName = base + "-ft-" + shortID
	}

	// Compute output path in models directory
	modelsPath := s.appConfig.SystemState.Model.ModelsPath
	outputPath := filepath.Join(modelsPath, modelName)

	// Check for name collision (synchronous — fast validation)
	configPath := filepath.Join(modelsPath, modelName+".yaml")
	if err := utils.VerifyPath(modelName+".yaml", modelsPath); err != nil {
		return "", fmt.Errorf("invalid model name: %w", err)
	}
	if _, err := os.Stat(configPath); err == nil {
		return "", fmt.Errorf("model %q already exists, choose a different name", modelName)
	}

	// Create output directory
	if err := os.MkdirAll(outputPath, 0750); err != nil {
		return "", fmt.Errorf("failed to create output directory: %w", err)
	}

	// Set export status to "exporting" and persist
	s.mu.Lock()
	job.ExportStatus = "exporting"
	job.ExportMessage = ""
	job.ExportModelName = ""
	if err := s.jobs.Set(ctx, job); err != nil {
		xlog.Warn("Failed to persist export start", "job_id", jobID, "error", err)
	}
	s.saveJobState(job)
	s.mu.Unlock()

	// Launch the export in a background goroutine
	go func() {
		s.setExportMessage(job, "Loading export backend...")

		exportModelID := job.ModelID
		if exportModelID == "" {
			exportModelID = job.Backend + "-finetune"
		}
		backendModel, err := s.modelLoader.Load(
			model.WithBackendString(job.Backend),
			model.WithModel(job.Backend),
			model.WithModelID(exportModelID),
		)
		if err != nil {
			s.setExportFailed(job, fmt.Sprintf("failed to load backend: %v", err))
			return
		}

		// Merge job's extra_options (contains hf_token from training) with request's
		mergedOpts := make(map[string]string)
		for k, v := range job.ExtraOptions {
			mergedOpts[k] = v
		}
		for k, v := range req.ExtraOptions {
			mergedOpts[k] = v // request overrides job
		}

		grpcReq := &pb.ExportModelRequest{
			CheckpointPath:     req.CheckpointPath,
			OutputPath:         outputPath,
			ExportFormat:       req.ExportFormat,
			QuantizationMethod: req.QuantizationMethod,
			Model:              req.Model,
			ExtraOptions:       mergedOpts,
		}

		s.setExportMessage(job, "Running model export (merging and converting — this may take a while)...")

		result, err := backendModel.ExportModel(context.Background(), grpcReq)
		if err != nil {
			s.setExportFailed(job, fmt.Sprintf("export failed: %v", err))
			return
		}
		if !result.Success {
			s.setExportFailed(job, fmt.Sprintf("export failed: %s", result.Message))
			return
		}

		s.setExportMessage(job, "Export complete, generating model configuration...")

		// Auto-import: detect format and generate config
		cfg, err := importers.ImportLocalPath(outputPath, modelName)
		if err != nil {
			s.setExportFailed(job, fmt.Sprintf("model exported to %s but config generation failed: %v", outputPath, err))
			return
		}

		cfg.Name = modelName

		// If base model not detected from files, use the job's model field
		if cfg.Model == "" && job.Model != "" {
			cfg.Model = job.Model
		}

		// Write YAML config
		yamlData, err := yaml.Marshal(cfg)
		if err != nil {
			s.setExportFailed(job, fmt.Sprintf("failed to marshal config: %v", err))
			return
		}
		if err := os.WriteFile(configPath, yamlData, 0644); err != nil {
			s.setExportFailed(job, fmt.Sprintf("failed to write config file: %v", err))
			return
		}

		s.setExportMessage(job, "Registering model with LocalAI...")

		// Reload configs so the model is immediately available
		if err := s.configLoader.LoadModelConfigsFromPath(modelsPath, s.appConfig.ToConfigLoaderOptions()...); err != nil {
			xlog.Warn("Failed to reload configs after export", "error", err)
		}
		if err := s.configLoader.Preload(modelsPath); err != nil {
			xlog.Warn("Failed to preload after export", "error", err)
		}

		xlog.Info("Model exported and registered", "job_id", jobID, "model_name", modelName, "format", req.ExportFormat)

		// Runs after the HTTP request returns, so use Background rather than the
		// (now likely cancelled) request ctx for the write-through.
		s.mu.Lock()
		job.ExportStatus = "completed"
		job.ExportModelName = modelName
		job.ExportMessage = ""
		if err := s.jobs.Set(context.Background(), job); err != nil {
			xlog.Warn("Failed to persist export completion", "job_id", jobID, "error", err)
		}
		s.saveJobState(job)
		s.mu.Unlock()
	}()

	return modelName, nil
}

// setExportMessage updates the export message and persists the job state. Called
// from the background export goroutine, so it uses Background for write-through.
func (s *FineTuneService) setExportMessage(job *schema.FineTuneJob, msg string) {
	s.mu.Lock()
	job.ExportMessage = msg
	if err := s.jobs.Set(context.Background(), job); err != nil {
		xlog.Warn("Failed to persist export message", "job_id", job.ID, "error", err)
	}
	s.saveJobState(job)
	s.mu.Unlock()
}

// GetExportedModelPath returns the path to the exported model directory and its name.
func (s *FineTuneService) GetExportedModelPath(userID, jobID string) (string, string, error) {
	s.mu.Lock()
	job, ok := s.jobs.Get(jobID)
	if !ok {
		s.mu.Unlock()
		return "", "", fmt.Errorf("job not found: %s", jobID)
	}
	if userID != "" && job.UserID != userID {
		s.mu.Unlock()
		return "", "", fmt.Errorf("job not found: %s", jobID)
	}
	if job.ExportStatus != "completed" {
		s.mu.Unlock()
		return "", "", fmt.Errorf("export not completed for job %s (status: %s)", jobID, job.ExportStatus)
	}
	exportModelName := job.ExportModelName
	s.mu.Unlock()

	if exportModelName == "" {
		return "", "", fmt.Errorf("no exported model name for job %s", jobID)
	}

	modelsPath := s.appConfig.SystemState.Model.ModelsPath
	modelDir := filepath.Join(modelsPath, exportModelName)

	if _, err := os.Stat(modelDir); os.IsNotExist(err) {
		return "", "", fmt.Errorf("exported model directory not found: %s", modelDir)
	}

	return modelDir, exportModelName, nil
}

// setExportFailed sets the export status to failed with a message.
func (s *FineTuneService) setExportFailed(job *schema.FineTuneJob, message string) {
	xlog.Error("Export failed", "job_id", job.ID, "error", message)
	s.mu.Lock()
	job.ExportStatus = "failed"
	job.ExportMessage = message
	if err := s.jobs.Set(context.Background(), job); err != nil {
		xlog.Warn("Failed to persist export failure", "job_id", job.ID, "error", err)
	}
	s.saveJobState(job)
	s.mu.Unlock()
}

// UploadDataset handles dataset file upload and returns the local path.
// The filename comes straight from a multipart upload, so strip directory
// components — anything else risks a write under a sibling path.
func (s *FineTuneService) UploadDataset(filename string, data []byte) (string, error) {
	uploadDir := filepath.Join(s.fineTuneBaseDir(), "datasets")
	if err := os.MkdirAll(uploadDir, 0750); err != nil {
		return "", fmt.Errorf("failed to create dataset directory: %w", err)
	}

	base := filepath.Base(filename)
	if base == "." || base == ".." || base == "/" || base == "" {
		return "", fmt.Errorf("invalid filename")
	}
	filePath := filepath.Join(uploadDir, uuid.New().String()[:8]+"-"+base)
	if err := os.WriteFile(filePath, data, 0640); err != nil {
		return "", fmt.Errorf("failed to write dataset: %w", err)
	}

	return filePath, nil
}

// MarshalProgressEvent converts a progress event to JSON for SSE.
func MarshalProgressEvent(event *schema.FineTuneProgressEvent) (string, error) {
	data, err := json.Marshal(event)
	if err != nil {
		return "", err
	}
	return string(data), nil
}
