
import { Platform, Base6502Platform, getOpcodeMetadata_6502, getToolForFilename_6502, Preset } from "../common/baseplatform";
import { PLATFORMS, setKeyboardFromMap, AnimationTimer, RasterVideo, Keys, makeKeycodeMap, KeyFlags, EmuHalt, ControllerPoller } from "../common/emu";
import { hex, byteArrayToString, replaceAll } from "../common/util";
import { CodeAnalyzer_nes } from "../common/analysis";
import { SampleAudio } from "../common/audio";
import { ProbeRecorder } from "../common/probe";
import { NullProbe, Probeable, ProbeAll } from "../common/devices";
import Mousetrap = require('mousetrap');
import jsnes = require('../../jsnes');
import { BaseMAME6502Platform } from "../common/mameplatform";

const JSNES_PRESETS : Preset[] = [
  {id:'hello.c', name:'Hello World'},
  {id:'attributes.c', name:'Attribute Table'},
  {id:'scroll.c', name:'Scrolling'},
  {id:'sprites.c', name:'Sprites'},
  {id:'metasprites.c', name:'Metasprites'},
  {id:'flicker.c', name:'Flickering Sprites'},
  {id:'metacursor.c', name:'Controllers'},
  {id:'vrambuffer.c', name:'VRAM Buffer'},
  {id:'statusbar.c', name:'Split Status Bar'},
  {id:'siegegame.c', name:'Siege Game'},
  {id:'tint.c', name:'Color Emphasis'},
  {id:'rletitle.c', name:'Title Screen RLE'},
  {id:'aputest.c', name:'Sound Tester'},
  {id:'music.c', name:'Music Player'},
  {id:'horizscroll.c', name:'Offscreen Scrolling'},
  {id:'monobitmap.c', name:'Monochrome Bitmap'},
  {id:'fami.c', name:'Famitone Demo'},
  {id:'shoot2.c', name:'Solarian Game'},
  {id:'climber.c', name:'Climber Game'},
  {id:'bankswitch.c', name:'Bank Switching'},
  {id:'irq.c', name:'IRQ Scanline Counter'},
  {id:'ex0.dasm', name:'Initialization', category:'Assembly Language (ASM)'},
  {id:'ex1.dasm', name:'Hello World'},
  {id:'ex2.dasm', name:'Scrolling Demo'},
  {id:'ex3.dasm', name:'Sprite Demo'},
  {id:'ex4.dasm', name:'Controller Demo'},
  {id:'musicdemo.dasm', name:'Famitone Demo'},
  {id:'xyscroll.dasm', name:'XY Split Scrolling'},
//  {id:'scrollrt.dasm', name:'Line-by-line Scrolling'},
  {id:'road.dasm', name:'3-D Road Demo'},
  {id:'chase/game.c', name:'Shiru\'s Chase Game', category:'Other'},
  {id:'hello.wiz', name:'Hello (Wiz)'},
];

/// JSNES

const JSNES_KEYCODE_MAP = makeKeycodeMap([
  [Keys.A,        0, 0],
  [Keys.B,        0, 1],
  [Keys.GP_A,     0, 0],
  [Keys.GP_B,     0, 1],
  [Keys.SELECT,   0, 2],
  [Keys.START,    0, 3],
  [Keys.UP,       0, 4],
  [Keys.DOWN,     0, 5],
  [Keys.LEFT,     0, 6],
  [Keys.RIGHT,    0, 7],
  
  [Keys.P2_A,      1, 0],
  [Keys.P2_B,      1, 1],
  [Keys.P2_SELECT, 1, 2],
  [Keys.P2_START,  1, 3],
  [Keys.P2_UP,     1, 4],
  [Keys.P2_DOWN,   1, 5],
  [Keys.P2_LEFT,   1, 6],
  [Keys.P2_RIGHT,  1, 7],
]);

class JSNESPlatform extends Base6502Platform implements Platform, Probeable {

