// promotion-import：将 web/promotion/视频发布 下中文路径素材导入到 uploads + site_assets（与后台上传到 promotion/social 一致）
//
// 用法（在项目 server 目录，已配置 server/.env 中 MONGODB_URI / MONGODB_DB）：
//
//	go run -mod=vendor ./cmd/promotion-import/ -site=站点MongoID
//
// 或指定路径：
//
//	go run -mod=vendor ./cmd/promotion-import/ -site=xxx -src=/www/yh_web/web/promotion/视频发布 -upload=/www/yh_web/data/uploads
package main

import (
	"context"
	"flag"
	"fmt"
	"io"
	"log"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"time"

	"yh_web/server/config"

	"github.com/joho/godotenv"
	"go.mongodb.org/mongo-driver/v2/bson"
	"go.mongodb.org/mongo-driver/v2/mongo"
)

func loadEnv() {
	wd, _ := os.Getwd()
	serverDir := wd
	if !strings.HasSuffix(filepath.Clean(wd), "server") {
		serverDir = filepath.Join(wd, "server")
	}
	envPath := filepath.Clean(filepath.Join(serverDir, ".env"))
	if _, err := os.Stat(envPath); err == nil {
		_ = godotenv.Load(envPath)
		log.Printf("已加载: %s", envPath)
	}
}

func mimeForExt(ext string) string {
	switch strings.ToLower(ext) {
	case ".mov":
		return "video/quicktime"
	case ".mp4":
		return "video/mp4"
	case ".webm":
		return "video/webm"
	case ".jpg", ".jpeg":
		return "image/jpeg"
	case ".png":
		return "image/png"
	default:
		return "application/octet-stream"
	}
}

// importRule：按顺序尝试 SrcRels；再 FallbackScanDir 下智能选文件；EpisodeScan 在非空时扫描 视频发布 下含「实例+一/二」的子目录（兼容半角括号、文件夹名略有差异）
type importRule struct {
	SrcRels         []string
	Dst             string
	FallbackScanDir string
	EpisodeScan     string // "一" 或 "二"
}

// 与 sync-video-assets-to-social.sh 对齐，并增加备选路径（线上常见「实例（一）」内不叫宣传片.mov 的情况）
var mappings = []importRule{
	{[]string{
		"宇恒一号操作计算软件实例（一）/宣传片-封面.jpg",
		"宇恒一号操作计算软件实例（一）/宇恒一号操作计算软件实例（一）-封面.jpg",
		"宇恒一号操作计算软件实例(一)/宣传片-封面.jpg",
		"宇恒一号操作计算软件实例(一)/宇恒一号操作计算软件实例(一)-封面.jpg",
	}, "video-calc-demo-1-cover.jpg", "宇恒一号操作计算软件实例（一）", "一"},
	{[]string{
		"宇恒一号操作计算软件实例（一）/宣传片.mov",
		"宇恒一号操作计算软件实例（一）/宇恒一号操作计算软件实例（一）.mov",
		"宇恒一号操作计算软件实例(一)/宣传片.mov",
		"宇恒一号操作计算软件实例(一)/宇恒一号操作计算软件实例(一).mov",
	}, "video-calc-demo-1.mov", "宇恒一号操作计算软件实例（一）", "一"},
	{[]string{
		"宇恒一号操作计算软件实例（二）/宇恒一号操作计算软件实例（二）-封面.jpg",
		"宇恒一号操作计算软件实例（二）/宣传片-封面.jpg",
		"宇恒一号操作计算软件实例(二)/宇恒一号操作计算软件实例(二)-封面.jpg",
		"宇恒一号操作计算软件实例(二)/宣传片-封面.jpg",
	}, "video-calc-demo-2-cover.jpg", "宇恒一号操作计算软件实例（二）", "二"},
	{[]string{
		"宇恒一号操作计算软件实例（二）/宇恒一号操作计算软件实例（二）.mov",
		"宇恒一号操作计算软件实例（二）/宣传片.mov",
		"宇恒一号操作计算软件实例(二)/宇恒一号操作计算软件实例(二).mov",
		"宇恒一号操作计算软件实例(二)/宣传片.mov",
	}, "video-calc-demo-2.mov", "宇恒一号操作计算软件实例（二）", "二"},
	{[]string{"宇恒一号AIWord简介/宇恒一号AIWord简介-封面.jpg"}, "video-aiword-cover.jpg", "宇恒一号AIWord简介", ""},
	{[]string{"宇恒一号AIWord简介/宇恒一号AIWord简介.mov"}, "video-aiword.mov", "宇恒一号AIWord简介", ""},
	{[]string{"宇恒一号语音办公实例/宇恒一号语音办公实例-封面.jpg"}, "video-voice-office-cover.jpg", "宇恒一号语音办公实例", ""},
	{[]string{"宇恒一号语音办公实例/宇恒一号语音办公实例.mov"}, "video-voice-office.mov", "宇恒一号语音办公实例", ""},
	{[]string{"宇恒一号，AI 全自动办发票/宇恒一号，AI 全自动办发票-封面.jpg"}, "video-invoice-ai-cover.jpg", "宇恒一号，AI 全自动办发票", ""},
	{[]string{"宇恒一号，AI 全自动办发票/宇恒一号，AI 全自动办发票.mov"}, "video-invoice-ai.mov", "宇恒一号，AI 全自动办发票", ""},
}

