package legacyimport

import (
	"context"
	"fmt"
	"os"
	"os/exec"
	"regexp"
	"sort"
	"strings"
	"time"

	"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)

// Store is the narrow slice of the rewrite's native storage layer the importer
// writes through. *sqlite.Store satisfies it. Idempotency lives here: a project
// or orchestrator whose id already exists is skipped, never overwritten, so a
// re-run is safe and legacy files stay the sole source of truth.
type Store interface {
	GetProject(ctx context.Context, id string) (domain.ProjectRecord, bool, error)
	UpsertProject(ctx context.Context, r domain.ProjectRecord) error
	GetSession(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error)
	ImportSession(ctx context.Context, rec domain.SessionRecord, num int64) (bool, error)
}

// Options configure one import run.
type Options struct {
	// Root is the legacy state root to read (default ~/.agent-orchestrator).
	Root string
	// DataDir is the rewrite data dir, used only to compute the destination
	// transcript slug. It must match the daemon's AO_DATA_DIR.
	DataDir string
	// DryRun parses + plans every row and relocation but writes nothing.
	DryRun bool
	// ClaudeProjectsDir overrides ~/.claude/projects (tests).
	ClaudeProjectsDir string
	// Now is the fallback registered_at timestamp. Zero → time.Now().UTC().
	Now time.Time
	// RepoOriginURL resolves a repo's git origin. Nil → the real git resolver.
	RepoOriginURL func(path string) string
}

// Report is the structured outcome of an import run.
type Report struct {
	DryRun                bool     `json:"dryRun"`
	ProjectsImported      int      `json:"projectsImported"`
	ProjectsSkipped       int      `json:"projectsSkipped"` // already present
	OrchestratorsImported int      `json:"orchestratorsImported"`
	OrchestratorsSkipped  int      `json:"orchestratorsSkipped"` // terminal / non-importable / already present
	OrchestratorsAbsent   int      `json:"orchestratorsAbsent"`
	TranscriptsRelocated  int      `json:"transcriptsRelocated"`
	Notes                 []string `json:"notes,omitempty"`
}

// HasLegacyData reports whether root holds an importable legacy store: a
// config.yaml with at least one project. Used for the first-boot opt-in check.
func HasLegacyData(root string) bool {
	if root == "" {
		return false
	}
	cfg, err := loadLegacyConfig(root)
	if err != nil {
		return false
	}
	return len(cfg.Projects) > 0
}

// rewriteProjectID gates the rewrite project-id charset (validateProjectID,
// service.go). Legacy ids are a strict subset, so this all but always passes;
// it guards against a hand-edited legacy config carrying an illegal id.
var rewriteProjectID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)

