/*
 * Copyright 2012-2014 eBay Software Foundation and selendroid committers.
 * 
 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
 * in compliance with the License. You may obtain a copy of the License at
 * 
 * http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software distributed under the License
 * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
 * or implied. See the License for the specific language governing permissions and limitations under
 * the License.
 */
package io.selendroid.server.model;

import android.app.Activity;
import android.view.ViewParent;
import io.selendroid.server.ServerInstrumentation;
import io.selendroid.server.android.AndroidWait;
import io.selendroid.server.android.KeySender;
import io.selendroid.server.android.ViewHierarchyAnalyzer;
import io.selendroid.server.android.internal.Dimension;
import io.selendroid.server.android.internal.Point;
import io.selendroid.server.common.exceptions.ElementNotVisibleException;
import io.selendroid.server.common.exceptions.NoSuchElementAttributeException;
import io.selendroid.server.common.exceptions.NoSuchElementException;
import io.selendroid.server.common.exceptions.SelendroidException;
import io.selendroid.server.common.exceptions.TimeoutException;
import io.selendroid.server.model.interactions.AndroidCoordinates;
import io.selendroid.server.model.interactions.Coordinates;
import io.selendroid.server.model.internal.AbstractNativeElementContext;
import io.selendroid.server.util.Function;
import io.selendroid.server.util.Preconditions;
import io.selendroid.server.util.SelendroidLogger;

import java.lang.ref.WeakReference;
import java.lang.reflect.Method;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;

import org.json.JSONException;
import org.json.JSONObject;

import android.graphics.Rect;
import android.os.Build;
import android.os.SystemClock;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewTreeObserver;
import android.webkit.JsResult;
import android.webkit.WebChromeClient;
import android.webkit.WebView;
import android.widget.CompoundButton;
import android.widget.EditText;
import android.widget.TextView;

public class AndroidNativeElement implements AndroidElement {
  // TODO revisit
  protected static final long DURATION_OF_LONG_PRESS = 750L;// (long)
  // (ViewConfiguration.getLongPressTimeout()
  // * 1.5f);
  private WeakReference<View> viewRef;
  private Collection<AndroidElement> children = new LinkedHashSet<AndroidElement>();
  private AndroidElement parent;
  private ServerInstrumentation instrumentation;
  private final KeySender keys;
  private SearchContext nativeElementSearchScope = null;
  private Coordinates coordinates = null;
  final Object syncObject = new Object();
  private Boolean done = false;
  private KnownElements ke;
  private int hashCode;
  static final long UI_TIMEOUT = 3000L;
  private final String id;

  public AndroidNativeElement(View view, ServerInstrumentation instrumentation, KeySender keys,
                              KnownElements ke) {
    Preconditions.checkNotNull(view);
    this.viewRef = new WeakReference<View>(view);
    hashCode = view.hashCode() + 31;
    this.instrumentation = instrumentation;
    this.keys = keys;
    this.nativeElementSearchScope = new NativeElementSearchScope(instrumentation, keys, ke);
    this.ke = ke;

    Random random = new Random();
    this.id = new UUID(random.nextLong(), random.nextLong()).toString();
  }

  @Override
  public AndroidElement getParent() {
    return parent;
  }

