var FS = require('fs');
var format = require('util').format;

var defs = require('./amqp-rabbitmq-0.9.1.json');

var FRAME_OVERHEAD = 8; // type + channel + size + frame-end

var METHOD_OVERHEAD = FRAME_OVERHEAD + 4;
// F_O + classId + methodId

var PROPERTIES_OVERHEAD = FRAME_OVERHEAD + 4 + 8 + 2;
// F_O + classId + weight + content size + flags


var out = process.stdout;

function printf() {
  out.write(format.apply(format, arguments), 'utf8');
}

function nl() { out.write('\n'); }
function println() { printf.apply(printf, arguments); nl(); }

function isEmptyObject(val) {
  return (val != null && typeof val === 'object' &&
          Object.keys(val).length === 0);
}

function stringifyValue(val) {
  return (isEmptyObject(val)) ? 'EMPTY_OBJECT' :
    JSON.stringify(val);
}

var constants = {};
var constant_strs = {};

for (var i = 0, len = defs.constants.length; i < len; i++) {
  var cdef = defs.constants[i];
  constants[constantName(cdef)] = cdef.value;
  constant_strs[cdef.value] = cdef.name;
}

function constantName(def) {
  return def.name.replace(/-/g, '_');
}

function methodName(clazz, method) {
  return initial(clazz.name) + method.name.split('-').map(initial).join('');
}

function propertyName(dashed) {
  var parts = dashed.split('-');
  return parts[0] + parts.slice(1).map(initial).join('');
}

function initial(part) {
  return part.charAt(0).toUpperCase() + part.substr(1);
}

function argument(a) {
  var type = a.type || domains[a.domain];
  var friendlyName = propertyName(a.name);
  return {type: type, name: friendlyName, default: a['default-value']};
}

var domains = {};
for (var i=0, len = defs.domains.length; i < len; i++) {
  var dom = defs.domains[i];
  domains[dom[0]] = dom[1];
}

var methods = {};
var propertieses = {};

for (var i = 0, len = defs.classes.length; i < len; i++) {
  var clazz = defs.classes[i];
  for (var j = 0, num = clazz.methods.length; j < num; j++) {
    var method = clazz.methods[j];
    var name = methodName(clazz, method);
    var info = 'methodInfo' + name;

    methods[name] = {
      id: methodId(clazz, method),
      name: name,
      methodId: method.id,
      clazzId: clazz.id,
      clazz: clazz.name,
      args: method['arguments'].map(argument),
      isReply: method.answer,
      encoder: 'encode' + name,
      decoder: 'decode' + name,
      info: info
    };
  }
  if (clazz.properties && clazz.properties.length > 0) {
    var name = propertiesName(clazz);
    var props = clazz.properties;
    propertieses[name] = {
      id: clazz.id,
      name: name,
      encoder: 'encode' + name,
      decoder: 'decode' + name,
      info: 'propertiesInfo' + name,
      args: props.map(argument),
    };
  }
}

// OK let's get emitting

println(
'/** @preserve This file is generated by the script\n',
'* ../bin/generate-defs.js, which is not in general included in a\n',
'* distribution, but is available in the source repository e.g. at\n',
'* https://github.com/squaremo/amqp.node/\n',
'*/');

println("'use strict';"); nl();
nl()
println('var codec = require("./codec");');
println('var ints = require("buffer-more-ints");');
println('var encodeTable = codec.encodeTable;');
println('var decodeFields = codec.decodeFields;');
nl();

println('var SCRATCH = Buffer.alloc(65536);');
println('var EMPTY_OBJECT = Object.freeze({});');

println('module.exports.constants = %s',
        JSON.stringify(constants));
nl();
println('module.exports.constant_strs = %s',
        JSON.stringify(constant_strs));
nl();
println('module.exports.FRAME_OVERHEAD = %d;', FRAME_OVERHEAD);
nl();

println('module.exports.decode = function(id, buf) {');
println('switch (id) {');
for (var m in methods) {
  var method = methods[m];
  println('case %d: return %s(buf);', method.id, method.decoder);
}
for (var p in propertieses) {
  var props = propertieses[p];
  println('case %d: return %s(buf);', props.id,  props.decoder);
}
println('default: throw new Error("Unknown class/method ID");');
println('}}'); nl();

println('module.exports.encodeMethod =',
        'function(id, channel, fields) {');