  mainElement : HTMLElement;
  nes;
  video: RasterVideo;
  audio;
  timer: AnimationTimer;
  poller : ControllerPoller;
  audioFrequency = 44030; //44100
  frameindex = 0;
  ntvideo: RasterVideo;
  ntlastbuf: Uint32Array;
  showDebugView = false;
  
  machine = { cpuCyclesPerLine: 114 }; // TODO: hack for width of probe scope
  
  constructor(mainElement) {
    super();
    this.mainElement = mainElement;
  }

  getPresets() { return JSNES_PRESETS; }

  start() {
    this.debugPCDelta = 1;
    this.audio = new SampleAudio(this.audioFrequency);
    this.video = new RasterVideo(this.mainElement,256,224,{overscan:true});
    this.video.create();
    // debugging view
    this.ntvideo = new RasterVideo(this.mainElement,512,480,{overscan:false});
    this.ntvideo.create();
    this.ntvideo.canvas.style.display = 'none';
    this.ntlastbuf = new Uint32Array(0x1000);
    if (Mousetrap.bind) Mousetrap.bind('ctrl+shift+alt+n', () => {
      this.showDebugView = !this.showDebugView;
      this.video.canvas.style.display = !this.showDebugView ? '' : 'none';
      this.ntvideo.canvas.style.display = this.showDebugView ? '' : 'none';
    });
    // toggle buttons (TODO)
    /*
    var debugbar = $("<div>").appendTo(this.mainElement);
    $('<button>').text("Video").appendTo(debugbar).click(() => { $(this.video.canvas).toggle() });
    $('<button>').text("Nametable").appendTo(debugbar).click(() => { $(this.ntvideo.canvas).toggle() });
    */
    var idata = this.video.getFrameData();
    this.nes = new jsnes.NES({
      onFrame: (frameBuffer : number[]) => {
        for (var i=0; i<frameBuffer.length; i++)
          idata[i] = frameBuffer[i] | 0xff000000;
        this.video.updateFrame();
        this.frameindex++;
        this.updateDebugViews();
      },
      onAudioSample: (left:number, right:number) => {
        if (this.frameindex < 10)
          this.audio.feedSample(0, 1); // avoid popping at powerup
        else
          this.audio.feedSample((left+right)*0.5, 1);
      },
      onStatusUpdate: function(s) {
        console.log(s);
      },
      //TODO: onBatteryRamWrite
    });
    //this.nes.ppu.showSpr0Hit = true;
    //this.nes.ppu.clipToTvSize = false;
    this.nes.stop = () => {
      this.haltAndCatchFire("Illegal instruction");
      throw new EmuHalt("CPU STOPPED"); //TODO: haltEmulation()
    };
    // insert debug hook
    this.nes.cpu._emulate = this.nes.cpu.emulate;
    this.nes.cpu.emulate = () => {
      if (this.nes.cpu.irqRequested) this.probe.logInterrupt(this.nes.cpu.irqType || 0);
      this.probe.logExecute(this.nes.cpu.REG_PC+1, this.nes.cpu.REG_SP);
      var cycles = this.nes.cpu._emulate();
      this.evalDebugCondition();
      this.probe.logClocks(cycles);
      return cycles > 0 ? cycles : 1;
    }
    this.timer = new AnimationTimer(60, this.nextFrame.bind(this));
    // set keyboard map
    this.poller = setKeyboardFromMap(this.video, [], JSNES_KEYCODE_MAP, (o,key,code,flags) => {
      if (flags & KeyFlags.KeyDown)
        this.nes.buttonDown(o.index+1, o.mask); // controller, button
      else if (flags & KeyFlags.KeyUp)
        this.nes.buttonUp(o.index+1, o.mask); // controller, button
    });
    //var s = ''; nes.ppu.palTable.curTable.forEach((rgb) => { s += "0x"+hex(rgb,6)+", "; }); console.log(s);
  }
  
