/*
 * Copyright 2008 Google Inc.
 *
 * 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 com.google.gwt.user.client.ui;

import com.google.gwt.dom.client.AnchorElement;
import com.google.gwt.dom.client.Document;
import com.google.gwt.dom.client.Element;
import com.google.gwt.dom.client.NativeEvent;
import com.google.gwt.event.logical.shared.ValueChangeEvent;
import com.google.gwt.event.logical.shared.ValueChangeHandler;
import com.google.gwt.event.shared.HandlerRegistration;
import com.google.gwt.junit.DoNotRunWith;
import com.google.gwt.junit.Platform;
import com.google.gwt.junit.client.GWTTestCase;
import com.google.gwt.user.client.History;
import com.google.gwt.user.client.Timer;
import com.google.gwt.user.client.Window;

import java.util.ArrayList;

/**
 * Tests for the history system.
 *
 * TODO: find a way to test unescaping of the initial hash value.
 */
public class HistoryTest extends GWTTestCase {

  private static String getCurrentLocationHash() {
    // Firefox automatically decodes location.hash so parse it from Window.Location.getHref
    String href = Window.Location.getHref();
    String[] split = href.split("#");
    if (split.length != 2) {
      fail("can not read history token");
    }
    return split[1];
  }

  private HandlerRegistration handlerRegistration;
  private Timer timer;

  @Override
  public String getModuleName() {
    return "com.google.gwt.user.User";
  }

  protected String getHistoryToken2() {
    return "token 2";
  }

  protected String getHistoryToken2_encoded() {
    return "token%202";
  }

  // TODO(dankurka): Fix up HTML unit hash change handling
  @DoNotRunWith(Platform.HtmlUnitUnknown)
  public void testClickLink() {
    AnchorElement anchorElement = Document.get().createAnchorElement();
    anchorElement.setHref("#href1");
    Document.get().getBody().appendChild(anchorElement);

    try {
      History.newItem("something_as_base");

      addHistoryListenerImpl(new ValueChangeHandler<String>() {

        @Override
        public void onValueChange(ValueChangeEvent<String> event) {
          assertEquals("href1", event.getValue());
          finishTest();
        }
      });

      delayTestFinish(5000);

      NativeEvent clickEvent =
          Document.get().createClickEvent(0, 0, 0, 0, 0, false, false, false, false);

      anchorElement.dispatchEvent(clickEvent);

    } finally {
      Document.get().getBody().removeChild(anchorElement);
    }
  }

  /* Tests against issue #572: Double unescaping of history tokens. */
  public void testDoubleEscaping() {
    final String escToken = "%24%24%24";

    delayTestFinish(5000);
    addHistoryListenerImpl(new ValueChangeHandler<String>() {

      @Override
      public void onValueChange(ValueChangeEvent<String> event) {
        assertEquals(escToken, event.getValue());
        finishTest();
      }
    });
    History.newItem(escToken);
  }

  /*
   * Tests against issue #879: Ensure that empty history tokens do not add
   * additional characters after the '#' symbol in the URL.
   */
  public void testEmptyHistoryTokens() {
    delayTestFinish(5000);

    addHistoryListenerImpl(new ValueChangeHandler<String>() {

      @Override
      public void onValueChange(ValueChangeEvent<String> event) {
        String historyToken = event.getValue();
        if (historyToken == null) {
          fail("historyToken should not be null");
        }

        if (historyToken.equals("foobar")) {
          History.newItem("");
        } else {
          assertEquals("", historyToken);
          finishTest();
        }
      }
    });

    // We must first start out with a non-blank history token. Adding a blank
    // history token in the initial state will not cause an onHistoryChanged
    // event to fire.
    History.newItem("foobar");
  }

  /**
   * Verify that no events are issued via newItem if there were not reqeuested.
   */
  public void testNoEvents() {
    delayTestFinish(5000);

    addHistoryListenerImpl(new ValueChangeHandler<String>() {

      @Override
      public void onValueChange(ValueChangeEvent<String> event) {
        fail("onHistoryChanged should not have been called");
      }
    });

    History.newItem("testNoEvents", false);

    timer = new Timer() {
      @Override
      public void run() {
        finishTest();
      }
    };
    timer.schedule(500);
  }

