using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using UnityEditor;
using UnityEditor.Animations;
using UnityEngine;

namespace CatsUdonTools.Editor
{
    /// <summary>クリップの再生の種類。値の並びはウィンドウの選択肢の並びと同じ</summary>
    public enum MultiClipMode
    {
        Loop = 0,
        Oneshot = 1,
        IntroLoop = 2
    }

    /// <summary>
    /// 複数のクリップの表の 1 行。EditorWindow がドメインリロードをまたいで持つため Serializable にする
    /// </summary>
    [Serializable]
    public sealed class MultiClipRow
    {
        /// <summary>クリップ名（= サブステートマシンの名前。CatsUdon のコマンドで打つ名前）</summary>
        public string name = "";

        public MultiClipMode mode = MultiClipMode.Loop;

        /// <summary>Loop / IntroLoop ではループするクリップ、Oneshot では 1 回流すクリップ</summary>
        public AnimationClip clip;

        /// <summary>IntroLoop のときだけ使う。最初に 1 回流してから clip のループに入る</summary>
        public AnimationClip introClip;

        /// <summary>30 か 15</summary>
        public int fps = MultiClipAnimatorTemplate.DefaultFps;

        public MultiClipRow Clone()
        {
            return new MultiClipRow { name = name, mode = mode, clip = clip, introClip = introClip, fps = fps };
        }
    }

    internal enum MultiClipIssueLevel
    {
        Error,
        Warning,
        Info
    }

    /// <summary>表の検査で見つかったこと。Error があるあいだは Controller を作らない</summary>
    internal sealed class MultiClipIssue
    {
        public MultiClipIssue(int rowIndex, MultiClipIssueLevel level, string message)
        {
            RowIndex = rowIndex;
            Level = level;
            Message = message;
        }

        /// <summary>行の番号（0 から）。表全体のことなら -1</summary>
        public int RowIndex { get; }

        public MultiClipIssueLevel Level { get; }

        public string Message { get; }
    }

    /// <summary>Animator に付いている Controller の種類</summary>
    internal enum MultiClipControllerKind
    {
        /// <summary>Controller が付いていない</summary>
        None,

        /// <summary>IsMeshAnimation パラメータの無い、ふつうの Controller</summary>
        NotTemplate,

        /// <summary>テンプレートを元にした Override Controller（表には読み戻さない）</summary>
        OverrideController,

        /// <summary>今までの 1 種類のテンプレート（OneShot / Loop / IntroLoop）</summary>
        SingleTemplate,

        /// <summary>テンプレート MultiClip（Base Layer の中にクリップごとのサブステートマシン）</summary>
        MultiClip,

        /// <summary>IsMeshAnimation はあるが、どのテンプレートの形とも合わない</summary>
        Broken
    }

    internal sealed class MultiClipReadResult
    {
        public MultiClipControllerKind Kind { get; set; }

        /// <summary>読み戻した表（SingleTemplate は 1 行、MultiClip はクリップの数だけ）</summary>
        public List<MultiClipRow> Rows { get; } = new List<MultiClipRow>();

        /// <summary>Broken の理由と、読み戻せたが気を付けること</summary>
        public List<string> Problems { get; } = new List<string>();
    }

    /// <summary>モデルの中のいちばん外側のテンプレート 1 つ分のクリップ名（ツリー間の 1 番目の検査に使う）</summary>
    internal sealed class MultiClipTree
    {
        /// <summary>案内に出す名前（Animator の GameObject 名、または「この表（…）」）</summary>
        public string Label { get; set; } = "";

        /// <summary>クリップ名の並び。MultiClip でないテンプレートは null</summary>
        public IList<string> ClipNames { get; set; }

        /// <summary>画面で編集中の表か（行の番号で案内するため）</summary>
        public bool IsTable { get; set; }
    }

    /// <summary>
    /// テンプレート MultiClip の AnimatorController を作る・読み戻す・表を検査する。
    /// 形は Document/cpmodel-multi-clip-animation-plan.md の §9.2 に合わせる（Worker 側エンコーダーがこの形を読んで録画する）。
    /// エンコーダーのコードは配布物に含めないので参照しない。ステート名などの決まりはエンコーダーの TemplateAnimatorUtility と同じ値をここにも持つ
    /// </summary>
    internal static class MultiClipAnimatorTemplate
    {
        internal const string MeshAnimationParameter = "IsMeshAnimation";
        internal const string OneshotStateName = "OneshotMeshAnimation";
        internal const string LoopStateName = "LoopMeshAnimation";
        internal const string IntroStateName = "IntroMeshAnimation";