  pollControls() { this.poller.poll(); }

  advance(novideo : boolean) : number {
    this.nes.frame();
    return 29780; //TODO: PAL or NTSC?
  }

  updateDebugViews() {
   // don't update if view is hidden
   if (!this.showDebugView)
     return;
   var a = 0;
   var attraddr = 0;
   var idata = this.ntvideo.getFrameData();
   var baseTile = this.nes.ppu.regS === 0 ? 0 : 256;
   for (var row=0; row<60; row++) {
     for (var col=0; col<64; col++) {
       a = 0x2000 + (col&31) + ((row%30)*32);
       if (col >= 32) a += 0x400;
       if (row >= 30) a += 0x800;
       var name = this.nes.ppu.mirroredLoad(a) + baseTile;
       var t = this.nes.ppu.ptTile[name];
       attraddr = (a & 0x2c00) | 0x3c0 | (a & 0x0C00) | ((a >> 4) & 0x38) | ((a >> 2) & 0x07);
       var attr = this.nes.ppu.mirroredLoad(attraddr);
       var tag = name ^ (attr<<9) ^ 0x80000000;
       if (tag != this.ntlastbuf[a & 0xfff]) {
         this.ntlastbuf[a & 0xfff] = tag;
         var i = row*64*8*8 + col*8;
         var j = 0;
         var attrshift = (col&2) + ((a&0x40)>>4);
         var coloradd = ((attr >> attrshift) & 3) << 2;
         for (var y=0; y<8; y++) {
           for (var x=0; x<8; x++) {
             var color = t.pix[j++];
             if (color) color += coloradd;
             var rgb = this.nes.ppu.imgPalette[color];
             idata[i++] = rgb | 0xff000000;
           }
           i += 64*8-8;
         }
       }
     }
   }
   this.ntvideo.updateFrame();
  }

  loadROM(title, data) {
    var romstr = byteArrayToString(data);
    this.nes.loadROM(romstr);
    this.frameindex = 0;
    this.installIntercepts();
  }
  installIntercepts() {
    // intercept bus calls, unless we did it already
    var mmap = this.nes.mmap;
    if (!mmap.haveProxied) {
      var oldload = mmap.load.bind(mmap);
      var oldwrite = mmap.write.bind(mmap);
      var oldregLoad = mmap.regLoad.bind(mmap);
      var oldregWrite = mmap.regWrite.bind(mmap);
      var lastioaddr = -1;
      mmap.load = (addr) => {
        var val = oldload(addr);
        if (addr != lastioaddr) this.probe.logRead(addr, val);
        return val;
      }
      mmap.write = (addr, val) => {
        if (addr != lastioaddr) this.probe.logWrite(addr, val);
        oldwrite(addr, val);
      }
      // try not to read/write then IOread/IOwrite at same time
      mmap.regLoad = (addr) => {
        var val = oldregLoad(addr);
        this.probe.logIORead(addr, val);
        lastioaddr = addr;
        return val;
      }
      mmap.regWrite = (addr, val) => {
        this.probe.logIOWrite(addr, val);
        lastioaddr = addr;
        oldregWrite(addr, val);
      }
      mmap.haveProxied = true;
    }
    var ppu = this.nes.ppu;
    if (!ppu.haveProxied) {
      var old_endScanline = ppu.endScanline.bind(ppu);
      var old_startFrame = ppu.startFrame.bind(ppu);
      var old_writeMem = ppu.writeMem.bind(ppu);
      ppu.endScanline = () => {
        old_endScanline();
        this.probe.logNewScanline();
      }
      ppu.startFrame = () => {
        old_startFrame();
        this.probe.logNewFrame();
      }
      ppu.writeMem = (a,v) => {
        old_writeMem(a,v);
        this.probe.logVRAMWrite(a,v);
      }
      ppu.haveProxied = true;
    }
  }
  newCodeAnalyzer() {
    return new CodeAnalyzer_nes(this);
  }
  getOriginPC() {	// TODO: is actually NMI
    return (this.readAddress(0xfffa) | (this.readAddress(0xfffb) << 8)) & 0xffff;
  }
  getDefaultExtensions() { return [...super.getDefaultExtensions(), ".nesasm"]; }

