/*
  HTTP protocol implementation (request and response).
  $Id: HTTP.mm 24 2008-06-10 19:49:57Z l.knecht $
*/
use net, io, array

const GET="GET"
const HEAD="HEAD"
const POST="POST"

/**
  Encode a string to URI format.
  @param s the string.
  @return the URI encoded string.
*/
function encodeuri(s)
  res="";
  for i=0 to len(s)-1 do
    ch=substr(s,i,1);
    if ch>="0" and ch<="9" or
       ch>="a" and ch<="z" or
       ch>="A" and ch<="Z" or
       ch="-" or ch="_" or ch="." or ch="~" then
      res=res+ch
    elsif ch=" " then res=res+"+"
    else res=res+"%"+hexstr(code(ch,0)) end
  end;
  return res
end

/**
  Decode a string from URI format.
  @param s the URI string.
  @return the decoded string.
*/
function decodeuri(s)
  res=""; i=0;
  while i < len(s) do
    ch=substr(s,i,1);
    if ch="+" then ch=" "
    elsif ch="%" then
      if i+2 < len(s) then
        ch=char(hexnum(substr(s,i+1,2))); i+=2
      end
    end;
    res=res+ch; i++
  end;
  return res
end

/**
  Encode an array of parameters.
  @param params the parameter array (with keys). May be null.
  @return the encoded and concatenated parameters.
*/
function encodeparams(params)
  res="";
  if params#null then
    for name in keys(params) do
      if len(res)#0 then res=res+"&" end;
      res=res+encodeuri(name)+"="+encodeuri(params[name])
    end
  end;
  return res
end