// 在 视频发布 下找名称同时含「实例」与「（一）」或「(一)」等的子目录（排除另一集）
func discoverEpisodeDir(videoPublish, episode string) (dirName string, ok bool) {
	full := "（" + episode + "）"
	half := "(" + episode + ")"
	entries, err := os.ReadDir(videoPublish)
	if err != nil {
		return "", false
	}
	var hits []string
	for _, e := range entries {
		if !e.IsDir() {
			continue
		}
		n := e.Name()
		if !strings.Contains(n, "实例") {
			continue
		}
		marked := strings.Contains(n, full) || strings.Contains(n, half)
		if !marked {
			continue
		}
		if episode == "一" && (strings.Contains(n, "（二）") || strings.Contains(n, "(二)")) {
			continue
		}
		if episode == "二" && (strings.Contains(n, "（一）") || strings.Contains(n, "(一)")) {
			continue
		}
		hits = append(hits, n)
	}
	if len(hits) == 0 {
		return "", false
	}
	if len(hits) == 1 {
		return hits[0], true
	}
	for _, h := range hits {
		if strings.Contains(h, "软件") {
			return h, true
		}
	}
	return hits[0], true
}

func dirHasMov(dir string) bool {
	entries, err := os.ReadDir(dir)
	if err != nil {
		return false
	}
	for _, e := range entries {
		if e.IsDir() {
			continue
		}
		if strings.EqualFold(filepath.Ext(e.Name()), ".mov") {
			return true
		}
	}
	return false
}

func dirHasJpeg(dir string) bool {
	entries, err := os.ReadDir(dir)
	if err != nil {
		return false
	}
	for _, e := range entries {
		if e.IsDir() {
			continue
		}
		ext := strings.ToLower(filepath.Ext(e.Name()))
		if ext == ".jpg" || ext == ".jpeg" {
			return true
		}
	}
	return false
}

// 列出「操作与计算」类子目录：含「实例」、内有 .mov 或 .jpg（避免仅封面无 mov 的（一）被漏掉），排除 AIWord/语音/发票等
func listCalcInstanceDirsForPairing(videoPublish string) []string {
	entries, err := os.ReadDir(videoPublish)
	if err != nil {
		return nil
	}
	skipSubstr := []string{"AIWord", "语音", "发票", "全自动"}
	var out []string
outer:
	for _, e := range entries {
		if !e.IsDir() {
			continue
		}
		n := e.Name()
		if !strings.Contains(n, "实例") {
			continue
		}
		for _, s := range skipSubstr {
			if strings.Contains(n, s) {
				continue outer
			}
		}
		sub := filepath.Join(videoPublish, n)
		if !dirHasMov(sub) && !dirHasJpeg(sub) {
			continue
		}
		out = append(out, n)
	}
	return out
}

// 将目录排序为 demo-1 在前、demo-2 在后（优先认全角/半角「一」「二」标记）
func orderCalcDirsForDemo12(dirs []string) []string {
	if len(dirs) <= 1 {
		return dirs
	}
	type scored struct {
		name string
		prio int
	}
	var xs []scored
	for _, d := range dirs {
		p := 100
		switch {
		case strings.Contains(d, "（一）") || strings.Contains(d, "(一)"):
			p = 1
		case strings.Contains(d, "（二）") || strings.Contains(d, "(二)"):
			p = 2
		case strings.Contains(d, "一") && !strings.Contains(d, "二"):
			p = 5
		case strings.Contains(d, "二"):
			p = 6
		}
		xs = append(xs, scored{d, p})
	}
	sort.Slice(xs, func(i, j int) bool {
		if xs[i].prio != xs[j].prio {
			return xs[i].prio < xs[j].prio
		}
		return xs[i].name < xs[j].name
	})
	out := make([]string, len(xs))
	for i, x := range xs {
		out[i] = x.name
	}
	return out
}

