Flash/Core/MovieClip.cs
using System;
using System.Collections.Generic;

namespace Flash;

/// <summary>One depth on a timeline: what the SWF tags say should be there.</summary>
public sealed class Slot
{
	public int CharId;
	public Matrix M = Matrix.Identity;
	public ColorTransform Cx = ColorTransform.Identity;
	public string Name;
	public int Ratio;
	public int ClipDepth;
	public List<Filter> Filters;
	public int BlendMode;
	public List<ClipActionDef> ClipActions;
	public int PlacedFrame;
	public int Order; // tag order within its frame (for construction order)

	public Slot Clone() => new()
	{
		CharId = CharId, M = M, Cx = Cx, Name = Name, Ratio = Ratio, ClipDepth = ClipDepth, Filters = Filters,
		BlendMode = BlendMode, ClipActions = ClipActions, PlacedFrame = PlacedFrame, Order = Order,
	};
}

/// <summary>A movie clip: a timeline with child display objects and an AS object.</summary>
public sealed class MovieClip : DisplayObject
{
	public const int TimelineDepthOffset = -16384;

	public SpriteDef Sprite;
	public int CurrentFrame; // 0 = not yet run
	public bool Playing = true;
	public readonly List<DisplayObject> Children = new();
	Dictionary<int, Slot> slots = new();
	public List<ClipActionDef> ClipActions;
	public bool Enabled = true, UseHandCursor = true, TrackAsMenu;
	public bool Initialized; // load event fired
	public MovieClip NextExec; // execution list link
	public bool InExecList;
	public AsFunction RegisteredClass;
	public int InstanceCounter;

	public MovieClip( Player p, SpriteDef def ) : base( p, p.Vm.MovieClipProto )
	{
		Sprite = def ?? new SpriteDef { FrameCount = 1, Frames = new[] { new List<FrameOp>() } };
		Def = def;
	}

	public int TotalFrames => Math.Max( 1, Sprite.FrameCount );

	public override bool HasOwnToString => false;

	// ================================================================== children

	public DisplayObject ChildAtDepth( int depth )
	{
		foreach ( var c in Children ) if ( c.Depth == depth ) return c;
		return null;
	}

	public DisplayObject ChildByName( string name )
	{
		if ( string.IsNullOrEmpty( name ) ) return null;
		// Flash returns the lowest-depth child with the name
		foreach ( var c in Children ) if ( !c.Removed && c.Name == name ) return c;
		return null;
	}

	public void AddChild( DisplayObject d, int depth )
	{
		var old = ChildAtDepth( depth );
		if ( old != null ) RemoveChild( old );
		d.Parent = this;
		d.Depth = depth;
		Player.ApplyLayout( d );
		int i = 0;
		while ( i < Children.Count && Children[i].Depth < depth ) i++;
		Children.Insert( i, d );
	}

	public void RemoveChild( DisplayObject d )
	{
		if ( !Children.Remove( d ) ) return;
		Player.Unload( d );
	}

	public void SwapDepths( int depth )
	{
		if ( Parent == null ) return;
		var p = Parent;
		if ( depth == Depth ) return;
		var other = p.ChildAtDepth( depth );
		if ( ReferenceEquals( other, this ) ) return;
		int myDepth = Depth;
		p.Children.Remove( this );
		if ( other != null )
		{
			p.Children.Remove( other );
			other.Depth = myDepth;
			other.TimelineDepth = -1;
			Insert( p, other );
		}
		Depth = depth;
		TimelineDepth = -1;
		Insert( p, this );
	}

	static void Insert( MovieClip p, DisplayObject d )
	{
		int i = 0;
		while ( i < p.Children.Count && p.Children[i].Depth < d.Depth ) i++;
		p.Children.Insert( i, d );
	}

	public int NextHighestDepth()
	{
		int max = -1;
		foreach ( var c in Children ) if ( c.Depth > max ) max = c.Depth;
		return Math.Max( 0, max + 1 );
	}

	// ================================================================== timeline