println('switch (id) {');
for (var m in methods) {
  var method = methods[m];
  println('case %d: return %s(channel, fields);',
          method.id, method.encoder);
}
println('default: throw new Error("Unknown class/method ID");');
println('}}'); nl();

println('module.exports.encodeProperties ='
        , 'function(id, channel, size, fields) {');
println('switch (id) {');
for (var p in propertieses) {
  var props = propertieses[p];
  println('case %d: return %s(channel, size, fields);',
          props.id, props.encoder);
}
println('default: throw new Error("Unknown class/properties ID");');
println('}}'); nl();

println('module.exports.info = function(id) {');
println('switch(id) {');
for (var m in methods) {
  var method = methods[m];
  println('case %d: return %s; ', method.id, method.info);
}
for (var p in propertieses) {
  var properties = propertieses[p];
  println('case %d: return %s', properties.id, properties.info);
}
println('default: throw new Error("Unknown class/method ID");');
println('}}'); nl();

for (var m in methods) {
  var method = methods[m];
  println('module.exports.%s = %d;', m, method.id);
  decoderFn(method); nl();
  encoderFn(method); nl();
  infoObj(method); nl();
}

for (var p in propertieses) {
  var properties = propertieses[p];
  println('module.exports.%s = %d;', p, properties.id);
  encodePropsFn(properties); nl();
  decodePropsFn(properties); nl();
  infoObj(properties); nl();
}

function methodId(clazz, method) {
  return (clazz.id << 16) + method.id;
}

function propertiesName(clazz) {
  return initial(clazz.name) + 'Properties';
}

function valTypeTest(arg) {
  switch (arg.type) {
  // everything is booleany
  case 'bit':       return 'true'
  case 'octet':
  case 'short':
  case 'long':
  case 'longlong':
  case 'timestamp': return "typeof val === 'number' && !isNaN(val)";
  case 'shortstr':  return "typeof val === 'string' &&" +
      " Buffer.byteLength(val) < 256";
  case 'longstr':   return "Buffer.isBuffer(val)";
  case 'table':     return "typeof val === 'object'";
  }
}

function typeDesc(t) {
  switch (t) {
  case 'bit':       return 'booleany';
  case 'octet':
  case 'short':
  case 'long':
  case 'longlong':
  case 'timestamp': return "a number (but not NaN)";
  case 'shortstr':  return "a string (up to 255 chars)";
  case 'longstr':   return "a Buffer";
  case 'table':     return "an object";
  }
}

function defaultValueRepr(arg) {
  switch (arg.type) {
  case 'longstr':
    return format("Buffer.from(%s)", JSON.stringify(arg.default));
  default:
    // assumes no tables as defaults
    return JSON.stringify(arg.default);
  }
}

// Emit code to assign the arg value to `val`.
function assignArg(a) {
  println("val = fields['%s'];", a.name);
}

function assignOrDefault(a) {
  println("val = fields['%s'];", a.name);
  println("if (val === undefined) val = %s;", defaultValueRepr(a));
}

// Emit code for assigning an argument value to `val`, checking that
// it exists (if it does not have a default) and is the correct
// type.
function checkAssignArg(a) {
  assignArg(a);
  println('if (val === undefined) {');
  if (a.default !== undefined) {
    println('val = %s;', defaultValueRepr(a));
  }
  else {
    println('throw new Error("Missing value for mandatory field \'%s\'");', a.name);
  }
  println('}'); // undefined test
  println('else if (!(%s)) {', valTypeTest(a));
  println('throw new TypeError(');
  println('"Field \'%s\' is the wrong type; must be %s");',
          a.name, typeDesc(a.type));
  println('}'); // type test
}

// Emit code for encoding `val` as a table and assign to a fresh
// variable (based on the arg name). I use a scratch buffer to compose
// the encoded table, otherwise I'd have to do a size calculation pass
// first. I can get away with this only because 1. the encoding
// procedures are not re-entrant; and, 2. I copy the result into
// another buffer before returning. `scratchOffset`, `val`, `len` are
// expected to have been declared.
function assignTable(a) {
  var varname = tableVar(a);
  println(
    "len = encodeTable(SCRATCH, val, scratchOffset);");
  println('var %s = SCRATCH.slice(scratchOffset, scratchOffset + len);', varname);
  println('scratchOffset += len;');
}

