// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:ui' as ui;

import 'package:flutter/rendering.dart';

import 'basic.dart';
import 'debug.dart';
import 'framework.dart';
import 'media_query.dart';

/// Controls how the [SnapshotWidget] paints its child.
enum SnapshotMode {
  /// The child is snapshotted, but only if all descendants can be snapshotted.
  ///
  /// If there is a platform view in the children of a snapshot widget, the
  /// snapshot will not be used and the child will be rendered using
  /// [SnapshotPainter.paint]. This uses an un-snapshotted child and by default
  /// paints it with no additional modification.
  permissive,

  /// An error is thrown if the child cannot be snapshotted.
  ///
  /// This setting is the default state of the [SnapshotWidget].
  normal,

  /// The child is snapshotted and any child platform views are ignored.
  ///
  /// This mode can be useful if there is a platform view descendant that does
  /// not need to be included in the snapshot.
  forced,
}

/// A controller for the [SnapshotWidget] that controls when the child image is displayed
/// and when to regenerated the child image.
///
/// When the value of [allowSnapshotting] is true, the [SnapshotWidget] will paint the child
/// widgets based on the [SnapshotMode] of the snapshot widget.
///
/// The controller notifies its listeners when the value of [allowSnapshotting] changes
/// or when [clear] is called.
///
/// To force [SnapshotWidget] to recreate the child image, call [clear].
class SnapshotController extends ChangeNotifier {
  /// Create a new [SnapshotController].
  ///
  /// By default, [allowSnapshotting] is `false` and cannot be `null`.
  SnapshotController({
    bool allowSnapshotting = false,
  }) : _allowSnapshotting = allowSnapshotting;

  /// Reset the snapshot held by any listening [SnapshotWidget].
  ///
  /// This has no effect if [allowSnapshotting] is `false`.
  void clear() {
    notifyListeners();
  }

  /// Whether a snapshot of this child widget is painted in its place.
  bool get allowSnapshotting => _allowSnapshotting;
  bool _allowSnapshotting;
  set allowSnapshotting(bool value) {
    if (value == allowSnapshotting) {
      return;
    }
    _allowSnapshotting = value;
    notifyListeners();
  }
}

/// A widget that can replace its child with a snapshotted version of the child.
///
/// A snapshot is a frozen texture-backed representation of all child pictures
/// and layers stored as a [ui.Image].
///
/// This widget is useful for performing short animations that would otherwise
/// be expensive or that cannot rely on raster caching. For example, scale and
/// skew animations are often expensive to perform on complex children, as are
/// blurs. For a short animation, a widget that contains these expensive effects
/// can be replaced with a snapshot of itself and manipulated instead.
///
/// For example, the Android Q [ZoomPageTransitionsBuilder] uses a snapshot widget
/// for the forward and entering route to avoid the expensive scale animation.
/// This also has the effect of briefly pausing any animations on the page.
///
/// Generally, this widget should not be used in places where users expect the
/// child widget to continue animating or to be responsive, such as an unbounded
/// animation.
///
/// Caveats:
///
/// * The contents of platform views cannot be captured by a snapshot
///   widget. If a platform view is encountered, then the snapshot widget will
///   determine how to render its children based on the [SnapshotMode]. This
///   defaults to [SnapshotMode.normal] which will throw an exception if a
///   platform view is encountered.
///
/// * The snapshotting functionality of this widget is not supported on the HTML
///   backend of Flutter for the Web. Setting [SnapshotController.allowSnapshotting] to true
///   may cause an error to be thrown. On the CanvasKit backend of Flutter, the
///   performance of using this widget may regress performance due to the fact
///   that both the UI and engine share a single thread.
class SnapshotWidget extends SingleChildRenderObjectWidget {
  /// Create a new [SnapshotWidget].
  ///
  /// The [controller] and [child] arguments are required.
  const SnapshotWidget({
    super.key,
    this.mode = SnapshotMode.normal,
    this.painter = const _DefaultSnapshotPainter(),
    required this.controller,
    required super.child
  });

  /// The controller that determines when to display the children as a snapshot.
  final SnapshotController controller;

  /// Configuration that controls how the snapshot widget decides to paint its children.
  ///
  /// Defaults to [SnapshotMode.normal], which throws an error when a platform view
  /// or texture view is encountered.
  ///
  /// See [SnapshotMode] for more information.
  final SnapshotMode mode;

  /// The painter used to paint the child snapshot or child widgets.
  final SnapshotPainter painter;

  @override
  RenderObject createRenderObject(BuildContext context) {
    debugCheckHasMediaQuery(context);
    return _RenderSnapshotWidget(
      controller: controller,
      mode: mode,
      devicePixelRatio: MediaQuery.of(context).devicePixelRatio,
      painter: painter,
    );
  }

  @override
  void updateRenderObject(BuildContext context, covariant RenderObject renderObject) {
    debugCheckHasMediaQuery(context);
    (renderObject as _RenderSnapshotWidget)
      ..controller = controller
      ..mode = mode
      ..devicePixelRatio = MediaQuery.of(context).devicePixelRatio
      ..painter = painter;
  }
}

