/*
 * JBoss, Home of Professional Open Source.
 * Copyright 2014 Red Hat, Inc., and individual contributors
 * as indicated by the @author tags.
 *
 * 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.undertow.util;

import static java.lang.Integer.signum;
import static java.lang.System.arraycopy;
import static java.util.Arrays.copyOfRange;

import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.OutputStream;
import java.io.Serializable;
import java.lang.reflect.Field;
import java.nio.ByteBuffer;

import io.undertow.UndertowMessages;

/**
 * An HTTP case-insensitive Latin-1 string.
 *
 * @author <a href="mailto:david.lloyd@redhat.com">David M. Lloyd</a>
 */
public final class HttpString implements Comparable<HttpString>, Serializable {
    private static final long serialVersionUID = 1L;

    private final byte[] bytes;
    private final transient int hashCode;
    /**
     * And integer that is only set for well known header to make
     * comparison fast
     */
    private final int orderInt;
    private transient String string;

    private static final Field hashCodeField;

    static {
        try {
            hashCodeField = HttpString.class.getDeclaredField("hashCode");
            hashCodeField.setAccessible(true);
        } catch (NoSuchFieldException e) {
            throw new NoSuchFieldError(e.getMessage());
        }
    }

    /**
     * Empty HttpString instance.
     */
    public static final HttpString EMPTY = new HttpString("");

    /**
     * Construct a new instance.
     *
     * @param bytes the byte array to copy
     */
    public HttpString(final byte[] bytes) {
        this(bytes.clone(), null);
    }

    /**
     * Construct a new instance.
     *
     * @param bytes  the byte array to copy
     * @param offset the offset into the array to start copying
     * @param length the number of bytes to copy
     */
    public HttpString(final byte[] bytes, int offset, int length) {
        this(copyOfRange(bytes, offset, offset + length), null);
    }

    /**
     * Construct a new instance by reading the remaining bytes from a buffer.
     *
     * @param buffer the buffer to read
     */
    public HttpString(final ByteBuffer buffer) {
        this(take(buffer), null);
    }

    /**
     * Construct a new instance from a {@code String}.  The {@code String} will be used
     * as the cached {@code toString()} value for this {@code HttpString}.
     *
     * @param string the source string
     */
    public HttpString(final String string) {
        this(string, 0);
    }

    HttpString(final String string, int orderInt) {
        this.orderInt = orderInt;
        this.bytes = toByteArray(string);
        this.hashCode = calcHashCode(bytes);
        this.string = string;
        checkForNewlines();
    }

    private static byte[] toByteArray(final String string) {
        final int len = string.length();
        final byte[] bytes = new byte[len];
        for (int i = 0; i < len; i++) {
            char c = string.charAt(i);
            if (c > 0xff) {
                throw new IllegalArgumentException("Invalid string contents " + string);
            }
            bytes[i] = (byte) c;
        }
        return bytes;
    }

    private void checkForNewlines() {
        for(byte b : bytes) {
            if(b == '\r' || b == '\n') {
                throw UndertowMessages.MESSAGES.newlineNotSupportedInHttpString(string);
            }
        }
    }

    private HttpString(final byte[] bytes, final String string) {
        this.bytes = bytes;
        this.hashCode = calcHashCode(bytes);
        this.string = string;
        this.orderInt = 0;
        checkForNewlines();
    }

    /**
     * Attempt to convert a {@code String} to an {@code HttpString}.  If the string cannot be converted,
     * {@code null} is returned.
     *
     * @param string the string to try
     * @return the HTTP string, or {@code null} if the string is not in a compatible encoding
     */
    public static HttpString tryFromString(String string) {
        HttpString cached = Headers.fromCache(string);
        if(cached != null) {
            return cached;
        }
        final int len = string.length();
        final byte[] bytes = new byte[len];
        for (int i = 0; i < len; i++) {
            char c = string.charAt(i);
            if (c > 0xff) {
                return null;
            }
            bytes[i] = (byte) c;
        }
        return new HttpString(bytes, string);
    }

    /**
     * Get the string length.
     *
     * @return the string length
     */
    public int length() {
        return bytes.length;
    }

    /**
     * Get the byte at an index.
     *
     * @return the byte at an index
     */
    public byte byteAt(int idx) {
        return bytes[idx];
    }

    /**
     * Copy {@code len} bytes from this string at offset {@code srcOffs} to the given array at the given offset.
     *
     * @param srcOffs the source offset
     * @param dst     the destination
     * @param offs    the destination offset
     * @param len     the number of bytes to copy
     */
    public void copyTo(int srcOffs, byte[] dst, int offs, int len) {
        arraycopy(bytes, srcOffs, dst, offs, len);
    }