func pickMediaInDir(videoPublish, dirName string, dstFile string) (absPath, relChosen string, ok bool) {
	ext := strings.ToLower(filepath.Ext(dstFile))
	if ext != ".mov" && ext != ".jpg" && ext != ".jpeg" {
		return "", "", false
	}
	dir := filepath.Join(videoPublish, filepath.FromSlash(dirName))
	entries, err := os.ReadDir(dir)
	if err != nil {
		return "", "", false
	}
	type cand struct {
		name string
		size int64
	}
	var movs, imgs []cand
	for _, e := range entries {
		if e.IsDir() {
			continue
		}
		ne := strings.ToLower(filepath.Ext(e.Name()))
		p := filepath.Join(dir, e.Name())
		st, err := os.Stat(p)
		if err != nil {
			continue
		}
		sz := st.Size()
		if ne == ".mov" {
			movs = append(movs, cand{e.Name(), sz})
		}
		if ne == ".jpg" || ne == ".jpeg" {
			imgs = append(imgs, cand{e.Name(), sz})
		}
	}
	pickMov := func() (string, bool) {
		if len(movs) == 0 {
			return "", false
		}
		best := movs[0]
		for _, c := range movs[1:] {
			if c.size > best.size {
				best = c
			}
		}
		return best.name, true
	}
	pickImg := func() (string, bool) {
		if len(imgs) == 0 {
			return "", false
		}
		for _, c := range imgs {
			if strings.Contains(c.name, "封面") {
				return c.name, true
			}
		}
		best := imgs[0]
		for _, c := range imgs[1:] {
			if c.size > best.size {
				best = c
			}
		}
		return best.name, true
	}
	var name string
	var found bool
	switch ext {
	case ".mov":
		name, found = pickMov()
	case ".jpg", ".jpeg":
		name, found = pickImg()
	default:
		return "", "", false
	}
	if !found {
		return "", "", false
	}
	rel := filepath.ToSlash(filepath.Join(dirName, name))
	return filepath.Join(videoPublish, filepath.FromSlash(rel)), rel, true
}

func resolveSourceFile(videoPublish string, rule importRule, calcPair []string) (absPath, relChosen string, ok bool) {
	for _, rel := range rule.SrcRels {
		p := filepath.Join(videoPublish, filepath.FromSlash(rel))
		if st, err := os.Stat(p); err == nil && !st.IsDir() {
			return p, rel, true
		}
	}
	tryDirs := []string{}
	if rule.FallbackScanDir != "" {
		tryDirs = append(tryDirs, rule.FallbackScanDir)
	}
	if rule.EpisodeScan != "" {
		if d, ok := discoverEpisodeDir(videoPublish, rule.EpisodeScan); ok {
			// 避免与固定目录重复
			dup := false
			for _, x := range tryDirs {
				if x == d {
					dup = true
					break
				}
			}
			if !dup {
				tryDirs = append(tryDirs, d)
			}
		}
	}
	for _, dirName := range tryDirs {
		if abs, rel, ok := pickMediaInDir(videoPublish, dirName, rule.Dst); ok {
			return abs, rel, true
		}
	}
	// 恰好两个「实例」类目录且无法按名称命中时：排序后第 1 个 -> demo-1，第 2 个 -> demo-2
	if rule.EpisodeScan == "一" && len(calcPair) >= 2 {
		if abs, rel, ok := pickMediaInDir(videoPublish, calcPair[0], rule.Dst); ok {
			return abs, rel, true
		}
	}
	if rule.EpisodeScan == "一" && len(calcPair) == 1 {
		if abs, rel, ok := pickMediaInDir(videoPublish, calcPair[0], rule.Dst); ok {
			return abs, rel, true
		}
	}
	if rule.EpisodeScan == "二" && len(calcPair) >= 2 {
		if abs, rel, ok := pickMediaInDir(videoPublish, calcPair[1], rule.Dst); ok {
			return abs, rel, true
		}
	}
	return "", "", false
}