// A render object that conditionally converts its child into a [ui.Image]
// and then paints it in place of the child.
class _RenderSnapshotWidget extends RenderProxyBox {
  // Create a new [_RenderSnapshotWidget].
  _RenderSnapshotWidget({
    required double devicePixelRatio,
    required SnapshotController controller,
    required SnapshotMode mode,
    required SnapshotPainter painter,
  }) : _devicePixelRatio = devicePixelRatio,
       _controller = controller,
       _mode = mode,
       _painter = painter;

  /// The device pixel ratio used to create the child image.
  double get devicePixelRatio => _devicePixelRatio;
  double _devicePixelRatio;
  set devicePixelRatio(double value) {
    if (value == devicePixelRatio) {
      return;
    }
    _devicePixelRatio = value;
    if (_childRaster == null) {
      return;
    } else {
      _childRaster?.dispose();
      _childRaster = null;
      markNeedsPaint();
    }
  }

  /// The painter used to paint the child snapshot or child widgets.
  SnapshotPainter get painter => _painter;
  SnapshotPainter _painter;
  set painter(SnapshotPainter value) {
    if (value == painter) {
      return;
    }
    final SnapshotPainter oldPainter = painter;
    oldPainter.removeListener(markNeedsPaint);
    _painter = value;
    if (oldPainter.runtimeType != painter.runtimeType ||
        painter.shouldRepaint(oldPainter)) {
      markNeedsPaint();
    }
    if (attached) {
      painter.addListener(markNeedsPaint);
    }
  }

  /// A controller that determines whether to paint the child normally or to
  /// paint a snapshotted version of that child.
  SnapshotController get controller => _controller;
  SnapshotController _controller;
  set controller(SnapshotController value) {
    if (value == controller) {
      return;
    }
    controller.removeListener(_onRasterValueChanged);
    final bool oldValue = controller.allowSnapshotting;
    _controller = value;
    if (attached) {
      controller.addListener(_onRasterValueChanged);
      if (oldValue != controller.allowSnapshotting) {
        _onRasterValueChanged();
      }
    }
  }

  /// How the snapshot widget will handle platform views in child layers.
  SnapshotMode get mode => _mode;
  SnapshotMode _mode;
  set mode(SnapshotMode value) {
    if (value == _mode) {
      return;
    }
    _mode = value;
    markNeedsPaint();
  }

  ui.Image? _childRaster;
  // Set to true if the snapshot mode was not forced and a platform view
  // was encountered while attempting to snapshot the child.
  bool _disableSnapshotAttempt = false;

  @override
  void attach(covariant PipelineOwner owner) {
    controller.addListener(_onRasterValueChanged);
    painter.addListener(markNeedsPaint);
    super.attach(owner);
  }

  @override
  void detach() {
    _disableSnapshotAttempt = false;
    controller.removeListener(_onRasterValueChanged);
    painter.removeListener(markNeedsPaint);
    _childRaster?.dispose();
    _childRaster = null;
    super.detach();
  }

  @override
  void dispose() {
    controller.removeListener(_onRasterValueChanged);
    painter.removeListener(markNeedsPaint);
    _childRaster?.dispose();
    _childRaster = null;
    super.dispose();
  }

  void _onRasterValueChanged() {
    _disableSnapshotAttempt = false;
    _childRaster?.dispose();
    _childRaster = null;
    markNeedsPaint();
  }

  // Paint [child] with this painting context, then convert to a raster and detach all
  // children from this layer.
  ui.Image? _paintAndDetachToImage() {
    final OffsetLayer offsetLayer = OffsetLayer();
    final PaintingContext context = PaintingContext(offsetLayer, Offset.zero & size);
    super.paint(context, Offset.zero);
    // This ignore is here because this method is protected by the `PaintingContext`. Adding a new
    // method that performs the work of `_paintAndDetachToImage` would avoid the need for this, but
    // that would conflict with our goals of minimizing painting context.
    // ignore: invalid_use_of_protected_member
    context.stopRecordingIfNeeded();
    if (mode != SnapshotMode.forced && !offsetLayer.supportsRasterization()) {
      if (mode == SnapshotMode.normal) {
        throw FlutterError('SnapshotWidget used with a child that contains a PlatformView.');
      }
      _disableSnapshotAttempt = true;
      return null;
    }
    final ui.Image image = offsetLayer.toImageSync(Offset.zero & size, pixelRatio: devicePixelRatio);
    offsetLayer.dispose();
    return image;
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    if (size.isEmpty) {
      _childRaster?.dispose();
      _childRaster = null;
      return;
    }
    if (!controller.allowSnapshotting || _disableSnapshotAttempt) {
      _childRaster?.dispose();
      _childRaster = null;
      painter.paint(context, offset, size, super.paint);
      return;
    }
    _childRaster ??= _paintAndDetachToImage();
    if (_childRaster == null) {
      painter.paint(context, offset, size, super.paint);
    } else {
      painter.paintSnapshot(context, offset, size, _childRaster!, devicePixelRatio);
    }
    return;
  }
}