function tableVar(a) {
  return a.name + '_encoded';
}

function stringLenVar(a) {
  return a.name + '_len';
}

function assignStringLen(a) {
  var v = stringLenVar(a);
  // Assumes the value or default is in val
  println("var %s = Buffer.byteLength(val, 'utf8');", v);
}


function encoderFn(method) {
  var args = method['args'];
  println('function %s(channel, fields) {', method.encoder);
  println('var offset = 0, val = null, bits = 0, varyingSize = 0;');
  println('var len, scratchOffset = 0;');

  // Encoding is split into two parts. Some fields have a fixed size
  // (e.g., integers of a specific width), while some have a size that
  // depends on the datum (e.g., strings). Each field will therefore
  // either 1. contribute to the fixed size; or 2. emit code to
  // calculate the size (and possibly the encoded value, in the case
  // of tables).
  var fixedSize = METHOD_OVERHEAD;

  var bitsInARow = 0;

  for (var i=0, len = args.length; i < len; i++) {
    var arg = args[i];

    if (arg.type != 'bit') bitsInARow = 0;

    switch (arg.type) {
    // varying size
    case 'shortstr':
      checkAssignArg(arg);
      assignStringLen(arg);
      println("varyingSize += %s;", stringLenVar(arg));
      fixedSize += 1;
      break;
    case 'longstr':
      checkAssignArg(arg);
      println("varyingSize += val.length;");
      fixedSize += 4;
      break;
    case 'table':
      // For a table we have to encode the table before we can see its
      // length.
      checkAssignArg(arg);
      assignTable(arg);
      println('varyingSize += %s.length;', tableVar(arg));
      break;

    // fixed size
    case 'octet': fixedSize += 1; break;
    case 'short': fixedSize += 2; break;
    case 'long': fixedSize += 4; break;
    case 'longlong': //fall through
    case 'timestamp':
      fixedSize += 8; break;
    case 'bit':
      bitsInARow ++;
      // open a fresh pack o' bits
      if (bitsInARow === 1) fixedSize += 1;
      // just used a pack; reset
      else if (bitsInARow === 8) bitsInARow = 0;
      break;
    }
  }

  println('var buffer = Buffer.alloc(%d + varyingSize);', fixedSize);

  println('buffer[0] = %d;', constants.FRAME_METHOD);
  println('buffer.writeUInt16BE(channel, 1);');
  // skip size for now, we'll write it in when we know
  println('buffer.writeUInt32BE(%d, 7);', method.id);
  println('offset = 11;');

  bitsInARow = 0;

  for (var i = 0, len = args.length; i < len; i++) {
    var a = args[i];

    // Flush any collected bits before doing a new field
    if (a.type != 'bit' && bitsInARow > 0) {
      bitsInARow = 0;
      println('buffer[offset] = bits; offset++; bits = 0;');
    }

    switch (a.type) {
    case 'octet':
      checkAssignArg(a);
      println('buffer.writeUInt8(val, offset); offset++;');
      break;
    case 'short':
      checkAssignArg(a);
      println('buffer.writeUInt16BE(val, offset); offset += 2;');
      break;
    case 'long':
      checkAssignArg(a);
      println('buffer.writeUInt32BE(val, offset); offset += 4;');
      break;
    case 'longlong':
    case 'timestamp':
      checkAssignArg(a);
      println('ints.writeUInt64BE(buffer, val, offset); offset += 8;');
      break;
    case 'bit':
      checkAssignArg(a);
      println('if (val) bits += %d;', 1 << bitsInARow);
      if (bitsInARow === 7) { // I don't think this ever happens, but whatever
        println('buffer[offset] = bits; offset++; bits = 0;');
        bitsInARow = 0;
      }
      else bitsInARow++;
      break;
    case 'shortstr':
      assignOrDefault(a);
      println('buffer[offset] = %s; offset++;', stringLenVar(a));
      println('buffer.write(val, offset, "utf8"); offset += %s;',
              stringLenVar(a));
      break;
    case 'longstr':
      assignOrDefault(a);
      println('len = val.length;');
      println('buffer.writeUInt32BE(len, offset); offset += 4;');
      println('val.copy(buffer, offset); offset += len;');
      break;
    case 'table':
      println('offset += %s.copy(buffer, offset);', tableVar(a));
      break;
    default: throw new Error("Unexpected argument type: " + a.type);
    }
  }

  // Flush any collected bits at the end
  if (bitsInARow > 0) {
    println('buffer[offset] = bits; offset++;');
  }

  println('buffer[offset] = %d;', constants.FRAME_END);
  // size does not include the frame header or frame end byte
  println('buffer.writeUInt32BE(offset - 7, 3);');

  println('return buffer;');
  println('}');
}

