package org.bouncycastle.est;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;

import org.bouncycastle.asn1.nist.NISTObjectIdentifiers;
import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
import org.bouncycastle.operator.DefaultDigestAlgorithmIdentifierFinder;
import org.bouncycastle.operator.DigestAlgorithmIdentifierFinder;
import org.bouncycastle.operator.DigestCalculator;
import org.bouncycastle.operator.DigestCalculatorProvider;
import org.bouncycastle.operator.OperatorCreationException;
import org.bouncycastle.util.Arrays;
import org.bouncycastle.util.Strings;
import org.bouncycastle.util.encoders.Base64;
import org.bouncycastle.util.encoders.Hex;

/**
 * Provides stock implementations for basic auth and digest auth.
 */
public class HttpAuth
    implements ESTAuth
{
    private static final DigestAlgorithmIdentifierFinder digestAlgorithmIdentifierFinder = new DefaultDigestAlgorithmIdentifierFinder();

    private final String realm;
    private final String username;
    private final char[] password;
    private final SecureRandom nonceGenerator;
    private final DigestCalculatorProvider digestCalculatorProvider;

    private static final Set<String> validParts;

    static
    {
        HashSet<String> s = new HashSet<String>();
        s.add("realm");
        s.add("nonce");
        s.add("opaque");
        s.add("algorithm");
        s.add("qop");
        validParts = Collections.unmodifiableSet(s);
    }

    /**
     * Base constructor for basic auth.
     *
     * @param username user id.
     * @param password user's password.
     */
    public HttpAuth(String username, char[] password)
    {
        this(null, username, password, null, null);
    }

    /**
     * Constructor for basic auth with a specified realm.
     *
     * @param realm    expected server realm.
     * @param username user id.
     * @param password user's password.
     */
    public HttpAuth(String realm, String username, char[] password)
    {
        this(realm, username, password, null, null);
    }

    /**
     * Base constructor for digest auth. The realm will be set by
     *
     * @param username                 user id.
     * @param password                 user's password.
     * @param nonceGenerator           random source for generating nonces.
     * @param digestCalculatorProvider provider for digest calculators needed for calculating hashes.
     */
    public HttpAuth(String username, char[] password, SecureRandom nonceGenerator, DigestCalculatorProvider digestCalculatorProvider)
    {
        this(null, username, password, nonceGenerator, digestCalculatorProvider);
    }

    /**
     * Constructor for digest auth with a specified realm.
     *
     * @param realm                    expected server realm.
     * @param username                 user id.
     * @param password                 user's password.
     * @param nonceGenerator           random source for generating nonces.
     * @param digestCalculatorProvider provider for digest calculators needed for calculating hashes.
     */
    public HttpAuth(String realm, String username, char[] password, SecureRandom nonceGenerator, DigestCalculatorProvider digestCalculatorProvider)
    {
        this.realm = realm;
        this.username = username;
        this.password = password;
        this.nonceGenerator = nonceGenerator;
        this.digestCalculatorProvider = digestCalculatorProvider;
    }

    public void applyAuth(final ESTRequestBuilder reqBldr)
    {
        reqBldr.withHijacker(new ESTHijacker()
        {
            public ESTResponse hijack(ESTRequest req, Source sock)
                throws IOException
            {
                ESTResponse res = new ESTResponse(req, sock);

                if (res.getStatusCode() == 401)
                {
                    String authHeader = res.getHeader("WWW-Authenticate");
                    if (authHeader == null)
                    {
                        throw new ESTException("Status of 401 but no WWW-Authenticate header");
                    }

                    authHeader = Strings.toLowerCase(authHeader);

                    if (authHeader.startsWith("digest"))
                    {
                        res = doDigestFunction(res);
                    }
                    else if (authHeader.startsWith("basic"))
                    {
                        res.close(); // Close off the last reqBldr.

                        //
                        // Check realm field from header.
                        //
                        Map<String, String> s = HttpUtil.splitCSL("Basic", res.getHeader("WWW-Authenticate"));

                        //
                        // If no realm supplied it will not check the server realm. TODO elaborate in documentation.
                        //
                        if (realm != null)
                        {
                            if (!realm.equals(s.get("realm")))
                            {
                                // Not equal then fail.
                                throw new ESTException("Supplied realm '" + realm + "' does not match server realm '" + s.get("realm") + "'", null, 401, null);
                            }
                        }

                        //
                        // Prepare basic auth answer.
                        //
                        ESTRequestBuilder answer = new ESTRequestBuilder(req).withHijacker(null);

                        if (realm != null && realm.length() > 0)
                        {
                            answer.setHeader("WWW-Authenticate", "Basic realm=\"" + realm + "\"");
                        }
                        if (username.contains(":"))
                        {
                            throw new IllegalArgumentException("User must not contain a ':'");
                        }
                        //userPass = username + ":" + password;
                        char[]  userPass = new char[username.length() + 1 + password.length];
                        System.arraycopy(username.toCharArray(), 0, userPass, 0, username.length());
                        userPass[username.length()] = ':';
                        System.arraycopy(password, 0, userPass, username.length() + 1, password.length);

                        // -DM Base64.toBase64String
                        answer.setHeader("Authorization", "Basic " + Base64.toBase64String(Strings.toByteArray(userPass)));

                        res = req.getClient().doRequest(answer.build());

                        Arrays.fill(userPass, (char)0);
                    }
                    else
                    {
                        throw new ESTException("Unknown auth mode: " + authHeader);
                    }


                    return res;
                }
                return res;
            }
        });
    }

    private ESTResponse doDigestFunction(ESTResponse res)
        throws IOException
    {
        res.close(); // Close off the last request.
        ESTRequest req = res.getOriginalRequest();


        Map<String, String> parts = null;
        try
        {
            parts = HttpUtil.splitCSL("Digest", res.getHeader("WWW-Authenticate"));
        }
        catch (Throwable t)
        {
            throw new ESTException(
                "Parsing WWW-Authentication header: " + t.getMessage(),
                t,
                res.getStatusCode(),
                new ByteArrayInputStream(res.getHeader("WWW-Authenticate").getBytes()));
        }


        String uri = null;
        try
        {
            uri = req.getURL().toURI().getPath();
        }
        catch (Exception e)
        {
            throw new IOException("unable to process URL in request: " + e.getMessage());
        }

        for (Iterator it = parts.keySet().iterator(); it.hasNext();)
        {
            Object k = it.next();
            if (!validParts.contains(k))
            {
                throw new ESTException("Unrecognised entry in WWW-Authenticate header: '" + k + "'");
            }
        }

        String method = req.getMethod();
        String realm = parts.get("realm");
        String nonce = parts.get("nonce");
        String opaque = parts.get("opaque");
        String algorithm = parts.get("algorithm");
        String qop = parts.get("qop");


        List<String> qopMods = new ArrayList<String>(); // Preserve ordering.

        if (this.realm != null)
        {
            if (!this.realm.equals(realm))
            {
                // Not equal then fail.
                throw new ESTException("Supplied realm '" + this.realm + "' does not match server realm '" + realm + "'", null, 401, null);
            }
        }

        // If an algorithm is not specified, default to MD5.
        if (algorithm == null)
        {
            algorithm = "MD5";
        }

        if (algorithm.length() == 0)
        {
            throw new ESTException("WWW-Authenticate no algorithm defined.");
        }

        algorithm = Strings.toUpperCase(algorithm);

        if (qop != null)
        {
            if (qop.length() == 0)
            {
                throw new ESTException("QoP value is empty.");
            }

            qop = Strings.toLowerCase(qop);
            String[] s = qop.split(",");
            for (int j = 0; j != s.length; j++)
            {
                if (!s[j].equals("auth") && !s[j].equals("auth-int"))
                {
                    throw new ESTException("QoP value unknown: '" + j + "'");
                }

                String jt = s[j].trim();
                if (qopMods.contains(jt))
                {
                    continue;
                }
                qopMods.add(jt);
            }
        }
        else
        {
            throw new ESTException("Qop is not defined in WWW-Authenticate header.");
        }


        AlgorithmIdentifier digestAlg = lookupDigest(algorithm);
        if (digestAlg == null || digestAlg.getAlgorithm() == null)
        {
            throw new IOException("auth digest algorithm unknown: " + algorithm);
        }

        DigestCalculator dCalc = getDigestCalculator(algorithm, digestAlg);
        OutputStream dOut = dCalc.getOutputStream();

        String crnonce = makeNonce(10); // TODO arbitrary?

        update(dOut, username);
        update(dOut, ":");
        update(dOut, realm);
        update(dOut, ":");
        update(dOut, password);

        dOut.close();

        byte[] ha1 = dCalc.getDigest();

        if (algorithm.endsWith("-SESS"))
        {
            DigestCalculator sessCalc = getDigestCalculator(algorithm, digestAlg);
            OutputStream sessOut = sessCalc.getOutputStream();
            // -DM Hex.toHexString
            String cs = Hex.toHexString(ha1);

            update(sessOut, cs);
            update(sessOut, ":");
            update(sessOut, nonce);
            update(sessOut, ":");
            update(sessOut, crnonce);

            sessOut.close();

            ha1 = sessCalc.getDigest();
        }

        // -DM Hex.toHexString
        String hashHa1 = Hex.toHexString(ha1);

        DigestCalculator authCalc = getDigestCalculator(algorithm, digestAlg);
        OutputStream authOut = authCalc.getOutputStream();

        if (qopMods.get(0).equals("auth-int"))
        {
            DigestCalculator reqCalc = getDigestCalculator(algorithm, digestAlg);
            OutputStream reqOut = reqCalc.getOutputStream();

            req.writeData(reqOut);

            reqOut.close();

            byte[] b = reqCalc.getDigest();

            // -DM Hex.toHexString

            update(authOut, method);
            update(authOut, ":");
            update(authOut, uri);
            update(authOut, ":");
            update(authOut, Hex.toHexString(b));
        }
        else if (qopMods.get(0).equals("auth"))
        {
            update(authOut, method);
            update(authOut, ":");
            update(authOut, uri);
        }

        authOut.close();
        // -DM Hex.toHexString
        String hashHa2 = Hex.toHexString(authCalc.getDigest());

        DigestCalculator responseCalc = getDigestCalculator(algorithm, digestAlg);
        OutputStream responseOut = responseCalc.getOutputStream();

        if (qopMods.contains("missing"))
        {
            update(responseOut, hashHa1);
            update(responseOut, ":");
            update(responseOut, nonce);
            update(responseOut, ":");
            update(responseOut, hashHa2);
        }
        else
        {
            update(responseOut, hashHa1);
            update(responseOut, ":");
            update(responseOut, nonce);
            update(responseOut, ":");
            update(responseOut, "00000001");
            update(responseOut, ":");
            update(responseOut, crnonce);
            update(responseOut, ":");

            if (qopMods.get(0).equals("auth-int"))
            {
                update(responseOut, "auth-int");
            }
            else
            {
                update(responseOut, "auth");
            }

            update(responseOut, ":");
            update(responseOut, hashHa2);
        }

        responseOut.close();

        // -DM Hex.toHexString
        String digest = Hex.toHexString(responseCalc.getDigest());

        Map<String, String> hdr = new HashMap<String, String>();
        hdr.put("username", username);
        hdr.put("realm", realm);
        hdr.put("nonce", nonce);
        hdr.put("uri", uri);
        hdr.put("response", digest);
        if (qopMods.get(0).equals("auth-int"))
        {
            hdr.put("qop", "auth-int");
            hdr.put("nc", "00000001");
            hdr.put("cnonce", crnonce);
        }
        else if (qopMods.get(0).equals("auth"))
        {
            hdr.put("qop", "auth");
            hdr.put("nc", "00000001");
            hdr.put("cnonce", crnonce);
        }
        hdr.put("algorithm", algorithm);

        if (opaque == null || opaque.length() == 0)
        {
            hdr.put("opaque", makeNonce(20));
        }

        ESTRequestBuilder answer = new ESTRequestBuilder(req).withHijacker(null);

        answer.setHeader("Authorization", HttpUtil.mergeCSL("Digest", hdr));

        return req.getClient().doRequest(answer.build());
    }

    private DigestCalculator getDigestCalculator(String algorithm, AlgorithmIdentifier digestAlg)
        throws IOException
    {
        DigestCalculator dCalc;
        try
        {
            dCalc = digestCalculatorProvider.get(digestAlg);
        }
        catch (OperatorCreationException e)
        {
            throw new IOException("cannot create digest calculator for " + algorithm + ": " + e.getMessage());
        }
        return dCalc;
    }

    private AlgorithmIdentifier lookupDigest(String algorithm)
    {
        if (algorithm.endsWith("-SESS"))
        {
            algorithm = algorithm.substring(0, algorithm.length() - "-SESS".length());
        }

        if (algorithm.equals("SHA-512-256"))
        {
            return digestAlgorithmIdentifierFinder.find(NISTObjectIdentifiers.id_sha512_256);
        }

        return digestAlgorithmIdentifierFinder.find(algorithm);
    }

    private void update(OutputStream dOut, char[] value)
        throws IOException
    {
        dOut.write(Strings.toUTF8ByteArray(value));
    }

    private void update(OutputStream dOut, String value)
        throws IOException
    {
        dOut.write(Strings.toUTF8ByteArray(value));
    }

    private String makeNonce(int len)
    {
        byte[] b = new byte[len];
        nonceGenerator.nextBytes(b);
        // -DM Hex.toHexString
        return Hex.toHexString(b);
    }
}