  public boolean isDisplayed() {
    View view = getView();
    boolean hasWindowFocus = view.hasWindowFocus();
    int width = view.getWidth();
    int height = view.getHeight();
    int visibility = view.getVisibility();
    boolean isVisible = (View.VISIBLE == visibility);

    // Check visibility of the view and its parents as well.
    // This is more reliable when transitions between activities are in progress.
    boolean isShown = view.isShown();

    boolean isDisplayed =
        hasWindowFocus && isVisible && isShown && (width > 0) && (height > 0);

    if (!isDisplayed) {
      Activity activity = instrumentation.getCurrentActivity();
      View focusedView = activity.getCurrentFocus();
      String displayCheckFailureMessage =
          String.format(
              "Display check failed\n" +
                  "for view: %s\n" +
                  "isVisible: %b\nvisibility: %d\nisShown: %b\nhasWindowFocus: %b\n" +
                  "width: %d\nheight: %d\ncurrent activity: %s\nfocused view: %s",
              view, isVisible, visibility, isShown, hasWindowFocus,
              width, height, activity, focusedView);
      SelendroidLogger.debug(displayCheckFailureMessage);
      if (!isShown) {
        logIsShownCheckFailure(view);
      }
      // Check the view belongs to the same view hierarchy as the view with current window focus.
      // If true, this usually means a system alert dialog is rendered on top of the view
      // (typically this is an app crash dialog).
      if (!hasWindowFocus) {
        if (activity != null && focusedView != null) {
          if (view.getRootView() == focusedView.getRootView()) {
             SelendroidLogger.debug("hasWindowFocus() check failed. " +
                    "This usually means the view is covered by a system dialog.");
          }
        }
      }
    }

    return isDisplayed;
  }

  /**
   * If view.isShown() == false, logs why exactly this evaluates to false.
   * Copied from Android's implementation of View.isShown().
   */
  private void logIsShownCheckFailure(View view) {
    try {
      SelendroidLogger.debug("Display check failed because View.isShown() == false");
      View current = view;
      do {
        if ((current.getVisibility()) != View.VISIBLE) {
          SelendroidLogger.debug(String.format(
              "isShown: View %s is not visible because its ancestor %s has visibility %d",
              view, current, current.getVisibility()));
          break;
        }
        ViewParent parent = current.getParent();
        if (parent == null) {
          SelendroidLogger.debug(String.format(
              "isShown: View %s is not visible because its ancestor %s has no parent " +
                  "(it is not attached to view root): ",
              view, current));
          break;
        }
        if (!(parent instanceof View)) {
          // The only case where View.isShown() returns true:
          // The view needs to have an ancestor that is not a View and all ancestors on the way up have to
          // be visible.
          break;
        }
        current = (View) parent;
      } while (current != null);
      SelendroidLogger.debug(String.format("View %s is not visible", view));
    } catch (Exception e) {
      // Don't let an exception in debug printing crash the caller
      SelendroidLogger.error("isShown() debug printing failed", e);
    }
  }

  private void waitUntilIsDisplayed() {
    AndroidWait wait = instrumentation.getAndroidWait();

    try {
      wait.until(new Function<Void, Boolean>() {
        @Override
        public Boolean apply(Void input) {
          return isDisplayed();
        }
      });
    } catch (TimeoutException exception) {
      throw new ElementNotVisibleException(
          "You may only do passive read with element not displayed");
    }
  }

  protected void scrollIntoScreenIfNeeded() {
    Point leftTopLocation = getLocation();
    final int left = leftTopLocation.x;
    final int top = leftTopLocation.y;

    instrumentation.getInstrumentation().runOnMainSync(new Runnable() {
      @Override
      public void run() {
        synchronized (syncObject) {
          Rect r = new Rect(left, top, getView().getWidth(), getView().getHeight());

          getView().requestRectangleOnScreen(r);
          done = true;
          syncObject.notify();
        }
      }
    });
    long end = System.currentTimeMillis() + instrumentation.getAndroidWait().getTimeoutInMillis();
    synchronized (syncObject) {
      while (!done && System.currentTimeMillis() < end) {
        try {
          syncObject.wait(AndroidWait.DEFAULT_SLEEP_INTERVAL);
        } catch (InterruptedException e) {
          Thread.currentThread().interrupt();
        }
      }
    }
  }

  @Override
  public void enterText(CharSequence... keysToSend) {
    requestFocus();

    StringBuilder sb = new StringBuilder();
    for (CharSequence keys : keysToSend) {
      sb.append(keys);
    }
    send(sb);
  }

  private void requestFocus() {
    final View viewview = getView();
    instrumentation.getInstrumentation().runOnMainSync(new Runnable() {
      @Override
      public void run() {
        viewview.requestFocus();
      }
    });
    click();
  }