  getROMExtension() { return ".nes"; }

  reset() {
    //this.nes.cpu.reset(); // doesn't work right, crashes
    this.nes.cpu.requestIrq(this.nes.cpu.IRQ_RESET);
    this.installIntercepts();
  }
  isRunning() {
    return this.timer.isRunning();
  }
  pause() {
    this.timer.stop();
    this.audio.stop();
  }
  resume() {
    this.timer.start();
    this.audio.start();
  }

  runToVsync() {
    var frame0 = this.frameindex;
    this.runEval((c) => { return this.frameindex>frame0; });
  }

  getRasterScanline() : number {
    return this.nes.ppu.scanline;
  }
  getRasterLineClock() : number {
    return this.nes.ppu.curX;
  }

  getCPUState() {
    var c = this.nes.cpu.toJSON();
    this.copy6502REGvars(c);
    return c;
  }
  // TODO don't need to save ROM?
  saveState() {
    //var s = $.extend(true, {}, this.nes);
    var s;
    if (this.nes.mmap) {
      s = this.nes.toJSON();
    } else {
      console.log("no nes.mmap!");
      s = { cpu: this.nes.cpu.toJSON(), ppu: this.nes.ppu.toJSON() };
    }
    s.c = s.cpu;
    this.copy6502REGvars(s.c);
    s.b = s.cpu.mem = s.cpu.mem.slice(0);
    s.ppu.vramMem = s.ppu.vramMem.slice(0);
    s.ppu.spriteMem = s.ppu.spriteMem.slice(0);
    s.ctrl = this.saveControlsState();
    return s;
  }
  loadState(state) {
    this.unfixPC(state.cpu);
    this.nes.fromJSON(state);
    this.fixPC(state.cpu);
    //this.nes.cpu.fromJSON(state.cpu);
    //this.nes.mmap.fromJSON(state.mmap);
    //this.nes.ppu.fromJSON(state.ppu);
    this.nes.cpu.mem = state.cpu.mem.slice(0);
    this.nes.ppu.vramMem = state.ppu.vramMem.slice(0);
    this.nes.ppu.spriteMem = state.ppu.spriteMem.slice(0);
    this.loadControlsState(state.ctrl);
    //$.extend(this.nes, state);
    this.installIntercepts();
  }
  saveControlsState() {
    return {
      c1: this.nes.controllers[1].state.slice(0),
      c2: this.nes.controllers[2].state.slice(0),
    };
  }
  loadControlsState(state) {
    this.nes.controllers[1].state = state.c1;
    this.nes.controllers[2].state = state.c2;
  }
  readAddress(addr) {
    return this.nes.cpu.mem[addr];
  }
  readVRAMAddress(addr : number) : number {
    return this.nes.ppu.vramMem[addr];
  }
  copy6502REGvars(c) {
    c.T = 0;
    c.PC = c.REG_PC;
    this.fixPC(c);
    c.A = c.REG_ACC;
    c.X = c.REG_X;
    c.Y = c.REG_Y;
    c.SP = c.REG_SP & 0xff;
    c.Z = c.F_ZERO;
    c.N = c.F_SIGN;
    c.V = c.F_OVERFLOW;
    c.D = c.F_DECIMAL;
    c.C = c.F_CARRY;
    c.I = c.F_INTERRUPT;
    c.R = 1;
    c.o = this.readAddress(c.PC+1);
    return c;
  }