        /// <summary>クリップの最初のステートの Tag にこれがあれば 15 fps（無い・fps30 なら 30 fps）</summary>
        internal const string Fps15Tag = "fps15";
        internal const string Fps30Tag = "fps30";

        internal const int DefaultFps = 30;
        internal const int HalfFps = 15;
        internal const int MaxClipCount = 16;
        internal const int MaxNameLength = 32;

        /// <summary>合計の長さ（30 fps 換算。15 fps のクリップは半分に数える）がこれを超えたら警告する</summary>
        internal const float TotalSecondsWarning = 60f;

        // 今の IntroLoop テンプレート（AnimationTemplates/IntroLoop.controller）の遷移と同じ値
        private const float IntroToLoopExitTime = 1f;
        private const float IntroToLoopDuration = 0.25f;

        // 空白のほかに使えない文字。コマンドで打つため（空白・カンマ・引用符）と、ドットは Animator のステートの位置の書き方とぶつかるため
        private static readonly char[] ForbiddenNameCharacters = { '.', ',', '"', '\'' };

        // 1 フレームより短いクリップは、録画しても動きにならない
        private const float MinimumClipSeconds = 1f / DefaultFps;

        private static readonly Vector3 FirstClipPosition = new Vector3(300f, 40f, 0f);
        private static readonly Vector3 ClipSpacing = new Vector3(0f, 70f, 0f);
        private static readonly Vector3 FirstStatePosition = new Vector3(300f, 120f, 0f);
        private static readonly Vector3 SecondStatePosition = new Vector3(300f, 220f, 0f);

        // ---- 判定 ----

        /// <summary>Override Controller を辿って、元の AnimatorController を返す</summary>
        internal static AnimatorController ResolveAnimatorController(RuntimeAnimatorController controller)
        {
            while (controller is AnimatorOverrideController overrideController)
            {
                controller = overrideController.runtimeAnimatorController;
            }

            return controller as AnimatorController;
        }

        /// <summary>IsMeshAnimation パラメータを持つ（= メッシュアニメのテンプレート）か。前後の空白はエンコーダーと同じく許す</summary>
        internal static bool IsTemplateController(RuntimeAnimatorController controller)
        {
            var resolved = ResolveAnimatorController(controller);
            return resolved != null && resolved.parameters.Any(parameter => parameter.name != null && parameter.name.Trim() == MeshAnimationParameter);
        }

        /// <summary>
        /// テンプレート MultiClip の Controller らしいか（テンプレートで、Base Layer にサブステートマシンがある）。
        /// 書き出しで複製する対象を選ぶための緩い判定。中身が正しいかは見ない（検査は Worker が行う）
        /// </summary>
        internal static bool LooksLikeMultiClip(RuntimeAnimatorController controller)
        {
            var animatorController = controller as AnimatorController;
            if (animatorController == null || !IsTemplateController(animatorController))
            {
                return false;
            }

            var layers = animatorController.layers;
            return layers.Length > 0 && layers[0].stateMachine != null && layers[0].stateMachine.stateMachines.Length > 0;
        }

        // ---- 読み戻し ----

