//
// Copyright (c) 2009, Brian Frank and Andy Frank
// Licensed under the Academic Free License version 3.0
//
// History:
//   20 May 09  Brian Frank  Creation
//

using web
using concurrent

**
** ObixClient implements the client side of the oBIX
** HTTP REST protocol.
**
class ObixClient
{

//////////////////////////////////////////////////////////////////////////
// Constructor
//////////////////////////////////////////////////////////////////////////

  **
  ** Construct to use Basic Authentication
  **
  static new makeBasicAuth(Uri lobby, Str username, Str password)
  {
    make(lobby, ["Authorization": "Basic " + "$username:$password".toBuf.toBase64])
  }

  **
  ** Construct with given headers to use for authentication
  **
  new make(Uri lobby, Str:Str authHeaders)
  {
    this.lobbyUri = lobby.plusSlash
    this.authHeaders = authHeaders
  }

//////////////////////////////////////////////////////////////////////////
// Configuration
//////////////////////////////////////////////////////////////////////////

  **
  ** Uri of the lobby object
  **
  const Uri lobbyUri

  **
  ** About object relative URI - either set manually or via `readLobby`.
  **
  Uri? aboutUri

  **
  ** Batch operation relative URI - either set manually or via `readLobby`.
  **
  Uri? batchUri

  **
  ** Watch service relative URI - either set manually or via `readLobby`
  **
  Uri? watchServiceUri

//////////////////////////////////////////////////////////////////////////
// Conveniences
//////////////////////////////////////////////////////////////////////////

  **
  ** Read the lobby object.  This method will set the
  ** `aboutUri` and `batchUri` fields.
  **
  ObixObj readLobby()
  {
    lobby := read(lobbyUri)
    aboutUri = lobby.get("about", false)?.href
    batchUri = lobby.get("batch", false)?.href
    watchServiceUri = lobby.get("watchService", false)?.href
    return lobby
  }

  **
  ** Read about object.  The `aboutUri` must be either set
  ** manually or via `readLobby`.
  **
  ObixObj readAbout()
  {
    if (aboutUri == null) throw Err("aboutUri not set")
    return read(aboutUri)
  }

  **
  ** Perform a batch read for all the given URIs.  The
  ** `batchUri` must be either set manually or via `readLobby`.
  **
  ObixObj[] batchRead(Uri[] uris)
  {
    // sanity checks
    if (batchUri == null) throw Err("batchUri not set")
    if (uris.isEmpty) return ObixObj[,]

    // if only one
    if (uris.size == 1) return [ read(uris[0]) ]

    // build batch-in argument
    in := ObixObj { elemName = "list"; contract = Contract.batchIn }
    baseUri := lobbyUri.pathOnly
    uris.each |uri|
    {
      in.add(ObixObj{elemName = "uri"; contract = Contract.read; val = baseUri + uri })
    }

    // invoke the request
    out := invoke(batchUri, in)
    if (out.elemName == "err") throw Err(out.toStr)

    // return the list of children
    return out.list
  }

  **
  ** Create a new watch from via `watchServiceUri` and return the
  ** object which represents the watch.  Raise err if watch service
  ** isn't available.
  **
  ObixClientWatch watchOpen()
  {
    // must have watchServiceUri configured
    if (watchServiceUri == null) throw Err("watchService is not avaialble")

    // lazily populate WatchService.make URI
    if (watchServiceMakeUri == null)
    {
      service := read(watchServiceUri)
      makeOp := service.get("make")
      if (makeOp.href == null) throw Err("WatchService.make missing href")
      watchServiceMakeUri = watchServiceUri + makeOp.href
    }

    // invoke the make op
    watch := invoke(watchServiceMakeUri, ObixObj())
    return ObixClientWatch(this, watch)
  }

//////////////////////////////////////////////////////////////////////////
// Requests
//////////////////////////////////////////////////////////////////////////

  **
  ** Read an obix document with the specified href.
  ** If the result is an '<err>' object, then throw
  ** an ObixErr with the object.
  **
  ObixObj read(Uri uri) { send(uri, "GET", null) }

  **
  ** Write an obix document to the specified href and return
  ** the server's result.  If the result is an '<err>' object,
  ** then throw an ObixErr with the object.
  **
  ObixObj write(ObixObj obj) { send(obj.href, "PUT", obj) }

  **
  ** Invoke the operation identified by the specified href.
  ** If the result is* an '<err>' object, then throw an ObixErr
  ** with the object.
  **
  ObixObj invoke(Uri uri, ObixObj in) { send(uri, "POST", in) }

  private ObixObj send(Uri uri, Str method, ObixObj? in)
  {
    uri = lobbyUri + uri
    c := WebClient(uri)
    c.reqMethod = method
    c.followRedirects = false
    c.socketOptions.receiveTimeout = this.receiveTimeout
    c.reqHeaders.setAll(authHeaders)
    c.cookies = cookies
    if (in != null) c.reqHeaders["Content-Type"]  = "text/xml; charset=utf-8"

    if (log.isDebug)
    {
      Str? req := null
      if (in != null)
      {
        reqBuf := StrBuf()
        in.writeXml(reqBuf.out)
        req = reqBuf.toStr
      }
      debugId := debugReq(c, req)

      c.writeReq
      if (req != null) c.reqOut.print(req).close
      c.readRes
      if (c.resCode == 100) c.readRes
      res := c.resCode == 200 ? c.resIn.readAllStr : null
      debugRes(debugId, c, res)

      return readResObj(c, res.in)
    }
    else
    {
      c.writeReq
      if (in != null)
      {
        in.writeXml(c.reqOut)
        c.reqOut.close
      }
      c.readRes
      if (c.resCode == 100) c.readRes
      return readResObj(c, c.resIn)
    }
  }

