/*
 * Copyright (C) 2015 Square, 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.squareup.okhttp;

import java.net.URI;
import java.net.URL;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import okio.Buffer;
import okio.ByteString;

import static org.junit.Assert.fail;

/** Tests how each code point is encoded and decoded in the context of each URL component. */
class UrlComponentEncodingTester {
  private static final int UNICODE_2 = 0x07ff; // Arbitrary code point that's 2 bytes in UTF-8.
  private static final int UNICODE_3 = 0xffff; // Arbitrary code point that's 3 bytes in UTF-8.
  private static final int UNICODE_4 = 0x10ffff; // Arbitrary code point that's 4 bytes in UTF-8.

  /**
   * The default encode set for the ASCII range. The specific rules vary per-component: for example,
   * '?' may be identity-encoded in a fragment, but must be percent-encoded in a path.
   *
   * See https://url.spec.whatwg.org/#percent-encoded-bytes
   */
  private static final Map<Integer, Encoding> defaultEncodings;
  static {
    Map<Integer, Encoding> map = new LinkedHashMap<>();
    map.put(       0x0, Encoding.PERCENT); // Null character
    map.put(       0x1, Encoding.PERCENT); // Start of Header
    map.put(       0x2, Encoding.PERCENT); // Start of Text
    map.put(       0x3, Encoding.PERCENT); // End of Text
    map.put(       0x4, Encoding.PERCENT); // End of Transmission
    map.put(       0x5, Encoding.PERCENT); // Enquiry
    map.put(       0x6, Encoding.PERCENT); // Acknowledgment
    map.put(       0x7, Encoding.PERCENT); // Bell
    map.put((int) '\b', Encoding.PERCENT); // Backspace
    map.put((int) '\t', Encoding.SKIP);    // Horizontal Tab
    map.put((int) '\n', Encoding.SKIP);    // Line feed
    map.put(       0xb, Encoding.PERCENT); // Vertical Tab
    map.put((int) '\f', Encoding.SKIP);    // Form feed
    map.put((int) '\r', Encoding.SKIP);    // Carriage return
    map.put(       0xe, Encoding.PERCENT); // Shift Out
    map.put(       0xf, Encoding.PERCENT); // Shift In
    map.put(      0x10, Encoding.PERCENT); // Data Link Escape
    map.put(      0x11, Encoding.PERCENT); // Device Control 1 (oft. XON)
    map.put(      0x12, Encoding.PERCENT); // Device Control 2
    map.put(      0x13, Encoding.PERCENT); // Device Control 3 (oft. XOFF)
    map.put(      0x14, Encoding.PERCENT); // Device Control 4
    map.put(      0x15, Encoding.PERCENT); // Negative Acknowledgment
    map.put(      0x16, Encoding.PERCENT); // Synchronous idle
    map.put(      0x17, Encoding.PERCENT); // End of Transmission Block
    map.put(      0x18, Encoding.PERCENT); // Cancel
    map.put(      0x19, Encoding.PERCENT); // End of Medium
    map.put(      0x1a, Encoding.PERCENT); // Substitute
    map.put(      0x1b, Encoding.PERCENT); // Escape
    map.put(      0x1c, Encoding.PERCENT); // File Separator
    map.put(      0x1d, Encoding.PERCENT); // Group Separator
    map.put(      0x1e, Encoding.PERCENT); // Record Separator
    map.put(      0x1f, Encoding.PERCENT); // Unit Separator
    map.put((int)  ' ', Encoding.PERCENT);
    map.put((int)  '!', Encoding.IDENTITY);
    map.put((int)  '"', Encoding.PERCENT);
    map.put((int)  '#', Encoding.PERCENT);
    map.put((int)  '$', Encoding.IDENTITY);
    map.put((int)  '%', Encoding.IDENTITY);
    map.put((int)  '&', Encoding.IDENTITY);
    map.put((int) '\'', Encoding.IDENTITY);
    map.put((int)  '(', Encoding.IDENTITY);
    map.put((int)  ')', Encoding.IDENTITY);
    map.put((int)  '*', Encoding.IDENTITY);
    map.put((int)  '+', Encoding.IDENTITY);
    map.put((int)  ',', Encoding.IDENTITY);
    map.put((int)  '-', Encoding.IDENTITY);
    map.put((int)  '.', Encoding.IDENTITY);
    map.put((int)  '/', Encoding.IDENTITY);
    map.put((int)  '0', Encoding.IDENTITY);
    map.put((int)  '1', Encoding.IDENTITY);
    map.put((int)  '2', Encoding.IDENTITY);
    map.put((int)  '3', Encoding.IDENTITY);
    map.put((int)  '4', Encoding.IDENTITY);
    map.put((int)  '5', Encoding.IDENTITY);
    map.put((int)  '6', Encoding.IDENTITY);
    map.put((int)  '7', Encoding.IDENTITY);
    map.put((int)  '8', Encoding.IDENTITY);
    map.put((int)  '9', Encoding.IDENTITY);
    map.put((int)  ':', Encoding.IDENTITY);
    map.put((int)  ';', Encoding.IDENTITY);
    map.put((int)  '<', Encoding.PERCENT);
    map.put((int)  '=', Encoding.IDENTITY);
    map.put((int)  '>', Encoding.PERCENT);
    map.put((int)  '?', Encoding.PERCENT);
    map.put((int)  '@', Encoding.IDENTITY);
    map.put((int)  'A', Encoding.IDENTITY);
    map.put((int)  'B', Encoding.IDENTITY);
    map.put((int)  'C', Encoding.IDENTITY);
    map.put((int)  'D', Encoding.IDENTITY);
    map.put((int)  'E', Encoding.IDENTITY);
    map.put((int)  'F', Encoding.IDENTITY);
    map.put((int)  'G', Encoding.IDENTITY);
    map.put((int)  'H', Encoding.IDENTITY);
    map.put((int)  'I', Encoding.IDENTITY);
    map.put((int)  'J', Encoding.IDENTITY);
    map.put((int)  'K', Encoding.IDENTITY);
    map.put((int)  'L', Encoding.IDENTITY);
    map.put((int)  'M', Encoding.IDENTITY);
    map.put((int)  'N', Encoding.IDENTITY);
    map.put((int)  'O', Encoding.IDENTITY);
    map.put((int)  'P', Encoding.IDENTITY);
    map.put((int)  'Q', Encoding.IDENTITY);
    map.put((int)  'R', Encoding.IDENTITY);
    map.put((int)  'S', Encoding.IDENTITY);
    map.put((int)  'T', Encoding.IDENTITY);
    map.put((int)  'U', Encoding.IDENTITY);
    map.put((int)  'V', Encoding.IDENTITY);
    map.put((int)  'W', Encoding.IDENTITY);
    map.put((int)  'X', Encoding.IDENTITY);
    map.put((int)  'Y', Encoding.IDENTITY);
    map.put((int)  'Z', Encoding.IDENTITY);
    map.put((int)  '[', Encoding.IDENTITY);
    map.put((int) '\\', Encoding.IDENTITY);
    map.put((int)  ']', Encoding.IDENTITY);
    map.put((int)  '^', Encoding.IDENTITY);
    map.put((int)  '_', Encoding.IDENTITY);
    map.put((int)  '`', Encoding.PERCENT);
    map.put((int)  'a', Encoding.IDENTITY);
    map.put((int)  'b', Encoding.IDENTITY);
    map.put((int)  'c', Encoding.IDENTITY);
    map.put((int)  'd', Encoding.IDENTITY);
    map.put((int)  'e', Encoding.IDENTITY);
    map.put((int)  'f', Encoding.IDENTITY);
    map.put((int)  'g', Encoding.IDENTITY);
    map.put((int)  'h', Encoding.IDENTITY);
    map.put((int)  'i', Encoding.IDENTITY);
    map.put((int)  'j', Encoding.IDENTITY);
    map.put((int)  'k', Encoding.IDENTITY);
    map.put((int)  'l', Encoding.IDENTITY);
    map.put((int)  'm', Encoding.IDENTITY);
    map.put((int)  'n', Encoding.IDENTITY);
    map.put((int)  'o', Encoding.IDENTITY);
    map.put((int)  'p', Encoding.IDENTITY);
    map.put((int)  'q', Encoding.IDENTITY);
    map.put((int)  'r', Encoding.IDENTITY);
    map.put((int)  's', Encoding.IDENTITY);
    map.put((int)  't', Encoding.IDENTITY);
    map.put((int)  'u', Encoding.IDENTITY);
    map.put((int)  'v', Encoding.IDENTITY);
    map.put((int)  'w', Encoding.IDENTITY);
    map.put((int)  'x', Encoding.IDENTITY);
    map.put((int)  'y', Encoding.IDENTITY);
    map.put((int)  'z', Encoding.IDENTITY);
    map.put((int)  '{', Encoding.IDENTITY);
    map.put((int)  '|', Encoding.IDENTITY);
    map.put((int)  '}', Encoding.IDENTITY);
    map.put((int)  '~', Encoding.IDENTITY);
    map.put(      0x7f, Encoding.PERCENT); // Delete
    map.put( UNICODE_2, Encoding.PERCENT);
    map.put( UNICODE_3, Encoding.PERCENT);
    map.put( UNICODE_4, Encoding.PERCENT);
    defaultEncodings = Collections.unmodifiableMap(map);
  }