function fieldsDecl(args) {
  println('var fields = {');
  for (var i=0, num=args.length; i < num; i++) {
    println('%s: undefined,', args[i].name);
  }
  println('};');
}

function decoderFn(method) {
  var args = method.args;
  println('function %s(buffer) {', method.decoder);
  println('var offset = 0, val, len;');
  fieldsDecl(args);

  var bitsInARow = 0;

  for (var i=0, num=args.length; i < num; i++) {
    var a = args[i];
    var field = "fields['" + a.name + "']";

    // Flush any collected bits before doing a new field
    if (a.type != 'bit' && bitsInARow > 0) {
      bitsInARow = 0;
      println('offset++;');
    }

    switch (a.type) {
    case 'octet':
      println('val = buffer[offset]; offset++;');
      break;
    case 'short':
      println('val = buffer.readUInt16BE(offset); offset += 2;');
      break;
    case 'long':
      println('val = buffer.readUInt32BE(offset); offset += 4;');
      break;
    case 'longlong':
    case 'timestamp':
      println('val = ints.readUInt64BE(buffer, offset); offset += 8;');
      break;
    case 'bit':
      var bit = 1 << bitsInARow;
      println('val = !!(buffer[offset] & %d);', bit);
      if (bitsInARow === 7) {
        println('offset++;');
        bitsInARow = 0;
      }
      else bitsInARow++;
      break;
    case 'longstr':
      println('len = buffer.readUInt32BE(offset); offset += 4;');
      println('val = buffer.subarray(offset, offset + len);');
      println('offset += len;');
      break;
    case 'shortstr':
      println('len = buffer.readUInt8(offset); offset++;');
      println('val = buffer.toString("utf8", offset, offset + len);');
      println('offset += len;');
      break;
    case 'table':
      println('len = buffer.readUInt32BE(offset); offset += 4;');
      println('val = decodeFields(buffer.subarray(offset, offset + len));');
      println('offset += len;');
      break;
    default:
      throw new TypeError("Unexpected type in argument list: " + a.type);
    }
    println('%s = val;', field);
  }
  println('return fields;');
  println('}');
}

function infoObj(thing) {
  var info = JSON.stringify({id: thing.id,
                             classId: thing.clazzId,
                             methodId: thing.methodId,
                             name: thing.name,
                             args: thing.args});
  println('var %s = module.exports.%s = %s;',
          thing.info, thing.info, info);
}

// The flags are laid out in groups of fifteen in a short (high to
// low bits), with a continuation bit (at 0) and another group
// following if there's more than fifteen. Presence and absence
// are conflated with true and false, for bit fields (i.e., if the
// flag for the field is set, it's true, otherwise false).
//
// However, none of that is actually used in AMQP 0-9-1. The only
// instance of properties -- basic properties -- has 14 fields, none
// of them bits.

function flagAt(index) {
  return 1 << (15 - index);
}