        /// <summary>
        /// Controller を表に読み戻す。MultiClip はクリップの数だけ、今までの 1 種類のテンプレートは 1 行（名前はクリップ名から作る）にする
        /// </summary>
        internal static MultiClipReadResult Read(RuntimeAnimatorController controller)
        {
            var result = new MultiClipReadResult();
            if (controller == null)
            {
                result.Kind = MultiClipControllerKind.None;
                return result;
            }

            if (!IsTemplateController(controller))
            {
                result.Kind = MultiClipControllerKind.NotTemplate;
                return result;
            }

            if (controller is AnimatorOverrideController)
            {
                result.Kind = MultiClipControllerKind.OverrideController;
                result.Problems.Add("Override Controller は表に読み戻せません。");
                return result;
            }

            var animatorController = (AnimatorController)controller;
            var layers = animatorController.layers;
            if (layers.Length != 1 || layers[0].stateMachine == null)
            {
                result.Kind = MultiClipControllerKind.Broken;
                result.Problems.Add($"レイヤーが {layers.Length} 個あります（Base Layer の 1 個だけにしてください）。");
                return result;
            }

            var root = layers[0].stateMachine;
            if (root.stateMachines.Length == 0)
            {
                var row = new MultiClipRow();
                if (TryReadClipStateMachine(root, "", row, result.Problems))
                {
                    row.name = MakeNameFromClip(row.clip != null ? row.clip : row.introClip, Array.Empty<string>());
                    result.Rows.Add(row);
                    result.Kind = MultiClipControllerKind.SingleTemplate;
                }
                else
                {
                    result.Kind = MultiClipControllerKind.Broken;
                }

                return result;
            }

            var broken = false;
            if (root.states.Length > 0)
            {
                broken = true;
                result.Problems.Add("Base Layer にステートが直接あります（Base Layer にはクリップごとのサブステートマシンだけを置いてください）。");
            }

            foreach (var child in root.stateMachines)
            {
                var row = new MultiClipRow { name = child.stateMachine.name };
                if (TryReadClipStateMachine(child.stateMachine, child.stateMachine.name, row, result.Problems))
                {
                    result.Rows.Add(row);
                }
                else
                {
                    broken = true;
                }
            }

            if (broken)
            {
                result.Kind = MultiClipControllerKind.Broken;
                result.Rows.Clear();
                return result;
            }

            var expectedDefault = GetFirstState(root.stateMachines[0].stateMachine);
            if (root.defaultState == null || root.defaultState != expectedDefault)
            {
                result.Problems.Add($"Base Layer の既定のステートが、1 番目のクリップ「{root.stateMachines[0].stateMachine.name}」の最初のステートではありません（［Controller を作る］で作り直すと直ります）。");
            }

            result.Kind = MultiClipControllerKind.MultiClip;
            return result;
        }

        // ステートの構成は今の 3 種類のテンプレートと同じ判定（エンコーダーの TryClassifyMeshAnimationTemplate と同じ条件）
        private static bool TryReadClipStateMachine(AnimatorStateMachine stateMachine, string clipName, MultiClipRow row, List<string> problems)
        {
            var label = string.IsNullOrEmpty(clipName) ? "" : $"クリップ「{clipName}」: ";
            if (stateMachine.stateMachines.Length > 0)
            {
                problems.Add(label + "中にさらにサブステートマシンがあります（入れ子にはできません）。");
                return false;
            }

            var states = stateMachine.states.Select(child => child.state).Where(state => state != null).ToList();
            var names = states.Select(state => (state.name ?? "").Trim()).ToList();
            AnimatorState firstState;
            if (names.Count == 1 && names[0] == OneshotStateName)
            {
                row.mode = MultiClipMode.Oneshot;
                firstState = states[0];
                row.clip = firstState.motion as AnimationClip;
            }
            else if (names.Count == 1 && names[0] == LoopStateName)
            {
                row.mode = MultiClipMode.Loop;
                firstState = states[0];
                row.clip = firstState.motion as AnimationClip;
            }
            else if (names.Count == 2 && names.Contains(IntroStateName) && names.Contains(LoopStateName))
            {
                row.mode = MultiClipMode.IntroLoop;
                firstState = states[names.IndexOf(IntroStateName)];
                var loopState = states[names.IndexOf(LoopStateName)];
                row.introClip = firstState.motion as AnimationClip;
                row.clip = loopState.motion as AnimationClip;
                if (stateMachine.defaultState != firstState)
                {
                    problems.Add(label + $"既定のステートが {IntroStateName} ではありません（イントロが流れずにループから始まります）。");
                    return false;
                }

                if (!TryCheckIntroTransition(firstState, loopState, label, problems))
                {
                    return false;
                }
            }
            else
            {
                var found = names.Count == 0 ? "（ステートなし）" : string.Join(", ", names);
                problems.Add(label + $"ステートの構成がテンプレートと合いません（今: {found}。{OneshotStateName} / {LoopStateName} / {IntroStateName} → {LoopStateName} のどれかにしてください）。");
                return false;
            }

            foreach (var state in states)
            {
                if (state.motion != null && !(state.motion is AnimationClip))
                {
                    problems.Add(label + $"ステート「{state.name}」の Motion は BlendTree ではなく、アニメーションクリップ 1 つにしてください。");
                    return false;
                }

                if (state.speed <= 0f)
                {
                    problems.Add(label + $"ステート「{state.name}」の Speed が 0 以下です（0 より大きくしてください。逆再生・停止はできません）。");
                    return false;
                }
            }

            var tag = (firstState.tag ?? "").Trim();
            row.fps = tag == Fps15Tag ? HalfFps : DefaultFps;
            if (tag.Length > 0 && tag != Fps15Tag && tag != Fps30Tag)
            {
                problems.Add(label + $"ステート「{firstState.name}」の Tag「{tag}」は使われません（fps を決める Tag は {Fps15Tag} か {Fps30Tag} だけです）。");
            }

            return true;
        }