  /*
   * Ensure that non-url-safe strings (such as those containing spaces) are
   * encoded/decoded correctly, and that programmatic 'back' works.
   */
  @DoNotRunWith(Platform.HtmlUnitUnknown)
  public void testHistory() {
    /*
     * Sentinel token which should only be seen if tokens are lost during the
     * rest of the test. Without this, History.back() might send the browser too
     * far back, i.e. back to before the web app containing our test module.
     */
    History.newItem("if-you-see-this-then-history-went-back-too-far");

    final String historyToken1 = "token1";
    final String historyToken2 = getHistoryToken2();
    delayTestFinish(10000);

    addHistoryListenerImpl(new ValueChangeHandler<String>() {

      private int state = 0;

      @Override
      public void onValueChange(ValueChangeEvent<String> event) {
        String historyToken = event.getValue();
        switch (state) {
          case 0: {
            if (!historyToken.equals(historyToken1)) {
              fail("Expecting token '" + historyToken1 + "', but got: " + historyToken);
            }

            state = 1;
            History.newItem(historyToken2);
            break;
          }

          case 1: {
            if (!historyToken.equals(historyToken2)) {
              fail("Expecting token '" + historyToken2 + "', but got: " + historyToken);
            }

            state = 2;
            History.back();
            break;
          }

          case 2: {
            if (!historyToken.equals(historyToken1)) {
              fail("Expecting token '" + historyToken1 + "', but got: " + historyToken);
            }
            finishTest();
            break;
          }
        }
      }
    });

    History.newItem(historyToken1);
  }

  /**
   * Verify that {@link ValueChangeHandler#onValueChange(ValueChangeEvent)}
   * is only called once per {@link History#newItem(String)}.
   */
  public void testHistoryChangedCount() {
    delayTestFinish(5000);
    timer = new Timer() {
      private int count = 0;

      @Override
      public void run() {
        if (count++ == 0) {
          // verify that duplicates don't issue another event
          History.newItem("testHistoryChangedCount");
          timer.schedule(500);
        } else {
          finishTest();
        }
      }
    };
    addHistoryListenerImpl(new ValueChangeHandler<String>() {
      private int count = 0;

      @Override
      public void onValueChange(ValueChangeEvent<String> event) {
        if (count++ != 0) {
          fail("onHistoryChanged called multiple times");
        }
        // wait 500ms to see if we get called multiple times
        timer.schedule(500);
      }
    });

    History.newItem("testHistoryChangedCount");
  }

  @DoNotRunWith(Platform.HtmlUnitUnknown)
  public void testReplaceItem() {
    /*
     * Sentinel token which should only be seen if tokens are lost during the rest of the test.
     * Without this, History.back() might send the browser too far back, i.e. back to before the web
     * app containing our test module.
     */
    History.newItem("if-you-see-this-then-history-went-back-too-far");

    final String historyToken1 = "token1";
    final String historyToken2 = getHistoryToken2();
    final String historyToken3 = "token3";

    delayTestFinish(10000);

    addHistoryListenerImpl(new ValueChangeHandler<String>() {

      private int state = 0;

      @Override
      public void onValueChange(ValueChangeEvent<String> event) {
        String historyToken = event.getValue();
        switch (state) {
          case 0: {
            if (!historyToken.equals(historyToken1)) {
              fail("Expecting token '" + historyToken1 + "', but got: " + historyToken);
            }

            state = 1;
            History.newItem(historyToken2);
            break;
          }

          case 1: {
            if (!historyToken.equals(historyToken2)) {
              fail("Expecting token '" + historyToken2 + "', but got: " + historyToken);
            }

            state = 2;
            History.replaceItem(historyToken3, true);
            break;
          }

          case 2: {
            if (!historyToken.equals(historyToken3)) {
              fail("Expecting token '" + historyToken3 + "', but got: " + historyToken);
            }
            state = 3;
            History.back();
            break;
          }

          case 3: {
            if (!historyToken.equals(historyToken1)) {
              fail("Expecting token '" + historyToken1 + "', but got: " + historyToken);
            }
            finishTest();
          }
        }
      }
    });

    History.newItem(historyToken1);
  }

  /*
   * HtmlUnit tends to fire events after adding a new item with fireEvent set
   * to false. This makes 'testEmptyHistoryTokens' randomly fail after running
   * this test.
   */
  @DoNotRunWith(Platform.HtmlUnitBug)
  public void testReplaceItemNoEvent() {
    /*
     * Sentinel token which should only be seen if tokens are lost during the rest of the test.
     * Without this, History.back() might send the browser too far back, i.e. back to before the web
     * app containing our test module.
     */
    History.newItem("if-you-see-this-then-history-went-back-too-far");

    final String historyToken1 = "token1";
    final String historyToken2 = getHistoryToken2();
    final String historyToken2_encoded = getHistoryToken2_encoded();

    History.newItem(historyToken1);

    addHistoryListenerImpl(new ValueChangeHandler<String>() {

      @Override
      public void onValueChange(ValueChangeEvent<String> event) {
        fail("No event expected");
      }
    });

    History.replaceItem(historyToken2, false);
    assertEquals(historyToken2, History.getToken());

    delayTestFinish(500);

    timer = new Timer() {
      @Override
      public void run() {
        // Make sure that we have updated the URL properly.
        assertEquals(historyToken2_encoded, getCurrentLocationHash());
        finishTest();
      }
    };

    timer.schedule(200);
  }