func main() {
	loadEnv()

	siteID := flag.String("site", "", "站点 MongoDB ObjectID（必填，与 /web/routes 的 site_id 一致）")
	srcRoot := flag.String("src", "", "「视频发布」目录绝对路径；默认尝试项目 web/promotion/视频发布")
	uploadRoot := flag.String("upload", "", "上传根目录（内含 sites/）；默认 data/uploads 或环境变量 UPLOAD_DIR")
	dryRun := flag.Bool("dry-run", false, "只打印计划，不写盘、不写库")
	flag.Parse()

	if strings.TrimSpace(*siteID) == "" {
		log.Fatal("请指定 -site=站点ID")
	}

	wd, _ := os.Getwd()
	projectRoot := wd
	if strings.HasSuffix(filepath.Clean(wd), "server") {
		projectRoot = filepath.Join(wd, "..")
	}
	projectRoot = filepath.Clean(projectRoot)

	videoPublish := *srcRoot
	if videoPublish == "" {
		videoPublish = filepath.Join(projectRoot, "web", "promotion", "视频发布")
	}
	videoPublish = filepath.Clean(videoPublish)

	calcPair := orderCalcDirsForDemo12(listCalcInstanceDirsForPairing(videoPublish))
	if *dryRun && len(calcPair) > 0 {
		log.Printf("[dry-run] 操作与计算类目录配对顺序(1<-[0], 2<-[1]): %v", calcPair)
	}

	uploadDir := *uploadRoot
	if uploadDir == "" {
		uploadDir = os.Getenv("UPLOAD_DIR")
	}
	if uploadDir == "" {
		uploadDir = filepath.Join(projectRoot, "data", "uploads")
	}
	uploadDir = filepath.Clean(uploadDir)

	mongoURI := os.Getenv("MONGODB_URI")
	if mongoURI == "" {
		mongoURI = "mongodb://localhost:27017"
	}
	if dbName := os.Getenv("MONGODB_DB"); dbName != "" {
		config.DBName = dbName
	}

	if *dryRun {
		log.Printf("[dry-run] 视频发布源: %s", videoPublish)
		log.Printf("[dry-run] 上传根: %s", uploadDir)
		log.Printf("[dry-run] site_id: %s", *siteID)
	}

	if !*dryRun {
		if err := config.ConnectMongoDB(mongoURI); err != nil {
			log.Fatalf("MongoDB: %v", err)
		}
		defer config.CloseMongoDB()
	}

	db := config.GetDB(config.DBName)
	if db == nil && !*dryRun {
		log.Fatal("数据库未连接")
	}
	var coll *mongo.Collection
	if db != nil {
		coll = db.Collection("site_assets")
	}

	ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
	defer cancel()

	ok, skip, fail := 0, 0, 0
	for _, m := range mappings {
		from, srcRelUsed, found := resolveSourceFile(videoPublish, m, calcPair)
		if !found {
			log.Printf("SKIP 源文件不存在（已试备选路径/扫描子目录）: dst=%s episode=%s", m.Dst, m.EpisodeScan)
			skip++
			continue
		}

		destDir := filepath.Join(uploadDir, "sites", *siteID, "promotion", "social")
		destPath := filepath.Join(destDir, m.Dst)
		relPath := filepath.ToSlash(filepath.Join("sites", *siteID, "promotion", "social", m.Dst))

		if *dryRun {
			log.Printf("COPY %s -> %s | DB file_path=%s", from, destPath, relPath)
			ok++
			continue
		}

		if err := os.MkdirAll(destDir, 0755); err != nil {
			log.Printf("FAIL 创建目录 %s: %v", destDir, err)
			fail++
			continue
		}

		if err := copyFile(from, destPath); err != nil {
			log.Printf("FAIL 复制 %s: %v", srcRelUsed, err)
			fail++
			continue
		}
		_ = os.Chmod(destPath, 0644)

		fi, _ := os.Stat(destPath)
		size := int64(0)
		if fi != nil {
			size = fi.Size()
		}
		ext := strings.ToLower(filepath.Ext(m.Dst))
		ct := mimeForExt(ext)

		_, _ = coll.DeleteMany(ctx, bson.M{"site_id": *siteID, "file_path": relPath})

		doc := bson.M{
			"site_id":         *siteID,
			"name":            m.Dst,
			"file_path":       relPath,
			"size":            size,
			"content_type":    ct,
			"downloadable":    false,
			"created_at":      time.Now().Format(time.RFC3339),
			"import_source":   "video_publish_legacy",
			"source_relpath":  srcRelUsed,
			"promotion_alias": filepath.ToSlash(filepath.Join("promotion", "social", m.Dst)),
		}
		if _, err := coll.InsertOne(ctx, doc); err != nil {
			log.Printf("FAIL 写库 %s: %v", relPath, err)
			fail++
			continue
		}
		log.Printf("OK %s -> %s", srcRelUsed, relPath)
		ok++
	}

	fmt.Printf("\n完成: 成功=%d 跳过=%d 失败=%d\n", ok, skip, fail)
	if fail > 0 {
		os.Exit(1)
	}
}

func copyFile(src, dst string) error {
	in, err := os.Open(src)
	if err != nil {
		return err
	}
	defer in.Close()
	out, err := os.Create(dst)
	if err != nil {
		return err
	}
	defer func() { _ = out.Close() }()
	if _, err := io.Copy(out, in); err != nil {
		return err
	}
	return out.Sync()
}