        // イントロ → ループの遷移は Exit Time の 1 本だけで、行き先は同じクリップ（サブステートマシン）の中の LoopMeshAnimation。
        // MultiClip ではどのクリップにも同じ名前の LoopMeshAnimation があるので、名前ではなくステートそのもので確かめる（エンコーダーと同じ）
        private static bool TryCheckIntroTransition(AnimatorState introState, AnimatorState loopState, string label, List<string> problems)
        {
            var transitions = introState.transitions;
            if (transitions.Length != 1)
            {
                problems.Add(label + (transitions.Length == 0
                    ? $"{IntroStateName} から {LoopStateName} への遷移がありません（Exit Time の遷移を 1 本付けてください）。"
                    : $"{IntroStateName} からの遷移が {transitions.Length} 本あります（Exit Time の遷移 1 本だけにしてください）。"));
                return false;
            }

            var transition = transitions[0];
            var reasons = new List<string>();
            if (!transition.hasExitTime)
            {
                reasons.Add("Has Exit Time が切れています");
            }

            if (transition.conditions.Length > 0)
            {
                reasons.Add("条件が付いています");
            }

            if (transition.mute)
            {
                reasons.Add("Mute されています");
            }

            if (transition.isExit || transition.destinationStateMachine != null || transition.destinationState != loopState)
            {
                reasons.Add($"行き先が同じクリップの中の {LoopStateName} ではありません（別のクリップのループ・サブステートマシン・Exit へはつなげません）");
            }

            if (reasons.Count == 0)
            {
                return true;
            }

            problems.Add(label + $"{IntroStateName} から {LoopStateName} への遷移: " + string.Join("・", reasons) + "。");
            return false;
        }

        // 種類ごとの最初のステート（Oneshot / Loop はその 1 つ、IntroLoop は IntroMeshAnimation）。Tag で fps を決めるステート
        private static AnimatorState GetFirstState(AnimatorStateMachine stateMachine)
        {
            var states = stateMachine.states.Select(child => child.state).Where(state => state != null).ToList();
            return states.FirstOrDefault(state => (state.name ?? "").Trim() == IntroStateName) ?? states.FirstOrDefault();
        }

        // ---- 検査 ----

        /// <summary>
        /// 入稿ツールだけで分かることを検査する（名前・クリップの設定・数・合計の長さ・Loop とイントロの組み合わせ・Loop Time）。
        /// Worker の判定（エンコーダー）とは別に持っているので、ここを変えたらプランの §9.2 と合っているか確かめる
        /// </summary>
        internal static List<MultiClipIssue> Validate(IList<MultiClipRow> rows)
        {
            var issues = new List<MultiClipIssue>();
            if (rows.Count == 0)
            {
                issues.Add(new MultiClipIssue(-1, MultiClipIssueLevel.Error, "クリップを 1 つ以上足してください。"));
                return issues;
            }

            if (rows.Count > MaxClipCount)
            {
                issues.Add(new MultiClipIssue(-1, MultiClipIssueLevel.Error, $"クリップは {MaxClipCount} 個までです（今 {rows.Count} 個）。"));
            }

            for (var index = 0; index < rows.Count; index++)
            {
                ValidateName(rows, index, issues);
                ValidateClips(rows[index], index, issues);
            }

            ValidateLoopTimes(rows, issues);

            var totalSeconds = rows.Sum(row => CountedSeconds(row));
            if (totalSeconds > TotalSecondsWarning)
            {
                issues.Add(new MultiClipIssue(-1, MultiClipIssueLevel.Warning,
                    $"合計の長さが {totalSeconds:0.0} 秒です（30 fps 換算。目安は {TotalSecondsWarning:0} 秒まで）。長いほど .cudm が大きくなり、変換にも時間がかかります。15 fps にできるクリップは 15 fps にすると半分に数えます。"));
            }

            return issues;
        }