	public int FrameOfLabel( string label )
	{
		if ( Sprite.Labels.TryGetValue( label, out var f ) ) return f;
		foreach ( var kv in Sprite.Labels )
			if ( string.Equals( kv.Key, label, StringComparison.OrdinalIgnoreCase ) ) return kv.Value;
		return 0;
	}

	public IEnumerable<byte[]> FrameScripts( int frame )
	{
		if ( frame < 1 || frame > Sprite.Frames.Length ) yield break;
		foreach ( var op in Sprite.Frames[frame - 1] ) if ( op.Kind == 'A' ) yield return op.Code;
	}

	public void Play() => Playing = true;
	public void Stop() => Playing = false;

	public void NextFrame()
	{
		if ( CurrentFrame < TotalFrames ) Seek( CurrentFrame + 1, true );
		Playing = false;
	}

	public void PrevFrame()
	{
		if ( CurrentFrame > 1 ) Seek( CurrentFrame - 1, true );
		Playing = false;
	}

	public void GotoFrame( int frame, bool stop )
	{
		if ( frame < 1 ) frame = 1;
		if ( frame > TotalFrames ) frame = TotalFrames;
		Playing = !stop;
		if ( frame == CurrentFrame ) return;
		Seek( frame, true );
	}

	public void GotoLabel( string label, bool stop )
	{
		int f = FrameOfLabel( label );
		if ( f <= 0 ) { Playing = !stop && Playing; return; }
		GotoFrame( f, stop );
	}

	/// <summary>Advance one frame (called by the player for playing clips).</summary>
	public void AdvanceFrame()
	{
		if ( TotalFrames <= 1 && CurrentFrame >= 1 ) return;
		int next = CurrentFrame + 1;
		if ( next > TotalFrames ) next = 1;
		Seek( next, true );
	}

	/// <summary>Moves the timeline to a frame: works out the display list the tags describe and makes the children match.</summary>
	public void Seek( int target, bool runActions )
	{
		if ( target < 1 ) target = 1;
		Dictionary<int, Slot> ns;
		int from;
		if ( CurrentFrame >= 1 && target > CurrentFrame )
		{
			ns = new Dictionary<int, Slot>( slots.Count );
			foreach ( var kv in slots ) ns[kv.Key] = kv.Value;
			from = CurrentFrame + 1;
		}
		else
		{
			ns = new Dictionary<int, Slot>();
			from = 1;
		}
		for ( int f = from; f <= target; f++ ) ApplyOps( ns, f );

		// --- reconcile
		var created = new List<(DisplayObject obj, Slot slot)>();
		// remove / update existing timeline children
		for ( int i = Children.Count - 1; i >= 0; i-- )
		{
			var c = Children[i];
			if ( c.TimelineDepth < 0 ) continue;
			if ( !ns.TryGetValue( c.TimelineDepth, out var s ) )
			{
				Children.RemoveAt( i );
				Player.Unload( c );
				continue;
			}
			if ( c.Def != null && c.Def.Id == s.CharId && c.PlacedFrame == s.PlacedFrame )
			{
				if ( !c.ScriptTransformed )
				{
					c.SetMatrixFromTimeline( s.M );
					c.Cx = s.Cx;
				}
				c.Ratio = s.Ratio;
				c.Filters = s.Filters;
				c.ClipDepth = s.ClipDepth;
				c.BlendMode = s.BlendMode;
				continue;
			}
			Children.RemoveAt( i );
			Player.Unload( c );
		}
		bool rebuild = from == 1;
		foreach ( var kv in ns )
		{
			int depth = kv.Key + TimelineDepthOffset;
			var existing = ChildAtDepth( depth );
			if ( existing != null && existing.TimelineDepth == kv.Key ) continue;
			if ( existing != null )
			{
				// a script object sits at the timeline depth: Flash keeps the script object
				continue;
			}
			var s = kv.Value;
			// when playing forward, only tags in the frames just played create objects
			// (an object the script moved away with swapDepths is not re-placed)
			if ( !rebuild && s.PlacedFrame < from ) continue;
			var obj = Player.Instantiate( s.CharId, this );
			if ( obj == null ) continue;
			obj.FromTimeline = true;
			obj.TimelineDepth = kv.Key;
			obj.PlacedFrame = s.PlacedFrame;
			obj.SetMatrixFromTimeline( s.M );
			obj.Cx = s.Cx;
			obj.Ratio = s.Ratio;
			obj.Filters = s.Filters;
			obj.ClipDepth = s.ClipDepth;
			obj.BlendMode = s.BlendMode;
			obj.Name = s.Name ?? Player.NextInstanceName();
			if ( obj is MovieClip mc ) mc.ClipActions = s.ClipActions;
			AddChild( obj, depth );
			created.Add( (obj, s) );
		}
		slots = ns;
		CurrentFrame = target;

		// construct the new objects, then this frame's scripts, in tag order
		created.Sort( ( a, b ) => a.slot.PlacedFrame != b.slot.PlacedFrame ? a.slot.PlacedFrame.CompareTo( b.slot.PlacedFrame ) : a.slot.Order.CompareTo( b.slot.Order ) );
		var ops = Sprite.Frames.Length >= target ? Sprite.Frames[target - 1] : null;
		int ci = 0;
		// objects placed on earlier (skipped) frames come first
		while ( ci < created.Count && created[ci].slot.PlacedFrame < target ) Player.Construct( created[ci++].obj );
		if ( ops != null )
		{
			int order = 0;
			foreach ( var op in ops )
			{
				order++;
				if ( op.Kind == 'A' ) { if ( runActions ) Player.QueueAction( this, op.Code ); }
				else if ( op.Kind == 'S' ) Player.StartSound( op.SoundId, op.SoundStop, op.SoundLoops, this, op.SoundNoMultiple );
				else if ( op.Kind == 'P' )
				{
					while ( ci < created.Count && created[ci].slot.PlacedFrame == target && created[ci].slot.Order <= order ) Player.Construct( created[ci++].obj );
				}
			}
		}
		while ( ci < created.Count ) Player.Construct( created[ci++].obj );
	}