function encodePropsFn(props) {
  println('function %s(channel, size, fields) {', props.encoder);
  println('var offset = 0, flags = 0, val, len;');
  println('var scratchOffset = 0, varyingSize = 0;');

  var fixedSize = PROPERTIES_OVERHEAD;

  var args = props.args;

  function incVarying(by) {
    println("varyingSize += %d;", by);
  }

  for (var i=0, num=args.length; i < num; i++) {
    var p = args[i];

    assignArg(p);
    println("if (val != undefined) {");

    println("if (%s) {", valTypeTest(p));
    switch (p.type) {
    case 'shortstr':
      assignStringLen(p);
      incVarying(1);
      println('varyingSize += %s;', stringLenVar(p));
      break;
    case 'longstr':
      incVarying(4);
      println('varyingSize += val.length;');
      break;
    case 'table':
      assignTable(p);
      println('varyingSize += %s.length;', tableVar(p));
      break;
    case 'octet': incVarying(1); break;
    case 'short': incVarying(2); break;
    case 'long': incVarying(4); break;
    case 'longlong': // fall through
    case 'timestamp':
      incVarying(8); break;
      // no case for bit, as they are accounted for in the flags
    }
    println('} else {');
    println('throw new TypeError(');
    println('"Field \'%s\' is the wrong type; must be %s");',
            p.name, typeDesc(p.type));
    println('}');
    println('}');
  }

  println('var buffer = Buffer.alloc(%d + varyingSize);', fixedSize);

  println('buffer[0] = %d', constants.FRAME_HEADER);
  println('buffer.writeUInt16BE(channel, 1);');
  // content class ID and 'weight' (== 0)
  println('buffer.writeUInt32BE(%d, 7);', props.id << 16);
  // skip frame size for now, we'll write it in when we know.

  // body size
  println('ints.writeUInt64BE(buffer, size, 11);');

  println('flags = 0;');
  // we'll write the flags later too
  println('offset = 21;');

  for (var i=0, num=args.length; i < num; i++) {
    var p = args[i];
    var flag = flagAt(i);

    assignArg(p);
    println("if (val != undefined) {");
    if (p.type === 'bit') { // which none of them are ..
      println('if (val) flags += %d;', flag);
    }
    else {
      println('flags += %d;', flag);
      // %%% FIXME only slightly different to the method args encoding
      switch (p.type) {
      case 'octet':
        println('buffer.writeUInt8(val, offset); offset++;');
        break;
      case 'short':
        println('buffer.writeUInt16BE(val, offset); offset += 2;');
        break;
      case 'long':
        println('buffer.writeUInt32BE(val, offset); offset += 4;');
        break;
      case 'longlong':
      case 'timestamp':
        println('ints.writeUInt64BE(buffer, val, offset);');
        println('offset += 8;');
        break;
      case 'shortstr':
        var v = stringLenVar(p);
        println('buffer[offset] = %s; offset++;', v);
        println("buffer.write(val, offset, 'utf8');");
        println("offset += %s;", v);
        break;
      case 'longstr':
        println('buffer.writeUInt32BE(val.length, offset);');
        println('offset += 4;');
        println('offset += val.copy(buffer, offset);');
        break;
      case 'table':
        println('offset += %s.copy(buffer, offset);', tableVar(p));
        break;
      default: throw new Error("Unexpected argument type: " + p.type);
      }
    }
    println('}'); // != undefined
  }

  println('buffer[offset] = %d;', constants.FRAME_END);
  // size does not include the frame header or frame end byte
  println('buffer.writeUInt32BE(offset - 7, 3);');
  println('buffer.writeUInt16BE(flags, 19);');
  println('return buffer.subarray(0, offset + 1);');
  println('}');
}

function decodePropsFn(props) {
  var args = props.args;

  println('function %s(buffer) {', props.decoder);
  println('var flags, offset = 2, val, len;');

  println('flags = buffer.readUInt16BE(0);');
  println('if (flags === 0) return {};');

  fieldsDecl(args);

  for (var i=0, num=args.length; i < num; i++) {
    var p = argument(args[i]);
    var field = "fields['" + p.name + "']";

    println('if (flags & %d) {', flagAt(i));
    if (p.type === 'bit') {
      println('%d = true;', field);
    }
    else {
      switch (p.type) {
      case 'octet':
        println('val = buffer[offset]; offset++;');
        break;
      case 'short':
        println('val = buffer.readUInt16BE(offset); offset += 2;');
        break;
      case 'long':
        println('val = buffer.readUInt32BE(offset); offset += 4;');
        break;
      case 'longlong':
      case 'timestamp':
        println('val = ints.readUInt64BE(buffer, offset); offset += 8;');
        break;
      case 'longstr':
        println('len = buffer.readUInt32BE(offset); offset += 4;');
        println('val = buffer.subarray(offset, offset + len);');
        println('offset += len;');
        break;
      case 'shortstr':
        println('len = buffer.readUInt8(offset); offset++;');
        println('val = buffer.toString("utf8", offset, offset + len);');
        println('offset += len;');
        break;
      case 'table':
        println('len = buffer.readUInt32BE(offset); offset += 4;');
        println('val = decodeFields(buffer.subarray(offset, offset + len));');
        println('offset += len;');
        break;
      default:
        throw new TypeError("Unexpected type in argument list: " + p.type);
      }
      println('%s = val;', field);
    }
    println('}');
  }
  println('return fields;');
  println('}');
}