/// A painter used to paint either a snapshot or the child widgets that
/// would be a snapshot.
///
/// The painter can call [notifyListeners] to have the [SnapshotWidget]
/// re-paint (re-using the same raster). This allows animations to be performed
/// without re-snapshotting of children. For certain scale or perspective changing
/// transforms, such as a rotation, this can be significantly faster than performing
/// the same animation at the widget level.
///
/// By default, the [SnapshotWidget] includes a delegate that draws the child raster
/// exactly as the child widgets would have been drawn. Nevertheless, this can
/// also be used to efficiently transform the child raster and apply complex paint
/// effects.
///
/// {@tool snippet}
///
/// The following method shows how to efficiently rotate the child raster.
///
/// ```dart
/// void paint(PaintingContext context, Offset offset, Size size, ui.Image image, double pixelRatio) {
///   const double radians = 0.5; // Could be driven by an animation.
///   final Matrix4 transform = Matrix4.rotationZ(radians);
///   context.canvas.transform(transform.storage);
///   final Rect src = Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble());
///   final Rect dst = Rect.fromLTWH(offset.dx, offset.dy, size.width, size.height);
///   final Paint paint = Paint()
///     ..filterQuality = FilterQuality.low;
///   context.canvas.drawImageRect(image, src, dst, paint);
/// }
/// ```
/// {@end-tool}
abstract class SnapshotPainter extends ChangeNotifier  {
  /// Called whenever the [image] that represents a [SnapshotWidget]s child should be painted.
  ///
  /// The image is rasterized at the physical pixel resolution and should be scaled down by
  /// [pixelRatio] to account for device independent pixels.
  ///
  /// {@tool snippet}
  ///
  /// The following method shows how the default implementation of the delegate used by the
  /// [SnapshotPainter] paints the snapshot. This must account for the fact that the image
  /// width and height will be given in physical pixels, while the image must be painted with
  /// device independent pixels. That is, the width and height of the image is the widget and
  /// height of the provided `size`, multiplied by the `pixelRatio`:
  ///
  /// ```dart
  /// void paint(PaintingContext context, Offset offset, Size size, ui.Image image, double pixelRatio) {
  ///   final Rect src = Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble());
  ///   final Rect dst = Rect.fromLTWH(offset.dx, offset.dy, size.width, size.height);
  ///   final Paint paint = Paint()
  ///     ..filterQuality = FilterQuality.low;
  ///   context.canvas.drawImageRect(image, src, dst, paint);
  /// }
  /// ```
  /// {@end-tool}
  void paintSnapshot(PaintingContext context, Offset offset, Size size, ui.Image image, double pixelRatio);

  /// Paint the child via [painter], applying any effects that would have been painted
  /// in [SnapshotPainter.paintSnapshot].
  ///
  /// This method is called when snapshotting is disabled, or when [SnapshotMode.permissive]
  /// is used and a child platform view prevents snapshotting.
  ///
  /// The [offset] and [size] are the location and dimensions of the render object.
  void paint(PaintingContext context, Offset offset, Size size, PaintingContextCallback painter);

  /// Called whenever a new instance of the snapshot widget delegate class is
  /// provided to the [SnapshotWidget] object, or any time that a new
  /// [SnapshotPainter] object is created with a new instance of the
  /// delegate class (which amounts to the same thing, because the latter is
  /// implemented in terms of the former).
  ///
  /// If the new instance represents different information than the old
  /// instance, then the method should return true, otherwise it should return
  /// false.
  ///
  /// If the method returns false, then the [paint] call might be optimized
  /// away.
  ///
  /// It's possible that the [paint] method will get called even if
  /// [shouldRepaint] returns false (e.g. if an ancestor or descendant needed to
  /// be repainted). It's also possible that the [paint] method will get called
  /// without [shouldRepaint] being called at all (e.g. if the box changes
  /// size).
  ///
  /// Changing the delegate will not cause the child image retained by the
  /// [SnapshotWidget] to be updated. Instead, [SnapshotController.clear] can
  /// be used to force the generation of a new image.
  ///
  /// The `oldPainter` argument will never be null.
  bool shouldRepaint(covariant SnapshotPainter oldPainter);
}

class _DefaultSnapshotPainter implements SnapshotPainter {
  const _DefaultSnapshotPainter();

  @override
  void addListener(ui.VoidCallback listener) { }

  @override
  void dispose() { }

  @override
  bool get hasListeners => false;

  @override
  void notifyListeners() { }

  @override
  void paint(PaintingContext context, ui.Offset offset, ui.Size size, PaintingContextCallback painter) {
    painter(context, offset);
  }

  @override
  void paintSnapshot(PaintingContext context, ui.Offset offset, ui.Size size, ui.Image image, double pixelRatio) {
    final Rect src = Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble());
    final Rect dst = Rect.fromLTWH(offset.dx, offset.dy, size.width, size.height);
    final Paint paint = Paint()
      ..filterQuality = FilterQuality.low;
    context.canvas.drawImageRect(image, src, dst, paint);
  }

  @override
  void removeListener(ui.VoidCallback listener) { }

  @override
  bool shouldRepaint(covariant _DefaultSnapshotPainter oldPainter) => false;
}