	void ApplyOps( Dictionary<int, Slot> ns, int frame )
	{
		if ( frame < 1 || frame > Sprite.Frames.Length ) return;
		int order = 0;
		foreach ( var op in Sprite.Frames[frame - 1] )
		{
			order++;
			switch ( op.Kind )
			{
				case 'P':
					{
						ns.TryGetValue( op.Depth, out var old );
						Slot s;
						if ( op.Move && old != null )
						{
							// replace the character at this depth, keeping unspecified properties
							s = old.Clone();
							s.CharId = op.CharId;
							s.PlacedFrame = frame;
							s.Order = order;
						}
						else
						{
							s = new Slot { CharId = op.CharId, PlacedFrame = frame, Order = order };
						}
						if ( op.M.HasValue ) s.M = op.M.Value;
						if ( op.Cx.HasValue ) s.Cx = op.Cx.Value;
						if ( op.Name != null ) s.Name = op.Name;
						if ( op.Ratio.HasValue ) s.Ratio = op.Ratio.Value;
						if ( op.ClipDepth > 0 ) s.ClipDepth = op.ClipDepth;
						if ( op.HasFilters ) s.Filters = op.Filters;
						if ( op.BlendMode != 0 ) s.BlendMode = op.BlendMode;
						if ( op.ClipActions != null ) s.ClipActions = op.ClipActions;
						ns[op.Depth] = s;
						break;
					}
				case 'M':
					{
						if ( !ns.TryGetValue( op.Depth, out var old ) ) break;
						var s = old.Clone();
						if ( op.M.HasValue ) s.M = op.M.Value;
						if ( op.Cx.HasValue ) s.Cx = op.Cx.Value;
						if ( op.Ratio.HasValue ) s.Ratio = op.Ratio.Value;
						if ( op.HasFilters ) s.Filters = op.Filters;
						if ( op.ClipDepth > 0 ) s.ClipDepth = op.ClipDepth;
						if ( op.BlendMode != 0 ) s.BlendMode = op.BlendMode;
						ns[op.Depth] = s;
						break;
					}
				case 'R':
					ns.Remove( op.Depth );
					break;
			}
		}
	}