    /**
     * Copy {@code len} bytes from this string to the given array at the given offset.
     *
     * @param dst  the destination
     * @param offs the destination offset
     * @param len  the number of bytes
     */
    public void copyTo(byte[] dst, int offs, int len) {
        copyTo(0, dst, offs, len);
    }

    /**
     * Copy all the bytes from this string to the given array at the given offset.
     *
     * @param dst  the destination
     * @param offs the destination offset
     */
    public void copyTo(byte[] dst, int offs) {
        copyTo(dst, offs, bytes.length);
    }

    /**
     * Append to a byte buffer.
     *
     * @param buffer the buffer to append to
     */
    public void appendTo(ByteBuffer buffer) {
        buffer.put(bytes);
    }

    /**
     * Append to an output stream.
     *
     * @param output the stream to write to
     * @throws IOException if an error occurs
     */
    public void writeTo(OutputStream output) throws IOException {
        output.write(bytes);
    }

    private static byte[] take(final ByteBuffer buffer) {
        if (buffer.hasArray()) {
            // avoid useless array clearing
            try {
                return copyOfRange(buffer.array(), buffer.arrayOffset() + buffer.position(), buffer.remaining());
            } finally {
                buffer.position(buffer.limit());
            }
        } else {
            final byte[] bytes = new byte[buffer.remaining()];
            buffer.get(bytes);
            return bytes;
        }
    }

    /**
     * Compare this string to another in a case-insensitive manner.
     *
     * @param other the other string
     * @return -1, 0, or 1
     */
    public int compareTo(final HttpString other) {
        if(orderInt != 0 && other.orderInt != 0) {
            return signum(orderInt - other.orderInt);
        }
        final int len = Math.min(bytes.length, other.bytes.length);
        int res;
        for (int i = 0; i < len; i++) {
            res = signum(higher(bytes[i]) - higher(other.bytes[i]));
            if (res != 0) return res;
        }
        // shorter strings sort higher
        return signum(bytes.length - other.bytes.length);
    }

    /**
     * Get the hash code.
     *
     * @return the hash code
     */
    @Override
    public int hashCode() {
        return hashCode;
    }

    /**
     * Determine if this {@code HttpString} is equal to another.
     *
     * @param other the other object
     * @return {@code true} if they are equal, {@code false} otherwise
     */
    @Override
    public boolean equals(final Object other) {
        if(other == this) {
            return true;
        }
        if(!(other instanceof HttpString)) {
            return false;
        }
        HttpString otherString = (HttpString) other;
        if(orderInt > 0 && otherString.orderInt > 0) {
            //if the order int is set for both of them and different then we know they are different strings
            return false;
        }
        return bytesAreEqual(bytes, otherString.bytes);
    }

    /**
     * Determine if this {@code HttpString} is equal to another.
     *
     * @param other the other object
     * @return {@code true} if they are equal, {@code false} otherwise
     */
    public boolean equals(final HttpString other) {
        return other == this || other != null && bytesAreEqual(bytes, other.bytes);
    }

    private static int calcHashCode(final byte[] bytes) {
        int hc = 17;
        for (byte b : bytes) {
            hc = (hc << 4) + hc + higher(b);
        }
        return hc;
    }

    private static int higher(byte b) {
        return b & (b >= 'a' && b <= 'z' ? 0xDF : 0xFF);
    }

    private static boolean bytesAreEqual(final byte[] a, final byte[] b) {
        return a.length == b.length && bytesAreEquivalent(a, b);
    }

    private static boolean bytesAreEquivalent(final byte[] a, final byte[] b) {
        assert a.length == b.length;
        final int len = a.length;
        for (int i = 0; i < len; i++) {
            if (higher(a[i]) != higher(b[i])) {
                return false;
            }
        }
        return true;
    }

    /**
     * Get the {@code String} representation of this {@code HttpString}.
     *
     * @return the string
     */
    @Override
    public String toString() {
        if (string == null) {
            string = new String(bytes, java.nio.charset.StandardCharsets.US_ASCII);
        }
        return string;
    }

    private void readObject(ObjectInputStream ois) throws ClassNotFoundException, IOException {
        ois.defaultReadObject();
        try {
            hashCodeField.setInt(this, calcHashCode(bytes));
        } catch (IllegalAccessException e) {
            throw new IllegalAccessError(e.getMessage());
        }
    }

    static int hashCodeOf(final String headerName) {
        final byte[] bytes = toByteArray(headerName);
        return calcHashCode(bytes);
    }

    public boolean equalToString(String headerName) {
        if(headerName.length() != bytes.length) {
            return false;
        }

        final int len = bytes.length;
        for (int i = 0; i < len; i++) {
            if (higher(bytes[i]) != higher((byte)headerName.charAt(i))) {
                return false;
            }
        }
        return true;
    }
}