func isValidRewriteProjectID(id string) bool {
	return id != "" && id != "." && !strings.Contains(id, "..") &&
		!strings.ContainsAny(id, `/\`) && rewriteProjectID.MatchString(id)
}

// Run reads the legacy store and writes projects (then orchestrator sessions)
// into store, relocating claude-code transcripts. It never modifies legacy
// files. It is idempotent: existing rows are skipped. A per-project parse or
// write failure is recorded as a note and does not abort the whole run, except a
// store write error, which is returned.
func Run(ctx context.Context, store Store, opts Options) (Report, error) {
	root := opts.Root
	if root == "" {
		root = DefaultLegacyRootDir()
	}
	now := opts.Now
	if now.IsZero() {
		now = time.Now().UTC()
	}
	resolveOrigin := opts.RepoOriginURL
	if resolveOrigin == nil {
		resolveOrigin = defaultRepoOriginURL
	}

	rep := Report{DryRun: opts.DryRun}

	cfg, err := loadLegacyConfig(root)
	if err != nil {
		return rep, err
	}
	if len(cfg.Projects) == 0 {
		rep.Notes = append(rep.Notes, "no legacy projects found at "+root)
		return rep, nil
	}

	configMtime := ""
	if info, err := os.Stat(globalConfigPath(root)); err == nil {
		configMtime = info.ModTime().UTC().Format(time.RFC3339)
	}
	prefs := loadPreferences(root)
	reg := loadRegistered(root)

	// Deterministic order: projects before sessions, ids sorted.
	ids := make([]string, 0, len(cfg.Projects))
	for id := range cfg.Projects {
		ids = append(ids, id)
	}
	sort.Strings(ids)

	deps := projectRowDeps{repoOriginURL: resolveOrigin, configMtime: configMtime, now: now}

	for _, id := range ids {
		pc := cfg.Projects[id]
		if !isValidRewriteProjectID(id) {
			rep.Notes = append(rep.Notes, "project "+quote(id)+" skipped: id is not a valid rewrite project id")
			continue
		}

		record, notes := buildProjectRecord(id, pc, prefs, reg, deps)
		rep.Notes = appendPrefixed(rep.Notes, id, notes)

		if err := importProject(ctx, store, record, opts.DryRun, &rep); err != nil {
			return rep, err
		}

		// Orchestrator session for this project.
		sessionsDir := projectSessionsDir(root, id)
		mapping := readOrchestratorMapping(sessionsDir, id, pc)
		if mapping.note != "" {
			rep.Notes = append(rep.Notes, id+": "+mapping.note)
		}
		switch mapping.status {
		case orchAbsent:
			rep.OrchestratorsAbsent++
		case orchSkipped:
			rep.OrchestratorsSkipped++
		case orchMapped:
			if err := importOrchestrator(ctx, store, mapping, opts, &rep); err != nil {
				return rep, err
			}
		}
	}
	return rep, nil
}

func importProject(ctx context.Context, store Store, record domain.ProjectRecord, dryRun bool, rep *Report) error {
	_, exists, err := store.GetProject(ctx, record.ID)
	if err != nil {
		return fmt.Errorf("lookup project %s: %w", record.ID, err)
	}
	if exists {
		rep.ProjectsSkipped++
		return nil
	}
	if dryRun {
		rep.ProjectsImported++
		return nil
	}
	if err := store.UpsertProject(ctx, record); err != nil {
		return fmt.Errorf("write project %s: %w", record.ID, err)
	}
	rep.ProjectsImported++
	return nil
}

func importOrchestrator(ctx context.Context, store Store, mapping orchestratorMapping, opts Options, rep *Report) error {
	rec := mapping.record
	_, exists, err := store.GetSession(ctx, rec.ID)
	if err != nil {
		return fmt.Errorf("lookup orchestrator %s: %w", rec.ID, err)
	}
	if exists {
		rep.OrchestratorsSkipped++
	} else if opts.DryRun {
		rep.OrchestratorsImported++
	} else {
		inserted, err := store.ImportSession(ctx, rec, 0)
		if err != nil {
			return fmt.Errorf("write orchestrator %s: %w", rec.ID, err)
		}
		if inserted {
			rep.OrchestratorsImported++
		} else {
			rep.OrchestratorsSkipped++
		}
	}

	// Relocate the claude-code transcript (codex/opencode resume by global id).
	if mapping.transcript == nil {
		return nil
	}
	plan := planTranscriptCopy(opts.DataDir, mapping.projectID, mapping.prefix,
		mapping.transcript.worktree, mapping.transcript.uuid, opts.ClaudeProjectsDir)
	if opts.DryRun {
		if _, err := os.Stat(plan.sourcePath); err == nil {
			rep.TranscriptsRelocated++
		}
		return nil
	}
	// Relocation is best-effort: a failure is noted, not fatal — the orchestrator
	// still resumes, just without prior context.
	outcome, relocErr := relocateTranscript(plan)
	switch {
	case relocErr != nil:
		rep.Notes = append(rep.Notes, mapping.projectID+": transcript relocation failed: "+relocErr.Error())
	case outcome == transcriptCopied:
		rep.TranscriptsRelocated++
	}
	return nil
}

func appendPrefixed(dst []string, id string, notes []string) []string {
	for _, n := range notes {
		dst = append(dst, id+": "+n)
	}
	return dst
}

// defaultRepoOriginURL resolves a repo's git origin URL, "" when the repo is
// absent or has no origin. Matches the rewrite's resolveGitOriginURL.
func defaultRepoOriginURL(path string) string {
	if path == "" {
		return ""
	}
	cmd := exec.Command("git", "-C", path, "remote", "get-url", "origin")
	out, err := cmd.Output()
	if err != nil {
		return ""
	}
	return strings.TrimSpace(string(out))
}
