// Copyright 2015 The Chromium 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:collection';
import 'dart:math' as math;

import 'package:flutter/rendering.dart';
import 'package:sky_services/semantics/semantics.mojom.dart' as mojom;

import 'basic.dart';
import 'binding.dart';
import 'framework.dart';
import 'gesture_detector.dart';

/// A widget that visualizes the semantics for the child.
///
/// This widget is useful for understand how an app presents itself to
/// accessibility technology.
class SemanticsDebugger extends StatefulWidget {
  /// Creates a widget that visualizes the semantics for the child.
  ///
  /// The [child] argument must not be null.
  const SemanticsDebugger({ Key key, this.child }) : super(key: key);

  /// The widget below this widget in the tree.
  final Widget child;

  @override
  _SemanticsDebuggerState createState() => new _SemanticsDebuggerState();
}

class _SemanticsDebuggerState extends State<SemanticsDebugger> {
  _SemanticsClient _client;

  @override
  void initState() {
    super.initState();
    // TODO(abarth): We shouldn't reach out to the WidgetsBinding.instance
    // static here because we might not be in a tree that's attached to that
    // binding. Instead, we should find a way to get to the PipelineOwner from
    // the BuildContext.
    _client = new _SemanticsClient(WidgetsBinding.instance.pipelineOwner)
      ..addListener(_update);
  }

  @override
  void dispose() {
    _client
      ..removeListener(_update)
      ..dispose();
    super.dispose();
  }

  void _update() {
    setState(() {
      // the generation of the _SemanticsDebuggerListener has changed
    });
  }

  Point _lastPointerDownLocation;
  void _handlePointerDown(PointerDownEvent event) {
    setState(() {
      _lastPointerDownLocation = event.position;
    });
  }

  void _handleTap() {
    assert(_lastPointerDownLocation != null);
    _client._performAction(_lastPointerDownLocation, SemanticAction.tap);
    setState(() {
      _lastPointerDownLocation = null;
    });
  }
  void _handleLongPress() {
    assert(_lastPointerDownLocation != null);
    _client._performAction(_lastPointerDownLocation, SemanticAction.longPress);
    setState(() {
      _lastPointerDownLocation = null;
    });
  }
  void _handlePanEnd(DragEndDetails details) {
    assert(_lastPointerDownLocation != null);
    _client.handlePanEnd(_lastPointerDownLocation, details.velocity);
    setState(() {
      _lastPointerDownLocation = null;
    });
  }

  @override
  Widget build(BuildContext context) {
    return new CustomPaint(
      foregroundPainter: new _SemanticsDebuggerPainter(_client.generation, _client, _lastPointerDownLocation),
      child: new GestureDetector(
        behavior: HitTestBehavior.opaque,
        onTap: _handleTap,
        onLongPress: _handleLongPress,
        onPanEnd: _handlePanEnd,
        excludeFromSemantics: true, // otherwise if you don't hit anything, we end up receiving it, which causes an infinite loop...
        child: new Listener(
          onPointerDown: _handlePointerDown,
          behavior: HitTestBehavior.opaque,
          child: new IgnorePointer(
            ignoringSemantics: false,
            child: config.child
          )
        )
      )
    );
  }
}

typedef bool _SemanticsDebuggerEntryFilter(_SemanticsDebuggerEntry entry);

class _SemanticsDebuggerEntry {
  _SemanticsDebuggerEntry(this.id);

  final int id;
  final Set<SemanticAction> actions = new Set<SemanticAction>();
  bool hasCheckedState = false;
  bool isChecked = false;
  String label;
  Matrix4 transform;
  Rect rect;
  List<_SemanticsDebuggerEntry> children;

  @override
  String toString() {
    StringBuffer buffer = new StringBuffer();
    buffer.write('_SemanticsDebuggerEntry($id; $rect; "$label"');
    for (SemanticAction action in actions)
      buffer.write('; $action');
    buffer
      ..write('${hasCheckedState ? isChecked ? "; checked" : "; unchecked" : ""}')
      ..write(')');
    return buffer.toString();
  }

  String toStringDeep([ String prefix = '']) {
    if (prefix.length > 20)
      return '$prefix<ABORTED>\n';
    String result = '$prefix$this\n';
    prefix += '  ';
    for (_SemanticsDebuggerEntry child in children) {
      result += '${child.toStringDeep(prefix)}';
    }
    return result;
  }