  private final Map<Integer, Encoding> encodings;
  private final StringBuilder uriEscapedCodePoints = new StringBuilder();

  public UrlComponentEncodingTester() {
    this.encodings = new LinkedHashMap<>(defaultEncodings);
  }

  public UrlComponentEncodingTester override(Encoding encoding, int... codePoints) {
    for (int codePoint : codePoints) {
      encodings.put(codePoint, encoding);
    }
    return this;
  }

  public UrlComponentEncodingTester identityForNonAscii() {
    encodings.put(UNICODE_2, Encoding.IDENTITY);
    encodings.put(UNICODE_3, Encoding.IDENTITY);
    encodings.put(UNICODE_4, Encoding.IDENTITY);
    return this;
  }

  /**
   * Configure a character to be skipped but only for conversion to and from {@code java.net.URI}.
   * That class is more strict than the others.
   */
  public UrlComponentEncodingTester skipForUri(int... codePoints) {
    uriEscapedCodePoints.append(new String(codePoints, 0, codePoints.length));
    return this;
  }

  public UrlComponentEncodingTester test(Component component) {
    for (Map.Entry<Integer, Encoding> entry : encodings.entrySet()) {
      Encoding encoding = entry.getValue();
      int codePoint = entry.getKey();
      testEncodeAndDecode(codePoint, component);
      if (encoding == Encoding.SKIP) continue;

      testParseOriginal(codePoint, encoding, component);
      testParseAlreadyEncoded(codePoint, encoding, component);
      testToUrl(codePoint, encoding, component);
      testFromUrl(codePoint, encoding, component);

      if (codePoint != '%') {
        boolean uriEscaped = uriEscapedCodePoints.indexOf(
            Encoding.IDENTITY.encode(codePoint)) != -1;
        testUri(codePoint, encoding, component, uriEscaped);
      }
    }
    return this;
  }