  public void testTokenEscaping() {
    final String shouldBeEncoded = "% ^[]|\"<>{}\\";
    final String shouldBeEncodedAs = "%25%20%5E%5B%5D%7C%22%3C%3E%7B%7D%5C";

    delayTestFinish(5000);
    addHistoryListenerImpl(new ValueChangeHandler<String>() {

      @Override
      public void onValueChange(ValueChangeEvent<String> event) {
        assertEquals(shouldBeEncodedAs, getCurrentLocationHash());
        assertEquals(shouldBeEncoded, event.getValue());
        finishTest();
      }
    });
    History.newItem(shouldBeEncoded);
  }

  /**
   * Test to make sure that there is no double unescaping of hash values.
   * See https://bugzilla.mozilla.org/show_bug.cgi?id=483304
   */
  @DoNotRunWith(Platform.HtmlUnitUnknown)
  public void testNoDoubleTokenUnEscaping() {
    final String shouldBeEncoded = "abc%20abc";

    delayTestFinish(5000);

    History.newItem(shouldBeEncoded);
    History.newItem("someOtherToken");
    History.back();
    // allow browser to update the url
    timer = new Timer() {
      @Override
      public void run() {
        // make sure that value in url actually matches the original token
        assertEquals(shouldBeEncoded, History.getToken());
        finishTest();
      }
    };
    timer.schedule(200);
  }

  public void testTokenNonescaping() {
    final String shouldNotChange = "abc;,/?:@&=+$-_.!~*()ABC123foo";

    delayTestFinish(5000);
    addHistoryListenerImpl(new ValueChangeHandler<String>() {

      @Override
      public void onValueChange(ValueChangeEvent<String> event) {
        assertEquals(shouldNotChange, event.getValue());
        finishTest();
      }
    });

    History.newItem(shouldNotChange);
  }

  /*
   * Test against issue #2500. IE6 has a bug that causes it to not report any
   * part of the current fragment after a '?' when read from location.hash; make
   * sure that on affected browsers, we're not relying on this.
   */
  public void testTokenWithQuestionmark() {
    delayTestFinish(5000);
    final String token = "foo?bar";

    addHistoryListenerImpl(new ValueChangeHandler<String>() {

      @Override
      public void onValueChange(ValueChangeEvent<String> event) {
        String historyToken = event.getValue();
        if (historyToken == null) {
          fail("historyToken should not be null");
        }
        assertEquals(token, historyToken);
        finishTest();
      }
    });

    History.newItem(token);
  }

  /**
   * Test that using an empty history token works properly. There have been
   * problems (see issue 2905) with this in the past on Safari.
   * <p>
   * Seems like a HtmlUnit bug. Need more investigation.
   */
  @DoNotRunWith(Platform.HtmlUnitBug)
  public void testEmptyHistoryToken() {
    final ArrayList<Object> counter = new ArrayList<Object>();

    addHistoryListenerImpl(new ValueChangeHandler<String>() {

      @Override
      public void onValueChange(ValueChangeEvent<String> event) {
        counter.add(new Object());
        assertFalse("Browser is borked by empty history token", isBorked());
      }
    });

    History.newItem("x");
    History.newItem("");

    assertEquals("Expected two history events", 2, counter.size());
  }

  // Used by testEmptyHistoryToken() to catch a bizarre failure mode on Safari.
  private static boolean isBorked() {
    Element e = Document.get().createDivElement();
    e.setInnerHTML("string");
    return e.getInnerHTML().length() == 0;
  }

  @Override
  protected void gwtTearDown() throws Exception {
    if (handlerRegistration != null) {
      handlerRegistration.removeHandler();
      handlerRegistration = null;
    }
    if (timer != null) {
      timer.cancel();
      timer = null;
    }
  }

  private void addHistoryListenerImpl(ValueChangeHandler<String> handler) {
    this.handlerRegistration = History.addValueChangeHandler(handler);
  }

  private native void click(Element el) /*-{
   el.click();
  }-*/;
}