	// ================================================================== bounds & hit testing

	public override (double x0, double y0, double x1, double y1)? LocalBounds()
	{
		(double, double, double, double)? r = null;
		foreach ( var c in Children )
		{
			if ( c.Removed ) continue;
			var b = c.BoundsIn( c.Matrix );
			if ( b is null ) continue;
			r = r is null ? b : (Math.Min( r.Value.Item1, b.Value.x0 ), Math.Min( r.Value.Item2, b.Value.y0 ), Math.Max( r.Value.Item3, b.Value.x1 ), Math.Max( r.Value.Item4, b.Value.y1 ));
		}
		return r;
	}

	public override bool HitLocal( double lx, double ly, bool shapeOnly )
	{
		for ( int i = Children.Count - 1; i >= 0; i-- )
		{
			var c = Children[i];
			if ( c.Removed || c.ClipDepth > 0 ) continue;
			if ( c.HitChild( lx, ly, shapeOnly ) ) return true;
		}
		return false;
	}

	/// <summary>Does this clip act like a button (has mouse handlers)?</summary>
	public bool HasButtonHandlers =>
		Has( "onRelease" ) || Has( "onPress" ) || Has( "onRollOver" ) || Has( "onRollOut" ) || Has( "onReleaseOutside" ) || Has( "onDragOut" ) || Has( "onDragOver" );

	// ================================================================== AS properties

	protected override bool TryGetNative( string name, Avm1 vm, out Value v )
	{
		if ( name.Length > 0 && name[0] == '_' )
		{
			if ( name == "_currentframe" ) { v = Math.Max( 1, CurrentFrame ); return true; }
			if ( name == "_totalframes" || name == "_framesloaded" ) { v = TotalFrames; return true; }
			if ( GetDisplayProperty( name, vm, out v ) ) return true;
		}
		var child = ChildByName( name );
		if ( child != null ) { v = Value.From( child ); return true; }
		switch ( name )
		{
			case "enabled": v = Enabled; return true;
			case "useHandCursor": v = UseHandCursor; return true;
			case "trackAsMenu": v = TrackAsMenu; return true;
		}
		v = default;
		return false;
	}

	protected override bool TrySetNative( string name, Value v, Avm1 vm )
	{
		if ( name.Length > 0 && name[0] == '_' && SetDisplayProperty( name, v, vm ) ) return true;
		switch ( name )
		{
			case "enabled": Enabled = v.ToBool( vm.SwfVersion ); return true;
			case "useHandCursor": UseHandCursor = v.ToBool( vm.SwfVersion ); return true;
			case "trackAsMenu": TrackAsMenu = v.ToBool( vm.SwfVersion ); return true;
		}
		return false;
	}

	protected override bool HasNativeProperty( string name ) => IsDisplayProperty( name ) || ChildByName( name ) != null;

	public override IEnumerable<string> NativeKeys
	{
		get
		{
			for ( int i = Children.Count - 1; i >= 0; i-- )
				if ( !Children[i].Removed && !string.IsNullOrEmpty( Children[i].Name ) && (Children[i] is MovieClip || Children[i] is EditText || Children[i] is ButtonObj) )
					yield return Children[i].Name;
		}
	}

	// ================================================================== script-side creation

	public override void Duplicate( string name, int depth )
	{
		if ( Parent == null ) return;
		var copy = Player.Instantiate( Def?.Id ?? -1, Parent ) as MovieClip ?? new MovieClip( Player, Sprite );
		copy.Name = name;
		copy.SetMatrixFromTimeline( Matrix );
		copy.Cx = Cx;
		copy.Filters = Filters;
		copy.ClipActions = ClipActions;
		copy.Visible = Visible;
		Parent.AddChild( copy, depth );
		Player.Construct( copy );
	}

	public override void OnRemoved()
	{
		base.OnRemoved();
		foreach ( var c in Children ) Player.Unload( c, recursiveOnly: true );
	}
}