class Socket
  host;
  port;
  stream;
  keepAlive; // null for no keep alive, timeout (seconds) for keep alive
  fields;
  cookies;
  at;
  chunklen;
  dumpin;
  dumpout;

  /**
    Connect this socket to a stream.
    @param stream the stream to connect to. If null, a stream is created from
    host and port.
  */
  function connect(stream=null)
    if stream=null then
      stream=net.conn(host, port)
    end;
    io.flush(stream, false);
    this.stream=stream
  end

  /**
    Create a new HTTP socket, either from a TCP/IP socket or a host/port pair.
    @param hostOrStream a host name, or a socket stream.
    @param port a port number, or null if creating from a socket.
    @param keepAlive the value for the keepAlive field. If null, a new
    connection will be created for each request. If keepAlive=null and
    port=null, only one request can be sent without re-init.
  */
  function init(hostOrStream, port=null, keepAlive=300)
    this.port=port; this.keepAlive=keepAlive;
    if port=null then
      stream=hostOrStream; host=null
    else
      host=hostOrStream; connect()
    end;
    if cookies=null then cookies=[] end
  end

  /**
    Write a string to the socket.
    @param s the string.
  */
  function write(s)
    if dumpout#null then io.write(dumpout, s) end;
    io.write(stream, s)
  end

  /**
    Write a string and a CRLF pair to the socket.
    @param s the string.
  */
  function writeln(s)
    if dumpout#null then io.writeln(dumpout, s) end;
    io.writeln(stream, s)
  end

  /**
    Flush the socket, sending all output.
  */
  function flush()
    io.flush(stream)
  end

  /**
    Close the socket.
  */
  function close()
    if stream#null then
      io.close(stream); stream=null
    end
  end

  /**
    Send a request.
    @param method the request method (GET, HEAD, POST).
    @param path the request path.
    @param params the request parameters.
    @param fields the extra header fields.
  */
  function request(method, path, params=null, fields=null)
    if stream=null then connect() end;
    write(method); write(" "); write(path);
    if method=..GET or method=..HEAD then
      s=encodeparams(params);
      if len(s)#0 then
        if index(path,"?")<0 then write("?") end;
        write(s)
      end
    end;
    writeln(" HTTP/1.1");
    if host#null then
      writeln("Host: " + host)
    end;
    if keepAlive#null then
      writeln("Keep-Alive: " + keepAlive);
      writeln("Connection: keep-alive")
    else
      writeln("Connection: close")
    end;
    if cookies#null then
      for name in keys(cookies) do
        write("Cookie: "); write(name); write("="); writeln(cookies[name])
      end
    end;
    if fields#null then
      for name in keys(fields) do
        write(name); write(": "); writeln(fields[name])
      end
    end;
    if method=..POST then
      s=encodeparams(params);
      writeln("Content-Type: application/x-www-form-urlencoded");
      writeln("Content-Length: " + len(s));
      writeln(""); write(s)
    else
      writeln("")
    end;
    flush()
  end

  /**
    Read a number of bytes from the socket.
    @param len the number of bytes to read.
    @return the string read.
  */
  function read(len)
    s=io.read(stream, len);
    if dumpin#null and s#null then io.write(dumpin, s) end;
    return s
  end

  /**
    Read a line up to a maximum length from the socket.
    @param maxlen the maximum number of bytes to read.
    @return the string read (without line end mark).
  */
  function readln(maxlen=1024)
    s=io.readln(stream, maxlen);
    if dumpin#null and s#null then io.writeln(dumpin, s) end;
    if s#null then io.read(stream, 1) end;
    return s
  end

  /**
     Read the HTTP headers (internal function).
  */
  function readHeaders()
    s=readln();
    while s#null and len(s)#0 do
      i=index(s, ":");
      if i>0 then
        name=substr(s,0,i);
        do
          i++
        until i>=len(s) or code(s,i)>32;
        s=substr(s,i);
        if lower(name)="set-cookie" then
          i=index(s,"=");
          if i>0 then
            name=substr(s,0,i); s=substr(s,i+1);
            i=index(s,";");
            if i>=0 then s=substr(s,0,i) end;
            cookies[name]=s
          end
        else
          fields[name]=s;
          if lower(name)="transfer-encoding" and lower(s)="chunked" then
            chunklen=-1
          end
        end
      end;
      s=readln()
    end
  end

  /**
    Handle a response, i.e. parse a response header. This sets up various
    internal fields, and allows to subsequently read the content via
    readContent().
    @return the response code, e.g. 200 for OK.
  */
  function handleResponse()
    code=null;
    fields=array.new(0, true);
    at=0; chunklen=null;
    s=readln();
    if s=null or len(s)=0 then
      return null
    end;
    i=index(s, " ");
    if i>=0 then
      i++; j=index(s, " ", i);
      if j<0 then code=substr(s, i)
      else code=substr(s, i, j-i) end;
      try
        code=num(code)
      catch e by end // ignore
    end;
    readHeaders();
    return code
  end

  /**
    Get a field from the response header.
    @param name the field name (e.g. "Content-Type"), not case sensitive.
    @return the field contents, or null if there is no such field.
  */
  function field(name)
    return fields[name]
  end

  /**
    Get a numeric field from the response header.
    @param name the field name (e.g. "Content-Length"), not case sensitive.
    @return the field value, or -1 if there is no such field or it does not
    contain a valid decimal number.
  */
  function numfield(name)
    try
      return num(field(name))
    catch e by
      return -1
    end
  end

  /**
    Read up to a given number of bytes from the content.
    @param len the number of bytes to read.
    @return the content, or null if there is no more content to read.
  */
  function readContent(len)
    if chunklen#null then
      if at>=chunklen and chunklen#0 then
        // read next chunk
        if chunklen>0 then readln() end;
        chunklen=hexnum(readln()); at=0;
        // if at end, read more header fields
        if chunklen=0 then
          readHeaders()
        end
      end;
      avail=chunklen-at
    else
      avail=numfield('Content-Length')-at
    end;
    if avail<=0 then
      if keepAlive=null then
        close()
      end;
      return null
    end;
    if len>avail then len=avail end;
    s=read(len);
    at+=len(s);
    return s
  end

  /**
    Discard all remaining bytes of content.
  */
  function discardContent()
    while readContent(1024)#null do
    end
  end
end