        private static void ValidateName(IList<MultiClipRow> rows, int index, List<MultiClipIssue> issues)
        {
            var name = rows[index].name ?? "";
            if (name.Length == 0)
            {
                issues.Add(new MultiClipIssue(index, MultiClipIssueLevel.Error, "名前を入れてください。"));
                return;
            }

            if (name.Length > MaxNameLength)
            {
                issues.Add(new MultiClipIssue(index, MultiClipIssueLevel.Error, $"名前は {MaxNameLength} 文字までです（今 {name.Length} 文字）。"));
            }

            if (name.Any(char.IsWhiteSpace))
            {
                issues.Add(new MultiClipIssue(index, MultiClipIssueLevel.Error, "名前に空白は使えません（CatsUdon のコマンドで打つため）。"));
            }

            var forbidden = name.Where(character => ForbiddenNameCharacters.Contains(character)).Distinct().ToArray();
            if (forbidden.Length > 0)
            {
                issues.Add(new MultiClipIssue(index, MultiClipIssueLevel.Error, $"名前に {string.Join(" ", forbidden.Select(character => character.ToString()))} は使えません（. , \" ' は使えません）。"));
            }

            // 打つときに紛れないよう、大文字と小文字の違いだけの名前も同じ名前として扱う
            for (var other = 0; other < index; other++)
            {
                if (string.Equals(rows[other].name ?? "", name, StringComparison.OrdinalIgnoreCase))
                {
                    var how = string.Equals(rows[other].name, name, StringComparison.Ordinal) ? "同じ名前" : "大文字と小文字だけが違う名前";
                    issues.Add(new MultiClipIssue(index, MultiClipIssueLevel.Error, $"{other + 1} 番目と{how}です。名前は重ならないようにしてください。"));
                    break;
                }
            }

            // 番号で指す書き方と区別できない名前は使えない（エンコーダーの TemplateAnimatorUtility.TryValidateClipName と同じ）
            if (name.StartsWith("#", StringComparison.Ordinal))
            {
                issues.Add(new MultiClipIssue(index, MultiClipIssueLevel.Error, "# で始まる名前は使えません（セーブではクリップを「#番号」で番号で指すため）。"));
            }

            if (IsNumberName(name))
            {
                issues.Add(new MultiClipIssue(index, MultiClipIssueLevel.Error, "数だけの名前（2・+1・007 など）は使えません（コマンドではクリップを番号そのままで指すため）。"));
            }
        }

        private static bool IsNumberName(string name)
        {
            return int.TryParse(name, NumberStyles.Integer, CultureInfo.InvariantCulture, out _);
        }

        /// <summary>
        /// いちばん外側のテンプレートが 2 つ以上あるとき、モデル全体の 1 番目のクリップ（並びで最初の MultiClip の 1 番目）を持つ
        /// ほかの MultiClip は、それを自分の 1 番目にしなければならない（エンコーダーの MeshAnimClipDetector.DetectAll と同じ決まり）。
        /// 古いワールドは各テンプレートの 1 番目を流し、新しいワールドはモデル全体の 1 番目を流すので、ずれると流れるクリップが変わる。
        /// 編集中の表が外れていれば行に赤を、ほかの Animator が外れていれば黄を付ける（ほかの Animator を直す前に表を作れるように）
        /// </summary>
        internal static List<MultiClipIssue> ValidateFirstClipAcrossTrees(IList<MultiClipTree> trees)
        {
            var issues = new List<MultiClipIssue>();
            var firstOwner = trees.FirstOrDefault(tree => tree.ClipNames != null && tree.ClipNames.Count > 0);
            if (firstOwner == null)
            {
                return issues;
            }

            var firstName = firstOwner.ClipNames[0];
            foreach (var tree in trees)
            {
                if (tree == firstOwner || tree.ClipNames == null)
                {
                    continue;
                }

                var index = tree.ClipNames.IndexOf(firstName);
                if (index <= 0)
                {
                    continue;
                }

                if (tree.IsTable)
                {
                    issues.Add(new MultiClipIssue(index, MultiClipIssueLevel.Error,
                        $"「{firstName}」は 1 番目にしてください。同じモデルの「{firstOwner.Label}」の 1 番目で、モデル全体の既定のクリップのためです（古いワールドでは各テンプレートの 1 番目が流れるので、そろえないとテンプレートごとに違うクリップが流れます）。"));
                }
                else
                {
                    issues.Add(new MultiClipIssue(-1, MultiClipIssueLevel.Warning,
                        $"同じモデルの「{tree.Label}」では「{firstName}」が {index + 1} 番目です。「{firstOwner.Label}」の 1 番目でモデル全体の既定のクリップなので、「{tree.Label}」でも 1 番目にしてください（このままでは変換が失敗します）。"));
                }
            }

            return issues;
        }