  getDebugCategories() {
    return super.getDebugCategories().concat(['PPU','Mapper']);
  }
  getDebugInfo(category, state) {
    switch (category) {
      case 'PPU': return this.ppuStateToLongString(state.ppu, state.b);
      case 'Mapper': return this.mapperStateToLongString(state.mmap, state.b);
      default: return super.getDebugInfo(category, state);
    }
  }
  ppuStateToLongString(ppu, mem) {
    var s = '';
    var PPUFLAGS = [
      ["f_nmiOnVblank","NMI_ON_VBLANK"],
      ["f_spVisibility","SPRITES"],
      ["f_spClipping","NO_CLIP_SPRITES"],
      ["f_dispType","MONOCHROME"],
      ["f_bgVisibility","BACKGROUND"],
      ["f_bgClipping","NO_CLIP_BACKGROUND"],
    ];
    for (var i=0; i<PPUFLAGS.length; i++) {
      var flag = PPUFLAGS[i];
      s += (ppu[flag[0]] ? flag[1] : "-") + " ";
      if (i==2 || i==5) s += "\n";
    }
    var status = mem[0x2002];
    s += "\n Status ";
    s += (status & 0x80) ? "VBLANK " : "- ";
    s += (status & 0x40) ? "SPRITE0HIT " : "- ";
    s += "\n";
    if (ppu.f_color)
      s += "   Tint " + ((ppu.f_color&1)?"RED ":"") + ((ppu.f_color&2)?"BLUE ":"") + ((ppu.f_color&4)?"GREEN ":"") + "\n";
    if (ppu.f_spVisibility) {
      s += "SprSize " + (ppu.f_spriteSize ? "8x16" : "8x8") + "\n";
      s += "SprBase $" + (ppu.f_spPatternTable ? "1000" : "0000") + "\n";
    }
    if (ppu.f_bgVisibility) {
      s += " BgBase $" + (ppu.f_bgPatternTable ? "1000" : "0000") + "\n";
      s += " NTBase $" + hex(ppu.f_nTblAddress*0x400+0x2000) + "\n";
      s += "AddrInc " + (ppu.f_addrInc ? "32" : "1") + "\n";
    }
    var scrollX = ppu.regFH + ppu.regHT*8;
    var scrollY = ppu.regFV + ppu.regVT*8;
    s += "ScrollX $" + hex(scrollX) + " (" + ppu.regHT + " * 8 + " + ppu.regFH + " = " + scrollX + ")\n";
    s += "ScrollY $" + hex(scrollY) + " (" + ppu.regVT + " * 8 + " + ppu.regFV + " = " + scrollY + ")\n";
    s += "\n";
    s += "   Scan Y: " + ppu.scanline + "  X: " + ppu.curX + "\n";
    s += "VramCur" + (ppu.firstWrite?" ":"?") + "$" + hex(ppu.vramAddress,4) + "\n";
    s += "VramTmp $" + hex(ppu.vramTmpAddress,4) + "\n";
    /*
    var PPUREGS = [
      'cntFV',
      'cntV',
      'cntH',
      'cntVT',
      'cntHT',
      'regV',
      'regH',
      'regS',
    ];
    s += "\n";
    for (var i=0; i<PPUREGS.length; i++) {
      var reg = PPUREGS[i];
      s += lpad(reg.toUpperCase(),7) + " $" + hex(ppu[reg]) + " (" + ppu[reg] + ")\n";
    }
    */
    return s;
  }
  mapperStateToLongString(mmap, mem) {
    //console.log(mmap, mem);
    var s = "";
    if (this.nes.rom) {
      s += "Mapper " + this.nes.rom.mapperType + "\n";
    }
    if (mmap.irqCounter !== undefined) {
      s += "\nIRQ Counter: " + mmap.irqCounter;
      s += "\n  IRQ Latch: " + mmap.irqLatchValue;
      s += "\n IRQ Reload: " + mmap.irqReload;
      s += "\n IRQ Enable: " + mmap.irqEnable;
      s += "\n PRG Select: " + mmap.prgAddressSelect;
      s += "\n CHR Select: " + mmap.chrAddressSelect;
    }
    s += "\n";
    return s;
  }
  getToolForFilename = (fn:string) : string => {
    //if (fn.endsWith(".asm")) return "ca65"; // .asm uses ca65
    if (fn.endsWith(".nesasm")) return "nesasm";
    else return getToolForFilename_6502(fn);
  }
  