  private ObixObj readResObj(WebClient c, InStream in)
  {
    if (c.resCode != 200) throw IOErr("Bad HTTP response: $c.resCode $c.resPhrase [$c.reqUri]")
    cookies = c.cookies
    obj := ObixObj.readXml(in)
    if (obj.elemName == "err") throw ObixErr(obj)
    return obj
  }

//////////////////////////////////////////////////////////////////////////
// Debug
//////////////////////////////////////////////////////////////////////////

  private Int debugReq(WebClient c, Str? req)
  {
    if (!log.isDebug) return 0
    debugId := debugCounter.getAndIncrement
    s := StrBuf()
    s.add("> [$debugId]\n")
    s.add("$c.reqMethod $c.reqUri\n")
    c.reqHeaders.each |v, n| { s.add("$n: $v\n") }
    if (req != null) s.add(req.trimEnd).add("\n")
    log.debug(s.toStr)
    return debugId
  }

  private Void debugRes(Int debugId, WebClient c, Str? res)
  {
    if (!log.isDebug) return
    s := StrBuf()
    s.add("< [$debugId]\n")
    s.add("$c.resCode $c.resPhrase\n")
    c.resHeaders.each |v, n| { s.add("$n: $v\n") }
    if (res != null) s.add(res.trimEnd).add("\n")
    log.debug(s.toStr)
  }

//////////////////////////////////////////////////////////////////////////
// Test
//////////////////////////////////////////////////////////////////////////

  static Void main(Str[] args)
  {
    c := ObixClient(args[0].toUri, args[1], args[2])
    c.log.level = LogLevel.debug
    c.readLobby
    3.times |i|
    {
      echo("------ $i ------")
      about := c.readAbout
      echo
      echo(about->serverName)
      echo(about->vendorName)
      echo(about->productName)
      echo(about->productVersion)
      echo
    }
  }

//////////////////////////////////////////////////////////////////////////
// Fields
//////////////////////////////////////////////////////////////////////////

  private static const AtomicInt debugCounter := AtomicInt()

  @NoDoc Log log := Log.get("obix")
  @NoDoc Duration receiveTimeout := 1min

  private Str:Str authHeaders
  private Uri? watchServiceMakeUri
  private Cookie[] cookies := Cookie#.emptyList
}

**************************************************************************
** ObixClientWatch
**************************************************************************

**
** Represents a clients side watch for an `ObixClient`
**
class ObixClientWatch
{
  ** Constructor used by ObixClient.watchOpen
  internal new make(ObixClient client, ObixObj obj)
  {
    if (obj.href == null)
      throw Err("Server returned Watch without href: $obj")

    this.client = client
    this.uri = obj.href
    this.leaseUri       = childUri(obj, "lease",       "reltime")
    this.addUri         = childUri(obj, "add",         "op")
    this.removeUri      = childUri(obj, "remove",      "op")
    this.pollChangesUri = childUri(obj, "pollChanges", "op")
    this.pollRefreshUri = childUri(obj, "pollRefresh", "op")
    this.deleteUri      = childUri(obj, "delete",      "op")
  }

  private Uri childUri(ObixObj obj, Str name, Str elem)
  {
    child := obj.get(name)
    if (child.elemName != elem) throw Err("Expecting Watch.$name to be $elem, not $child.elemName")
    if (child.href == null) throw Err("Missing href for Watch.$name")
    return this.uri + child.href
  }

  ** Associated client
  ObixClient client { private set }

  ** Get or set the watch lease time on the server
  Duration lease
  {
    get { client.read(leaseUri).val as Duration ?: throw Err("Invalid lease val") }
    set { newVal := it; client.write(ObixObj { href = leaseUri; val = newVal }) }
  }

  ** Add URIs to the watch.
  ObixObj[] add(Uri[] uris)
  {
    if (uris.isEmpty) return ObixObj[,]
    return fromWatchOut(client.invoke(addUri, toWatchIn(uris)))
  }

  ** Remove URIs from the watch.
  Void remove(Uri[] uris)
  {
    if (uris.isEmpty) return
    client.invoke(removeUri, toWatchIn(uris))
  }

  ** Poll for changes to get state of only objects which have changed.
  ObixObj[] pollChanges()
  {
    fromWatchOut(client.invoke(pollChangesUri, nullArg))
  }

  ** Poll refresh to get current state of every URI in watch
  ObixObj[] pollRefresh()
  {
    fromWatchOut(client.invoke(pollRefreshUri, nullArg))
  }

  ** Close the watch down on the server side.
  Void close()
  {
    client.invoke(deleteUri, nullArg)
  }

  private ObixObj nullArg() { ObixObj() }

  private ObixObj toWatchIn(Uri[] uris)
  {
    list := ObixObj { elemName = "list"; name = "hrefs";  }
    uris.each |uri| { list.add(ObixObj { val = uri }) }
    return ObixObj { contract=Contract.watchIn; it.add(list) }
  }

  private ObixObj[] fromWatchOut(ObixObj res)
  {
    list := res.get("values")
    if (list.elemName != "list") throw Err("Expecting WatchOut.list to be <list>: $list")
    return list.list
  }

  private const Uri uri
  private const Uri leaseUri
  private const Uri addUri
  private const Uri removeUri
  private const Uri pollChangesUri
  private const Uri pollRefreshUri
  private const Uri deleteUri
}