        private static void ValidateClips(MultiClipRow row, int index, List<MultiClipIssue> issues)
        {
            var clipLabel = row.mode == MultiClipMode.IntroLoop ? "ループのクリップ" : "クリップ";
            ValidateClip(row.clip, clipLabel, index, issues);

            if (row.mode == MultiClipMode.IntroLoop)
            {
                ValidateClip(row.introClip, "イントロのクリップ", index, issues);
                if (row.clip != null && row.clip == row.introClip)
                {
                    issues.Add(new MultiClipIssue(index, MultiClipIssueLevel.Warning, "イントロとループに同じクリップを入れています。イントロが要らなければ Loop にしてください。"));
                }
            }
            else if (row.introClip != null)
            {
                issues.Add(new MultiClipIssue(index, MultiClipIssueLevel.Info, "イントロのクリップは IntroLoop の時だけ使います（今は使われません）。"));
            }

            if (row.fps != DefaultFps && row.fps != HalfFps)
            {
                issues.Add(new MultiClipIssue(index, MultiClipIssueLevel.Error, $"fps は {DefaultFps} か {HalfFps} にしてください。"));
            }
        }

        private static void ValidateClip(AnimationClip clip, string label, int index, List<MultiClipIssue> issues)
        {
            if (clip == null)
            {
                issues.Add(new MultiClipIssue(index, MultiClipIssueLevel.Error, $"{label}を入れてください。"));
                return;
            }

            if (clip.legacy)
            {
                issues.Add(new MultiClipIssue(index, MultiClipIssueLevel.Error, $"{label}「{clip.name}」は Legacy のクリップなので Animator で使えません。"));
            }

            if (clip.length < MinimumClipSeconds)
            {
                issues.Add(new MultiClipIssue(index, MultiClipIssueLevel.Warning, $"{label}「{clip.name}」の長さがほぼ 0 秒です（1 フレームより短いと動きになりません）。"));
            }
        }

        // Loop Time は、書き出す時に入稿物のフォルダへ複製したクリップでそろえる（SubmissionAnimationDuplicator）。ここでは何が起きるかを知らせるだけ
        private static void ValidateLoopTimes(IList<MultiClipRow> rows, List<MultiClipIssue> issues)
        {
            var desiredLoopByClip = CollectDesiredLoopTimes(rows);
            for (var index = 0; index < rows.Count; index++)
            {
                var row = rows[index];
                foreach (var (clip, shouldLoop) in EnumerateClipPositions(row))
                {
                    if (clip == null || !desiredLoopByClip.TryGetValue(clip, out var desired))
                    {
                        continue;
                    }

                    if (desired != shouldLoop)
                    {
                        issues.Add(new MultiClipIssue(index, MultiClipIssueLevel.Info, $"クリップ「{clip.name}」はループする所としない所の両方で使っているので、Loop Time はループ（ON）にそろえます。"));
                    }
                    else if (AnimationUtility.GetAnimationClipSettings(clip).loopTime != desired)
                    {
                        issues.Add(new MultiClipIssue(index, MultiClipIssueLevel.Info, $"クリップ「{clip.name}」の Loop Time は、書き出す時に入稿物に複製したクリップで {(desired ? "ON" : "OFF")} にします（元のクリップは変えません）。"));
                    }
                }
            }
        }

        /// <summary>
        /// クリップごとの Loop Time（LoopMeshAnimation に置くものは ON、それ以外は OFF。両方に置くものはループを優先）。
        /// エンコーダーの TryNormalizeMeshAnimationClipLoopSettings と同じ決まり
        /// </summary>
        internal static Dictionary<AnimationClip, bool> CollectDesiredLoopTimes(IEnumerable<MultiClipRow> rows)
        {
            var desiredLoopByClip = new Dictionary<AnimationClip, bool>();
            foreach (var row in rows)
            {
                foreach (var (clip, shouldLoop) in EnumerateClipPositions(row))
                {
                    if (clip == null)
                    {
                        continue;
                    }

                    desiredLoopByClip[clip] = desiredLoopByClip.TryGetValue(clip, out var existing) ? existing || shouldLoop : shouldLoop;
                }
            }

            return desiredLoopByClip;
        }