  // probing
  nullProbe = new NullProbe();
  probe : ProbeAll = this.nullProbe;

  startProbing?() : ProbeRecorder {
    var rec = new ProbeRecorder(this);
    this.connectProbe(rec);
    return rec;
  }
  stopProbing?() : void {
    this.connectProbe(null);
  }
  connectProbe(probe:ProbeAll) {
    this.probe = probe || this.nullProbe;
  }

  getMemoryMap = function() { return { main:[
      {name:'Zero Page RAM',start:0x0,size:0x100,type:'ram'},
      {name:'OAM Buffer',start:0x200,size:0x100,type:'ram'},
      {name:'Work RAM',start:0x300,size:0x1000-0x300,type:'ram'},
      {name:'PPU Registers',start:0x2000,last:0x2008,size:0x2000,type:'io'},
      {name:'APU Registers',start:0x4000,last:0x4020,size:0x2000,type:'io'},
      {name:'Optional Cartridge RAM',start:0x6000,size:0x2000,type:'ram'},
  ] } };

  showHelp() {
    return "https://8bitworkshop.com/docs/platforms/nes/";
  }

  getDebugSymbolFile() {
    var sym = this.debugSymbols.addr2symbol;
    var text = "";
    for (let [k, v] of Object.entries(sym)) {
      let numK = Number(k);
      let symType;
      if (numK < 0x2000) {
        numK = numK % 0x800;
        symType = "R";
      } else if (numK < 0x6000) symType = "G";
      else if (numK < 0x8000) {
        numK = numK - 0x6000;
        symType = "S";
      } else {
        numK = numK - 0x8000;
        symType = "P";
      }
      let addr = numK.toString(16).padStart(4, '0').toUpperCase();
      // Mesen doesn't allow lables to start with digits
      if ((v as string)[0] >= '0' && (v as string)[0] <= '9') {
        v = "L" + v;
      }
      // nor does it allow dots
      v = replaceAll(v, '.', '_');
      text += `${symType}:${addr}:${v}\n`;
    }
    return {
      extension:".mlb", 
      blob: new Blob([text], {type:"text/plain"})
    };
  }
}

/// MAME support

class NESMAMEPlatform extends BaseMAME6502Platform implements Platform {

  start() {
  }

  loadROM(title, data) {
    if (!this.started) {
      this.startModule(this.mainElement, {
        jsfile:'mame8bitws.js',
        //cfgfile:'nes.cfg',
        driver:'nes',
        width:256,
        height:240,
        romfn:'/emulator/cart.nes',
        romdata:new Uint8Array(data),
        preInit:function(_self) {
        },
      });
    } else {
      // look at iNES header for PRG and CHR ROM lengths
      var prgromlen = data[4] * 0x4000;
      var chrromlen = data[5] * 0x2000;
      this.loadROMFile(data);
      this.loadRegion(":nes_slot:cart:prg_rom", data.slice(0x10, 0x10+prgromlen));
      this.loadRegion(":nes_slot:cart:chr_rom", data.slice(0x10+prgromlen, 0x10+prgromlen+chrromlen));
    }
  }

  getPresets() { return JSNES_PRESETS; }
  getToolForFilename = getToolForFilename_6502;
  getOpcodeMetadata = getOpcodeMetadata_6502;
  getDefaultExtensions() { return [".c", ".cpp", ".acme", ".ca65", ".dasm", ".ecs", ".wiz"]; }

}

///

PLATFORMS['nes'] = JSNESPlatform;
PLATFORMS['nes-asm'] = JSNESPlatform;
PLATFORMS['nes.mame'] = NESMAMEPlatform;