  void updateWith(mojom.SemanticsNode node) {
    if (node.flags != null) {
      hasCheckedState = node.flags.hasCheckedState;
      isChecked = node.flags.isChecked;
    }
    if (node.actions != null) {
      actions.clear();
      for (int encodedAction in node.actions)
        actions.add(SemanticAction.values[encodedAction]);
    }
    if (node.strings != null) {
      assert(node.strings.label != null);
      label = node.strings.label;
    } else {
      assert(label != null);
    }
    if (node.geometry != null) {
      if (node.geometry.transform != null) {
        assert(node.geometry.transform.length == 16);
        // TODO(ianh): Replace this with a cleaner call once
        //  https://github.com/google/vector_math.dart/issues/159
        // is fixed.
        List<double> array = node.geometry.transform;
        transform = new Matrix4(
          array[0],  array[1],  array[2],  array[3],
          array[4],  array[5],  array[6],  array[7],
          array[8],  array[9],  array[10], array[11],
          array[12], array[13], array[14], array[15]
        );
      } else {
        transform = null;
      }
      rect = new Rect.fromLTWH(node.geometry.left, node.geometry.top, node.geometry.width, node.geometry.height);
    }
    _updateMessage();
  }

  int findDepth() {
    if (children == null || children.isEmpty)
      return 1;
    return children.map((_SemanticsDebuggerEntry e) => e.findDepth()).reduce((int runningDepth, int nextDepth) {
      return math.max(runningDepth, nextDepth);
    }) + 1;
  }

  static const TextStyle textStyles = const TextStyle(
    color: const Color(0xFF000000),
    fontSize: 10.0,
    height: 0.8
  );

  bool get _isScrollable {
    return actions.contains(SemanticAction.scrollLeft)
        || actions.contains(SemanticAction.scrollRight)
        || actions.contains(SemanticAction.scrollUp)
        || actions.contains(SemanticAction.scrollDown);
  }

  bool get _isAdjustable {
    return actions.contains(SemanticAction.increase)
        || actions.contains(SemanticAction.decrease);
  }

  TextPainter textPainter;
  void _updateMessage() {
    List<String> annotations = <String>[];
    bool wantsTap = false;
    if (hasCheckedState) {
      annotations.add(isChecked ? 'checked' : 'unchecked');
      wantsTap = true;
    }
    if (actions.contains(SemanticAction.tap)) {
      if (!wantsTap)
        annotations.add('button');
    } else {
      if (wantsTap)
        annotations.add('disabled');
    }
    if (actions.contains(SemanticAction.longPress))
      annotations.add('long-pressable');
    if (_isScrollable)
      annotations.add('scrollable');
    if (_isAdjustable)
      annotations.add('adjustable');
    String message;
    if (annotations.isEmpty) {
      assert(label != null);
      message = label;
    } else {
      if (label == '') {
        message = annotations.join('; ');
      } else {
        message = '$label (${annotations.join('; ')})';
      }
    }
    message = message.trim();
    if (message != '') {
      textPainter ??= new TextPainter();
      textPainter
        ..text = new TextSpan(style: textStyles, text: message)
        ..textAlign = TextAlign.center
        ..layout(maxWidth: rect.width);
    } else {
      textPainter = null;
    }
  }

  void paint(Canvas canvas, int rank) {
    canvas.save();
    if (transform != null)
      canvas.transform(transform.storage);
    if (!rect.isEmpty) {
      Color lineColor = new Color(0xFF000000 + new math.Random(id).nextInt(0xFFFFFF));
      Rect innerRect = rect.deflate(rank * 1.0);
      if (innerRect.isEmpty) {
        Paint fill = new Paint()
         ..color = lineColor
         ..style = PaintingStyle.fill;
        canvas.drawRect(rect, fill);
      } else {
        Paint fill = new Paint()
         ..color = const Color(0xFFFFFFFF)
         ..style = PaintingStyle.fill;
        canvas.drawRect(rect, fill);
        Paint line = new Paint()
         ..strokeWidth = rank * 2.0
         ..color = lineColor
         ..style = PaintingStyle.stroke;
        canvas.drawRect(innerRect, line);
      }
      if (textPainter != null) {
        canvas.save();
        canvas.clipRect(rect);
        textPainter.paint(canvas, rect.topLeft.toOffset());
        canvas.restore();
      }
    }
    for (_SemanticsDebuggerEntry child in children)
      child.paint(canvas, rank - 1);
    canvas.restore();
  }