        private static IEnumerable<(AnimationClip clip, bool shouldLoop)> EnumerateClipPositions(MultiClipRow row)
        {
            switch (row.mode)
            {
                case MultiClipMode.Loop:
                    yield return (row.clip, true);
                    break;
                case MultiClipMode.Oneshot:
                    yield return (row.clip, false);
                    break;
                case MultiClipMode.IntroLoop:
                    yield return (row.introClip, false);
                    yield return (row.clip, true);
                    break;
            }
        }

        /// <summary>そのクリップの長さ（秒。IntroLoop はイントロ込み）</summary>
        internal static float LengthSeconds(MultiClipRow row)
        {
            var length = row.clip != null ? row.clip.length : 0f;
            if (row.mode == MultiClipMode.IntroLoop && row.introClip != null)
            {
                length += row.introClip.length;
            }

            return length;
        }

        /// <summary>上限の目安に数える長さ（30 fps 換算。15 fps は半分）</summary>
        internal static float CountedSeconds(MultiClipRow row)
        {
            var length = LengthSeconds(row);
            return row.fps == HalfFps ? length / 2f : length;
        }

        /// <summary>クリップ名から、使えない文字を除いた名前を作る（ほかの行と重なれば _2 などを付ける）</summary>
        internal static string MakeNameFromClip(AnimationClip clip, IEnumerable<string> usedNames)
        {
            var baseName = new string((clip != null ? clip.name : "").Where(character => !char.IsWhiteSpace(character) && !ForbiddenNameCharacters.Contains(character)).ToArray()).TrimStart('#');
            if (baseName.Length == 0)
            {
                baseName = "Clip";
            }
            else if (IsNumberName(baseName))
            {
                baseName = "Clip" + baseName;
            }

            if (baseName.Length > MaxNameLength)
            {
                baseName = baseName.Substring(0, MaxNameLength);
            }

            var used = new HashSet<string>(usedNames.Where(name => name != null), StringComparer.OrdinalIgnoreCase);
            var name = baseName;
            for (var number = 2; used.Contains(name); number++)
            {
                var suffix = "_" + number;
                name = baseName.Substring(0, Math.Min(baseName.Length, MaxNameLength - suffix.Length)) + suffix;
            }

            return name;
        }

        // ---- 作る ----

        /// <summary>
        /// 表から MultiClip の Controller を作る。assetPath に AnimatorController があれば、GUID を変えないよう中身を消して作り直す。
        /// 作った後に読み戻して表と同じになったかを確かめ、違えば例外にする
        /// </summary>
        internal static AnimatorController Build(string assetPath, IList<MultiClipRow> rows)
        {
            var controller = AssetDatabase.LoadAssetAtPath<AnimatorController>(assetPath);
            if (controller != null)
            {
                Clear(controller);
            }
            else
            {
                if (AssetDatabase.LoadMainAssetAtPath(assetPath) != null)
                {
                    throw new InvalidOperationException($"「{assetPath}」は Animator Controller ではないので上書きできません。別の保存先を選んでください。");
                }

                controller = AnimatorController.CreateAnimatorControllerAtPath(assetPath);
            }

            controller.AddParameter(new AnimatorControllerParameter
            {
                name = MeshAnimationParameter,
                type = AnimatorControllerParameterType.Bool,
                defaultBool = true
            });

            var root = controller.layers[0].stateMachine;
            AnimatorState layerDefaultState = null;
            for (var index = 0; index < rows.Count; index++)
            {
                var row = rows[index];
                var clipStateMachine = root.AddStateMachine(row.name, FirstClipPosition + ClipSpacing * index);
                if (clipStateMachine.name != row.name)
                {
                    throw new InvalidOperationException($"サブステートマシンの名前が「{row.name}」になりませんでした（Unity が「{clipStateMachine.name}」に変えました）。名前を変えてください。");
                }

                var firstState = AddClipStates(clipStateMachine, row);
                if (row.fps == HalfFps)
                {
                    firstState.tag = Fps15Tag;
                }

                if (index == 0)
                {
                    layerDefaultState = firstState;
                }
            }

            // 1 番目のクリップが既定（読み込み直後に流れる・古いワールドで流れる）
            root.defaultState = layerDefaultState;

            EditorUtility.SetDirty(controller);
            AssetDatabase.SaveAssets();

            VerifyBuilt(controller, rows);
            return controller;
        }