  @Override
  public String getText() {
    if (getView() instanceof TextView) {
      return ((TextView) getView()).getText().toString();
    }
    SelendroidLogger.warning("Element does not support getText(): "
        + getView().getClass().getSimpleName());
    return null;
  }

  @Override
  public void click() {
    waitUntilIsDisplayed();
    scrollIntoScreenIfNeeded();

    final View view = getView();
    final int[] xy = new int[2];

    try {
      Thread.sleep(300);
    } catch (InterruptedException e) {
      // No-op
    }
    view.getLocationOnScreen(xy);
    if (Build.VERSION.SDK_INT < 19 || xy[0] !=0 && xy[1] != 0) {
      doClick();
      return;
    }

    if (view.isLaidOut()) {
      SelendroidLogger.debug("View is laid out, clicking immediately");
      doClick();
      return;
    }

    final AndroidWait wait = instrumentation.getAndroidWait();
    final AtomicBoolean isLaidOut = new AtomicBoolean(false);
    final ViewTreeObserver observer = view.getViewTreeObserver();
    observer.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
      @Override
      public void onGlobalLayout() {
        try {
          isLaidOut.set(true);
        } finally {
          if (observer.isAlive()) {
            removeOnGlobalLayoutListener(observer, this);
          } else {
            removeOnGlobalLayoutListener(view.getViewTreeObserver(), this);
          }
        }
      }
    }); 
    try {
      wait.until(new Function<Void, Boolean>() {
        @Override
        public Boolean apply(Void input) {
          return isLaidOut.get();
        }
      });
    } catch (TimeoutException e) {
      throw new SelendroidException("View was never laid out", e);
    }

    doClick();
  }

  private void doClick() {
    final View view = getView();
    final int[] xy = new int[2];

    view.getLocationOnScreen(xy);
    SelendroidLogger.debug("View reported coordinates: " + xy[0] + "," + xy[1]);
    clickOnScreen(xy[0] + view.getWidth() / 2.0f, xy[1] + view.getHeight() / 2.0f);
  }

  private void removeOnGlobalLayoutListener(
    ViewTreeObserver observer,
    ViewTreeObserver.OnGlobalLayoutListener listener) {
    if (observer == null || !observer.isAlive()) {
      return;
    }

    if (Build.VERSION.SDK_INT < 16) {
      observer.removeGlobalOnLayoutListener(listener);
    } else {
      observer.removeOnGlobalLayoutListener(listener);
    }
  }

  private void clickOnScreen(float x, float y) {
    SelendroidLogger.debug(String.format("Clicking at position [%f, %f]", x, y));
    final ServerInstrumentation inst = instrumentation;
    long downTime = SystemClock.uptimeMillis();
    long eventTime = SystemClock.uptimeMillis();
    final MotionEvent event =
        MotionEvent.obtain(downTime, eventTime, MotionEvent.ACTION_DOWN, x, y, 0);
    final MotionEvent event2 =
        MotionEvent.obtain(downTime, eventTime, MotionEvent.ACTION_UP, x, y, 0);

    try {
      inst.getInstrumentation().sendPointerSync(event);
      inst.getInstrumentation().sendPointerSync(event2);
      try {
        Thread.sleep(300);
      } catch (InterruptedException ignored) {}
    } catch (SecurityException e) {
      SelendroidLogger.error("error while clicking element", e);
    }
  }

  public Integer getAndroidId() {
    int viewId = getView().getId();
    return (viewId == View.NO_ID) ? null : viewId;
  }

  @Override
  public AndroidElement findElement(By by) throws NoSuchElementException {
    return by.findElement(nativeElementSearchScope);
  }

  @Override
  public List<AndroidElement> findElements(By by) throws NoSuchElementException {
    return by.findElements(nativeElementSearchScope);
  }

  @Override
  public Collection<AndroidElement> getChildren() {
    return children;
  }

  public void setParent(AndroidElement parent) {
    this.parent = parent;
  }

  public void addChild(AndroidElement child) {
    this.children.add(child);
  }

  public String toString() {
    return new StringBuilder().append("id: ").append(getView().getId()).append("view class: ")
        .append(getView().getClass()).append("view content desc: ")
        .append(getView().getContentDescription()).toString();
  }

  protected void send(CharSequence string) {
    keys.send(string);
  }

  public JSONObject toJson() throws JSONException {
    JSONObject object = new JSONObject();
    JSONObject l10n = new JSONObject();
    l10n.put("matches", 0);
    object.put("l10n", l10n);
    CharSequence cd = getView().getContentDescription();
    if (cd != null && cd.length() > 0) {
      String label = String.valueOf(cd);
      object.put("name", label);
    } else {
      object.put("name", "");
    }
    String id = getNativeId();
    object.put("id", id.startsWith("id/") ? id.replace("id/", "") : id);
    JSONObject rect = new JSONObject();

    object.put("rect", rect);
    JSONObject origin = new JSONObject();
    Point location = getLocation();
    origin.put("x", location.x);
    origin.put("y", location.y);
    rect.put("origin", origin);

    JSONObject size = new JSONObject();
    Dimension s = getSize();
    size.put("height", s.getHeight());
    size.put("width", s.getWidth());
    rect.put("size", size);

    object.put("ref", ke.getIdOfElement(this));
    object.put("type", getView().getClass().getSimpleName());
    String value = "";
    if (getView() instanceof TextView) {
      TextView textView = (TextView) getView();
      value = String.valueOf(textView.getText());

      CharSequence error = textView.getError();
      if(error != null && error.length() > 0){
        SelendroidLogger.info("error: " + error);
        object.put("error", error);
      }
    }
    object.put("value", value);
    object.put("shown", getView().isShown());
    if (getView() instanceof WebView) {
      final WebView webview = (WebView) getView();
      final WebViewSourceClient client = new WebViewSourceClient();
      instrumentation.getCurrentActivity().runOnUiThread(new Runnable() {
        public void run() {
          synchronized (syncObject) {
            webview.getSettings().setJavaScriptEnabled(true);

            webview.setWebChromeClient(client);
            String script = "document.body.parentNode.innerHTML";
            webview.loadUrl("javascript:alert('selendroidSource:'+" + script + ")");
          }
        }
      });
      long end = System.currentTimeMillis() + 10000;
      waitForDone(end, UI_TIMEOUT, "Error while grabbing web view source code.");
      object.put("source", "<html>" + client.result + "</html>");
    }

    return object;
  }

  public class WebViewSourceClient extends WebChromeClient {
    public Object result = null;

    /**
     * Unconventional way of adding a Javascript interface but the main reason why I took this way
     * is that it is working stable compared to the webview.addJavascriptInterface way.
     */
    @Override
    public boolean onJsAlert(WebView view, String url, String message, JsResult jsResult) {
      if (message != null && message.startsWith("selendroidSource:")) {
        jsResult.confirm();

        synchronized (syncObject) {
          result = message.replaceFirst("selendroidSource:", "");
          done = true;
          syncObject.notify();
        }

        return true;
      } else {
        return super.onJsAlert(view, url, message, jsResult);
      }
    }
  }

  private void waitForDone(long end, long timeout, String error) {
    synchronized (syncObject) {
      while (!done && System.currentTimeMillis() < end) {
        try {
          syncObject.wait(timeout);
        } catch (InterruptedException e) {
          throw new SelendroidException(error, e);
        }
      }
    }
  }

  private String getNativeId() {
    return ViewHierarchyAnalyzer.getNativeId(getView());
  }

  public View getView() {
    if (viewRef.get() == null) {
      throw new IllegalStateException(
          "Trying to access a native element that has already been garbage collected");
    }
    return viewRef.get();
  }

  @Override
  public void clear() {
    final View viewview = getView();
    instrumentation.getInstrumentation().runOnMainSync(new Runnable() {
      @Override
      public void run() {
        viewview.requestFocus();
        if (viewview instanceof EditText) {
          ((EditText) viewview).setText("");
        }
      }
    });
  }

  @Override
  public void submit() {
    throw new UnsupportedOperationException("Submit is not supported for native elements.");
  }

  @Override
  public boolean isSelected() {
    if (getView() instanceof CompoundButton) {
      return ((CompoundButton) getView()).isChecked();
    }

    throw new UnsupportedOperationException(
        "Is selected is only available for view class CheckBox and RadioButton.");
  }

  @Override
  public Point getLocation() {
    int[] xy = new int[2];
    getView().getLocationOnScreen(xy);
    return new Point(xy[0], xy[1]);
  }

  private class NativeElementSearchScope extends AbstractNativeElementContext {
    public NativeElementSearchScope(ServerInstrumentation instrumentation, KeySender keys,
                                    KnownElements knownElements) {
      super(instrumentation, keys, knownElements);
    }

    @Override
    protected View getRootView() {
      return getView();
    }

    protected List<View> getTopLevelViews() {
      return Arrays.asList(getView());
    }
  }

  @Override
  public Coordinates getCoordinates() {
    if (coordinates == null) {
      coordinates =
          new AndroidCoordinates(String.valueOf(getView().getId()), getCenterCoordinates());
    }
    return coordinates;
  }

  private Point getCenterCoordinates() {
    int height = getView().getHeight();
    int width = getView().getWidth();
    Point location = getLocation();
    int x = location.x + (height / 2);
    int y = location.y + (width / 2);
    return new Point(x, y);
  }

  @Override
  public int hashCode() {
    return hashCode;
  }

  @Override
  public boolean equals(Object obj) {
    if (this == obj) return true;
    if (obj == null) return false;
    if (getClass() != obj.getClass()) return false;
    AndroidNativeElement other = (AndroidNativeElement) obj;
    // Not calling getView() here so inserting into a set with stale elements doesn't suddenly start
    // throwing.
    if (viewRef.get() == null) {
      if (other.viewRef.get() != null) return false;
    } else if (!getView().equals(other.viewRef.get())) return false;
    return true;
  }

  @Override
  public Dimension getSize() {
    return new Dimension(getView().getWidth(), getView().getHeight());
  }

  @Override
  public String getAttribute(String attribute) {
    if (attribute.equalsIgnoreCase("nativeid")) {
      return getNativeId();
    }
    String name = capitalizeFirstLetter(attribute);
    Method method = getDeclaredMethod("get" + name);
    if (method == null) {
      method = getDeclaredMethod("is" + name);
      if (method == null) {
        throw new NoSuchElementAttributeException("The attribute with name '" + name
            + "' was not found.");
      }
    }
    final Object[] result = new Object[1];
    final Exception[] exception = new Exception[1];
    final Method m = method;
    instrumentation.getInstrumentation().runOnMainSync(new Runnable() {
      @Override
      public void run() {
        try {
          result[0] = m.invoke(getView());
        } catch(Exception e) {
          exception[0] = e;
        }
      }
    });
    if (exception.length == 1 && exception[0] != null) {
      throw new SelendroidException(exception[0]);
    }
    return String.valueOf(result[0]);
  }

  private String capitalizeFirstLetter(String name) {
    return name.substring(0, 1).toUpperCase() + name.substring(1);
  }

  private Method getDeclaredMethod(String name) {
    Preconditions.checkNotNull(name);

    Method method = null;
    try {
      method = getView().getClass().getMethod(name);
    } catch (NoSuchMethodException e) {
      // can happen
    }
    return method;
  }

  @Override
  public boolean isEnabled() {
    return getView().isEnabled();
  }

  @Override
  public String getTagName() {
    return getView().getClass().getSimpleName();
  }

  @Override
  public void setText(CharSequence... keysToSend) {
    requestFocus();
    final View viewview = getView();
    StringBuilder sb = new StringBuilder();
    for (CharSequence keys : keysToSend) {
      sb.append(keys);
    }
    final String text = getText() + sb;
    instrumentation.getInstrumentation().runOnMainSync(new Runnable() {
      @Override
      public void run() {
        ((EditText) viewview).setText(text);
      }
    });
  }

  @Override
  public String id() {
    return this.id;
  }
}