  _SemanticsDebuggerEntry hitTest(Point position, _SemanticsDebuggerEntryFilter filter) {
    if (transform != null) {
      Matrix4 invertedTransform = new Matrix4.identity();
      double determinant = invertedTransform.copyInverse(transform);
      if (determinant == 0.0)
        return null;
      position = MatrixUtils.transformPoint(invertedTransform, position);
    }
    if (!rect.contains(position))
      return null;
    _SemanticsDebuggerEntry result;
    for (_SemanticsDebuggerEntry child in children.reversed) {
      result = child.hitTest(position, filter);
      if (result != null)
        break;
    }
    if (result == null || !filter(result))
      result = this;
    return result;
  }
}

class _SemanticsClient extends ChangeNotifier {
  _SemanticsClient(PipelineOwner pipelineOwner) {
    _semanticsOwner = pipelineOwner.addSemanticsListener(_updateSemanticsTree);
  }

  SemanticsOwner _semanticsOwner;

  @override
  void dispose() {
    _semanticsOwner.removeListener(_updateSemanticsTree);
    _semanticsOwner = null;
    super.dispose();
  }

  _SemanticsDebuggerEntry get rootNode => _nodes[0];
  final Map<int, _SemanticsDebuggerEntry> _nodes = <int, _SemanticsDebuggerEntry>{};

  _SemanticsDebuggerEntry _updateNode(mojom.SemanticsNode node) {
    final int id = node.id;
    _SemanticsDebuggerEntry entry = _nodes.putIfAbsent(id, () => new _SemanticsDebuggerEntry(id));
    entry.updateWith(node);
    if (node.children != null) {
      if (entry.children != null)
        entry.children.clear();
      else
        entry.children = new List<_SemanticsDebuggerEntry>();
      for (mojom.SemanticsNode child in node.children)
        entry.children.add(_updateNode(child));
    }
    return entry;
  }

  void _removeDetachedNodes() {
    // TODO(abarth): We should be able to keep this table updated without
    // walking the entire tree.
    Set<int> detachedNodes = new Set<int>.from(_nodes.keys);
    Queue<_SemanticsDebuggerEntry> unvisited = new Queue<_SemanticsDebuggerEntry>();
    unvisited.add(rootNode);
    while (unvisited.isNotEmpty) {
      _SemanticsDebuggerEntry node = unvisited.removeFirst();
      detachedNodes.remove(node.id);
      if (node.children != null)
        unvisited.addAll(node.children);
    }
    for (int id in detachedNodes)
      _nodes.remove(id);
  }

  int generation = 0;

  void _updateSemanticsTree(List<mojom.SemanticsNode> nodes) {
    generation += 1;
    for (mojom.SemanticsNode node in nodes)
      _updateNode(node);
    _removeDetachedNodes();
    notifyListeners();
  }

  _SemanticsDebuggerEntry _hitTest(Point position, _SemanticsDebuggerEntryFilter filter) {
    return rootNode?.hitTest(position, filter);
  }

  void _performAction(Point position, SemanticAction action) {
    _SemanticsDebuggerEntry entry = _hitTest(position, (_SemanticsDebuggerEntry entry) => entry.actions.contains(action));
    _semanticsOwner.performAction(entry?.id ?? 0, action);
  }

  void handlePanEnd(Point position, Velocity velocity) {
    double vx = velocity.pixelsPerSecond.dx;
    double vy = velocity.pixelsPerSecond.dy;
    if (vx.abs() == vy.abs())
      return;
    if (vx.abs() > vy.abs()) {
      if (vx.sign < 0) {
        _performAction(position, SemanticAction.decrease);
        _performAction(position, SemanticAction.scrollLeft);
      } else {
        _performAction(position, SemanticAction.increase);
        _performAction(position, SemanticAction.scrollRight);
      }
    } else {
      if (vy.sign < 0)
        _performAction(position, SemanticAction.scrollUp);
      else
        _performAction(position, SemanticAction.scrollDown);
    }
  }
}

class _SemanticsDebuggerPainter extends CustomPainter {
  const _SemanticsDebuggerPainter(this.generation, this.client, this.pointerPosition);

  final int generation;
  final _SemanticsClient client;
  final Point pointerPosition;

  @override
  void paint(Canvas canvas, Size size) {
    _SemanticsDebuggerEntry rootNode = client.rootNode;
    rootNode?.paint(canvas, rootNode.findDepth());
    if (pointerPosition != null) {
      Paint paint = new Paint();
      paint.color = const Color(0x7F0090FF);
      canvas.drawCircle(pointerPosition, 10.0, paint);
    }
  }

  @override
  bool shouldRepaint(_SemanticsDebuggerPainter oldDelegate) {
    return generation != oldDelegate.generation
        || client != oldDelegate.client
        || pointerPosition != oldDelegate.pointerPosition;
  }
}