        private static AnimatorState AddClipStates(AnimatorStateMachine clipStateMachine, MultiClipRow row)
        {
            switch (row.mode)
            {
                case MultiClipMode.Oneshot:
                {
                    var oneshot = clipStateMachine.AddState(OneshotStateName, FirstStatePosition);
                    oneshot.motion = row.clip;
                    clipStateMachine.defaultState = oneshot;
                    return oneshot;
                }
                case MultiClipMode.IntroLoop:
                {
                    var intro = clipStateMachine.AddState(IntroStateName, FirstStatePosition);
                    intro.motion = row.introClip;
                    var loop = clipStateMachine.AddState(LoopStateName, SecondStatePosition);
                    loop.motion = row.clip;

                    var transition = intro.AddTransition(loop);
                    transition.hasExitTime = true;
                    transition.exitTime = IntroToLoopExitTime;
                    transition.hasFixedDuration = true;
                    transition.duration = IntroToLoopDuration;
                    transition.offset = 0f;
                    transition.interruptionSource = TransitionInterruptionSource.None;
                    transition.orderedInterruption = true;
                    transition.canTransitionToSelf = true;

                    clipStateMachine.defaultState = intro;
                    return intro;
                }
                default:
                {
                    var loop = clipStateMachine.AddState(LoopStateName, FirstStatePosition);
                    loop.motion = row.clip;
                    clipStateMachine.defaultState = loop;
                    return loop;
                }
            }
        }

        // 作り直すとき、Controller の GUID を変えないよう、アセットは消さずに中身だけを空にする
        private static void Clear(AnimatorController controller)
        {
            foreach (var parameter in controller.parameters)
            {
                controller.RemoveParameter(parameter);
            }

            for (var layerIndex = controller.layers.Length - 1; layerIndex >= 1; layerIndex--)
            {
                controller.RemoveLayer(layerIndex);
            }

            if (controller.layers.Length == 0)
            {
                controller.AddLayer("Base Layer");
            }

            // Base Layer の設定はテンプレートと同じにする（マスクなし・上書き）
            var layers = controller.layers;
            layers[0].avatarMask = null;
            layers[0].blendingMode = AnimatorLayerBlendingMode.Override;
            layers[0].iKPass = false;
            layers[0].syncedLayerIndex = -1;
            controller.layers = layers;

            var root = controller.layers[0].stateMachine;
            foreach (var child in root.stateMachines)
            {
                root.RemoveStateMachine(child.stateMachine);
            }

            foreach (var child in root.states)
            {
                root.RemoveState(child.state);
            }

            foreach (var transition in root.anyStateTransitions)
            {
                root.RemoveAnyStateTransition(transition);
            }

            foreach (var transition in root.entryTransitions)
            {
                root.RemoveEntryTransition(transition);
            }

            foreach (var behaviour in root.behaviours)
            {
                if (behaviour != null)
                {
                    UnityEngine.Object.DestroyImmediate(behaviour, true);
                }
            }

            root.behaviours = Array.Empty<StateMachineBehaviour>();
        }

        private static void VerifyBuilt(AnimatorController controller, IList<MultiClipRow> rows)
        {
            var read = Read(controller);
            if (read.Kind != MultiClipControllerKind.MultiClip || read.Problems.Count > 0)
            {
                throw new InvalidOperationException("作った Controller を読み戻せませんでした: " + string.Join(" ", read.Problems));
            }

            if (read.Rows.Count != rows.Count)
            {
                throw new InvalidOperationException($"作った Controller のクリップの数が表と違います（表 {rows.Count} 個・Controller {read.Rows.Count} 個）。");
            }

            for (var index = 0; index < rows.Count; index++)
            {
                var expected = rows[index];
                var actual = read.Rows[index];
                var same = actual.name == expected.name
                    && actual.mode == expected.mode
                    && actual.clip == expected.clip
                    && (expected.mode != MultiClipMode.IntroLoop || actual.introClip == expected.introClip)
                    && actual.fps == expected.fps;
                if (!same)
                {
                    throw new InvalidOperationException($"作った Controller の {index + 1} 番目のクリップ「{actual.name}」が表と違います。");
                }
            }
        }
    }
}