  private void testParseAlreadyEncoded(int codePoint, Encoding encoding, Component component) {
    String encoded = encoding.encode(codePoint);
    String urlString = component.urlString(encoded);
    HttpUrl url = HttpUrl.parse(urlString);
    if (!component.encodedValue(url).equals(encoded)) {
      fail(String.format("Encoding %s %#x using %s", component, codePoint, encoding));
    }
  }

  private void testEncodeAndDecode(int codePoint, Component component) {
    String expected = Encoding.IDENTITY.encode(codePoint);
    HttpUrl.Builder builder = HttpUrl.parse("http://host/").newBuilder();
    component.set(builder, expected);
    HttpUrl url = builder.build();
    String actual = component.get(url);
    if (!expected.equals(actual)) {
      fail(String.format("Roundtrip %s %#x %s", component, codePoint, url));
    }
  }

  private void testParseOriginal(int codePoint, Encoding encoding, Component component) {
    String encoded = encoding.encode(codePoint);
    if (encoding != Encoding.PERCENT) return;
    String identity = Encoding.IDENTITY.encode(codePoint);
    String urlString = component.urlString(identity);
    HttpUrl url = HttpUrl.parse(urlString);

    String s = component.encodedValue(url);
    if (!s.equals(encoded)) {
      fail(String.format("Encoding %s %#02x using %s", component, codePoint, encoding));
    }
  }

  private void testToUrl(int codePoint, Encoding encoding, Component component) {
    String encoded = encoding.encode(codePoint);
    HttpUrl httpUrl = HttpUrl.parse(component.urlString(encoded));
    URL javaNetUrl = httpUrl.url();
    if (!javaNetUrl.toString().equals(javaNetUrl.toString())) {
      fail(String.format("Encoding %s %#x using %s", component, codePoint, encoding));
    }
  }

  private void testFromUrl(int codePoint, Encoding encoding, Component component) {
    String encoded = encoding.encode(codePoint);
    HttpUrl httpUrl = HttpUrl.parse(component.urlString(encoded));
    HttpUrl toAndFromJavaNetUrl = HttpUrl.get(httpUrl.url());
    if (!toAndFromJavaNetUrl.equals(httpUrl)) {
      fail(String.format("Encoding %s %#x using %s", component, codePoint, encoding));
    }
  }

  private void testUri(
      int codePoint, Encoding encoding, Component component, boolean uriEscaped) {
    String string = new String(new int[] { codePoint }, 0, 1);
    String encoded = encoding.encode(codePoint);
    HttpUrl httpUrl = HttpUrl.parse(component.urlString(encoded));
    URI uri = httpUrl.uri();
    HttpUrl toAndFromUri = HttpUrl.get(uri);
    if (uriEscaped) {
      // The URI has more escaping than the HttpURL. Check that the decoded values still match.
      if (uri.toString().equals(httpUrl.toString())) {
        fail(String.format("Encoding %s %#x using %s", component, codePoint, encoding));
      }
      if (!component.get(toAndFromUri).equals(string)) {
        fail(String.format("Encoding %s %#x using %s", component, codePoint, encoding));
      }
    } else {
      // Check that the URI and HttpURL have the exact same escaping.
      if (!toAndFromUri.equals(httpUrl)) {
        fail(String.format("Encoding %s %#x using %s", component, codePoint, encoding));
      }
      if (!uri.toString().equals(httpUrl.toString())) {
        fail(String.format("Encoding %s %#x using %s", component, codePoint, encoding));
      }
    }
  }

  public enum Encoding {
    IDENTITY {
      public String encode(int codePoint) {
        return new String(new int[] { codePoint }, 0, 1);
      }
    },

    PERCENT {
      public String encode(int codePoint) {
        ByteString utf8 = ByteString.encodeUtf8(IDENTITY.encode(codePoint));
        Buffer percentEncoded = new Buffer();
        for (int i = 0; i < utf8.size(); i++) {
          percentEncoded.writeUtf8(String.format("%%%02X", utf8.getByte(i) & 0xff));
        }
        return percentEncoded.readUtf8();
      }
    },

    SKIP;

    public String encode(int codePoint) {
      throw new UnsupportedOperationException();
    }
  }

  public enum Component {
    USER {
      @Override public String urlString(String value) {
        return "http://" + value + "@example.com/";
      }
      @Override public String encodedValue(HttpUrl url) {
        return url.encodedUsername();
      }
      @Override public void set(HttpUrl.Builder builder, String value) {
        builder.username(value);
      }
      @Override public String get(HttpUrl url) {
        return url.username();
      }
    },
    PASSWORD {
      @Override public String urlString(String value) {
        return "http://:" + value + "@example.com/";
      }
      @Override public String encodedValue(HttpUrl url) {
        return url.encodedPassword();
      }
      @Override public void set(HttpUrl.Builder builder, String value) {
        builder.password(value);
      }
      @Override public String get(HttpUrl url) {
        return url.password();
      }
    },
    PATH {
      @Override public String urlString(String value) {
        return "http://example.com/a" + value + "z/";
      }
      @Override public String encodedValue(HttpUrl url) {
        String path = url.encodedPath();
        return path.substring(2, path.length() - 2);
      }
      @Override public void set(HttpUrl.Builder builder, String value) {
        builder.addPathSegment("a" + value + "z");
      }
      @Override public String get(HttpUrl url) {
        String pathSegment = url.pathSegments().get(0);
        return pathSegment.substring(1, pathSegment.length() - 1);
      }
    },
    QUERY {
      @Override public String urlString(String value) {
        return "http://example.com/?a" + value + "z";
      }
      @Override public String encodedValue(HttpUrl url) {
        String query = url.encodedQuery();
        return query.substring(1, query.length() - 1);
      }
      @Override public void set(HttpUrl.Builder builder, String value) {
        builder.query("a" + value + "z");
      }
      @Override public String get(HttpUrl url) {
        String query = url.query();
        return query.substring(1, query.length() - 1);
      }
    },
    FRAGMENT {
      @Override public String urlString(String value) {
        return "http://example.com/#a" + value + "z";
      }
      @Override public String encodedValue(HttpUrl url) {
        String fragment = url.encodedFragment();
        return fragment.substring(1, fragment.length() - 1);
      }
      @Override public void set(HttpUrl.Builder builder, String value) {
        builder.fragment("a" + value + "z");
      }
      @Override public String get(HttpUrl url) {
        String fragment = url.fragment();
        return fragment.substring(1, fragment.length() - 1);
      }
    };

    public abstract String urlString(String value);

    public abstract String encodedValue(HttpUrl url);

    public abstract void set(HttpUrl.Builder builder, String value);

    public abstract String get(HttpUrl url);
  }
}
