/*
 * Copyright (C) 2012 Brendan Robert (BLuRry) brendan.robert@gmail.com.
 *
 * This library is free software; you can redistribute it and/or
 * modify it under the terms of the GNU Lesser General Public
 * License as published by the Free Software Foundation; either
 * version 2.1 of the License, or (at your option) any later version.
 *
 * This library is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 * Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public
 * License along with this library; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
 * MA 02110-1301  USA
 */
package jace.hardware;

import jace.core.Computer;
import jace.state.StateManager;
import jace.state.Stateful;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.RandomAccessFile;
import java.util.Arrays;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * Representation of a 140kb floppy disk image. This also performs conversions
 * as needed. Internally, the emulator will always use a "nibblized" disk
 * representation during active use. So if any sort of dsk/do/po image is loaded
 * it will be converted first. If changes are made to the disk then the tracks
 * will be converted back into de-nibblized form prior to saving. The
 * DiskIIDrive class managed disk changes, this class is more an interface to
 * load/save various disk formats and hold the active disk image while it is in
 * use.
 *
 * @author Brendan Robert (BLuRry) brendan.robert@gmail.com
 */
@Stateful
public class FloppyDisk {

    @Stateful
    boolean writeProtected;
    @Stateful
    public int headerLength = 0;
    @Stateful
    public boolean isNibblizedImage;
    @Stateful
    public int volumeNumber;
    static final public int TRACK_NIBBLE_LENGTH = 0x1A00;
    static final public int TRACK_COUNT = 35;
    static final public int SECTOR_COUNT = 16;
    static final public int HALF_TRACK_COUNT = TRACK_COUNT * 2;
    static final public int DISK_NIBBLE_LENGTH = TRACK_NIBBLE_LENGTH * TRACK_COUNT;
    static final public int DISK_PLAIN_LENGTH = 143360;
    static final public int DISK_2MG_NON_NIB_LENGTH = DISK_PLAIN_LENGTH + 0x040;
    static final public int DISK_2MG_NIB_LENGTH = DISK_NIBBLE_LENGTH + 0x040;
    @Stateful
    public byte[] nibbles = new byte[DISK_NIBBLE_LENGTH];
    // Denotes the mapping of physical order (array index) to the dos 3.3 logical order (value)
    public static int[] DOS_33_SECTOR_ORDER = {
        0x00, 0x07, 0x0E, 0x06, 0x0D, 0x05, 0x0C, 0x04,
        0x0B, 0x03, 0x0A, 0x02, 0x09, 0x01, 0x08, 0x0F
    };
    // Denotes the mapping of physical order (array index) to the Prodos logical order (value)
    // Borrowed from KEGS -- thanks KEGS team!
    public static int[] PRODOS_SECTOR_ORDER = {
        0x00, 0x08, 0x01, 0x09, 0x02, 0x0a, 0x03, 0x0b,
        0x04, 0x0c, 0x05, 0x0d, 0x06, 0x0e, 0x07, 0x0f
    };
    // Sector ordering used for current disk
    @Stateful
    public int[] currentSectorOrder;
    // Location of image
    @Stateful
    public File diskPath;
    static int[] NIBBLE_62 = {
        0x96, 0x97, 0x9a, 0x9b, 0x9d, 0x9e, 0x9f, 0xa6,
        0xa7, 0xab, 0xac, 0xad, 0xae, 0xaf, 0xb2, 0xb3,
        0xb4, 0xb5, 0xb6, 0xb7, 0xb9, 0xba, 0xbb, 0xbc,
        0xbd, 0xbe, 0xbf, 0xcb, 0xcd, 0xce, 0xcf, 0xd3,
        0xd6, 0xd7, 0xd9, 0xda, 0xdb, 0xdc, 0xdd, 0xde,
        0xdf, 0xe5, 0xe6, 0xe7, 0xe9, 0xea, 0xeb, 0xec,
        0xed, 0xee, 0xef, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6,
        0xf7, 0xf9, 0xfa, 0xfb, 0xfc, 0xfd, 0xfe, 0xff};
    static int[] NIBBLE_62_REVERSE;

    static {
        NIBBLE_62_REVERSE = new int[256];
        for (int i = 0; i < NIBBLE_62.length; i++) {
            NIBBLE_62_REVERSE[NIBBLE_62[i] & 0x0ff] = 0x0ff & i;
        }
    }
    private static boolean DEBUG = false;

    public FloppyDisk() throws IOException {
        // This constructor is only used for disk conversion...
    }

    /**
     *
     * @param diskFile
     * @param computer
     * @throws IOException
     */
    public FloppyDisk(File diskFile, Computer computer) throws IOException {
        FileInputStream input = new FileInputStream(diskFile);
        String name = diskFile.getName().toUpperCase();
        readDisk(input, name.endsWith(".PO"), computer);
        writeProtected = !diskFile.canWrite();
        diskPath = diskFile;
    }

    // brendanr: refactored to use input stream
    public void readDisk(InputStream diskFile, boolean prodosOrder, Computer computer) throws IOException {
        isNibblizedImage = true;
        volumeNumber = CardDiskII.DEFAULT_VOLUME_NUMBER;
        headerLength = 0;
        try {
            int bytesRead = diskFile.read(nibbles);
            if (bytesRead == DISK_2MG_NIB_LENGTH) {
                bytesRead -= 0x040;
                // Try to pick up volume number from 2MG header.
                volumeNumber = ((nibbles[17] & 1) == 1) ? nibbles[16] : 254;
                nibbles = Arrays.copyOfRange(nibbles, 0x040, nibbles.length);

                headerLength = 0x040;
            }
            if (bytesRead == DISK_2MG_NON_NIB_LENGTH) {
                bytesRead -= 0x040;
                // Try to pick up correct sector ordering and volume from 2MG header.
                prodosOrder = (nibbles[12] == 01);
                volumeNumber = ((nibbles[17] & 1) == 1) ? nibbles[16] : 254;
                nibbles = Arrays.copyOfRange(nibbles, 0x040, nibbles.length);

                headerLength = 0x040;
            }
            currentSectorOrder = prodosOrder ? PRODOS_SECTOR_ORDER : DOS_33_SECTOR_ORDER;
            if (bytesRead == DISK_PLAIN_LENGTH) {
                isNibblizedImage = false;
                nibbles = nibblize(nibbles);
                if (nibbles.length != DISK_NIBBLE_LENGTH) {
                    throw new IOException("Nibblized version is wrong size (expected-actual = " + (DISK_NIBBLE_LENGTH - nibbles.length) + ")");
                }
            } else if (bytesRead != DISK_NIBBLE_LENGTH) {
                throw new IOException("Bad NIB size " + bytesRead + "; JACE only recognizes plain images " + DISK_PLAIN_LENGTH + " or nibble images " + DISK_NIBBLE_LENGTH + " sizes");
            }
        } catch (IOException ex) {
            throw ex;
        }
        StateManager.markDirtyValue(nibbles, computer);
        StateManager.markDirtyValue(currentSectorOrder, computer);
    }

    /*
     * Convert a block-format disk to a 6-by-2 nibblized encoding scheme (raw NIB disk format)
     */
    public byte[] nibblize(byte[] nibbles) throws IOException {
        ByteArrayOutputStream output = new ByteArrayOutputStream();
        for (int track = 0; track < TRACK_COUNT; track++) {
            for (int sector = 0; sector < SECTOR_COUNT; sector++) {
                int gap2 = (int) ((Math.random() * 5.0) + 4);
                // 15 junk bytes
                writeJunkBytes(output, 15);
                // Address block
                writeAddressBlock(output, track, sector);
                // 4 junk bytes
                writeJunkBytes(output, gap2);
                // Data block
                nibblizeBlock(output, track, currentSectorOrder[sector], nibbles);
                // 34 junk bytes
                writeJunkBytes(output, 38 - gap2);
            }
        }
        return output.toByteArray();
    }

    private void writeJunkBytes(ByteArrayOutputStream output, int i) {
        for (int b = 0; b < i; b++) {
            output.write(0x0FF);
        }
    }

    private void writeAddressBlock(ByteArrayOutputStream output, int track, int sector) throws IOException {
        output.write(0x0d5);
        output.write(0x0aa);
        output.write(0x096);

        int checksum = 00;
        // volume
        checksum ^= volumeNumber;
        output.write(getOddEven(volumeNumber));
        // track
        checksum ^= track;
        output.write(getOddEven(track));
        // sector
        checksum ^= sector;
        output.write(getOddEven(sector));
        // checksum
        output.write(getOddEven(checksum & 0x0ff));
        output.write(0x0de);
        output.write(0x0aa);
        output.write(0x0eb);
    }

    private byte[] getOddEven(int i) {
        byte[] out = new byte[2];
        out[0] = (byte) (0xAA | (i >> 1));
        out[1] = (byte) (0xAA | i);
        return out;
    }

    private int decodeOddEven(byte b1, byte b2) {
//        return (((b1 ^ 0x0AA) << 1) & 0x0ff) | ((b2 ^ 0x0AA) & 0x0ff);
        int result = ((((b1 << 1) | 1) & b2) & 0x0ff);
        return result;
    }

    private void nibblizeBlock(ByteArrayOutputStream output, int track, int sector, byte[] nibbles) {
        int offset = ((track * SECTOR_COUNT) + sector) * 256;
        int[] temp = new int[342];
        for (int i = 0; i < 256; i++) {
            temp[i] = (nibbles[offset + i] & 0x0ff) >> 2;
        }
        int hi = 0x001;
        int med = 0x0AB;
        int low = 0x055;

        for (int i = 0; i < 0x56; i++) {
            int value = ((nibbles[offset + hi] & 1) << 5)
                    | ((nibbles[offset + hi] & 2) << 3)
                    | ((nibbles[offset + med] & 1) << 3)
                    | ((nibbles[offset + med] & 2) << 1)
                    | ((nibbles[offset + low] & 1) << 1)
                    | ((nibbles[offset + low] & 2) >> 1);
            temp[i + 256] = value;
            hi = (hi - 1) & 0x0ff;
            med = (med - 1) & 0x0ff;
            low = (low - 1) & 0x0ff;
        }
        output.write(0x0d5);
        output.write(0x0aa);
        output.write(0x0ad);

        int last = 0;
        for (int i = temp.length - 1; i > 255; i--) {
            int value = temp[i] ^ last;
            output.write(NIBBLE_62[value]);
            last = temp[i];
        }
        for (int i = 0; i < 256; i++) {
            int value = temp[i] ^ last;
            output.write(NIBBLE_62[value]);
            last = temp[i];
        }
        // Last data byte used as checksum
        output.write(NIBBLE_62[last]);
        output.write(0x0de);
        output.write(0x0aa);
        output.write(0x0eb);
    }

    public void updateTrack(Integer track) {
        // If disk is nibble image, write nibbles directly
        if (isNibblizedImage) {
            updateNibblizedTrack(track);
        }
        // Otherwise denibblize and write out
        if (!isNibblizedImage) {
            updateDenibblizedTrack(track);
        }
    }

    void updateNibblizedTrack(Integer track) {
        // Locate start of track
        try (RandomAccessFile disk = new RandomAccessFile(diskPath, "rws")) {
            // Locate start of track
            disk.seek(headerLength + track * TRACK_NIBBLE_LENGTH);
            // Update that section of the disk image
            disk.write(nibbles, track * TRACK_NIBBLE_LENGTH, TRACK_NIBBLE_LENGTH);
        } catch (FileNotFoundException ex) {
            Logger.getLogger(FloppyDisk.class.getName()).log(Level.SEVERE, null, ex);
        } catch (IOException ex) {
            Logger.getLogger(FloppyDisk.class.getName()).log(Level.SEVERE, null, ex);
        }
    }
    static public boolean CHECK_NIB_SECTOR_PATTERN_ON_WRITE = true;

    void updateDenibblizedTrack(Integer track) {
        try {
            byte[] trackNibbles = new byte[TRACK_NIBBLE_LENGTH];
            byte[] trackData = new byte[SECTOR_COUNT * 256];
            // Copy track into temporary buffer
//            System.out.println("Nibblized track "+track);
//            System.out.printf("%04d:",0);
            for (int i = 0, pos = track * TRACK_NIBBLE_LENGTH; i < TRACK_NIBBLE_LENGTH; i++, pos++) {
                trackNibbles[i] = nibbles[pos];
//                System.out.print(Integer.toString(nibbles[pos] & 0x0ff, 16)+" ");
//                if (i % 16 == 15) {
//                    System.out.println();
//                    System.out.printf("%04d:",i+1);
//                }
            }
//            System.out.println();

            int pos = 0;
            for (int i = 0; i < SECTOR_COUNT; i++) {
                // Loop through number of sectors
                pos = locatePattern(pos, trackNibbles, 0x0d5, 0x0aa, 0x096);
                // Locate track number
                int trackVerify = decodeOddEven(trackNibbles[pos + 5], trackNibbles[pos + 6]);
                // Locate sector number
                int sector = decodeOddEven(trackNibbles[pos + 7], trackNibbles[pos + 8]);
//                System.out.println("Writing track " + track + ", getting address block for T" + trackVerify + ".S" + sector + " found at NIB offset "+pos);
                // Skip to end of address block
                pos = locatePattern(pos, trackNibbles, 0x0de, 0x0aa /*, 0x0eb this is sometimes being written as FF??*/);
                // Locate start of sector data
                pos = locatePattern(pos, trackNibbles, 0x0d5, 0x0aa, 0x0ad);
                // Determine offset in output data for sector
                //int offset = reverseLoopkup(currentSectorOrder, sector) * 256;
                int offset = currentSectorOrder[sector] * 256;
//                System.out.println("Sector "+sector+" maps to physical sector "+reverseLoopkup(currentSectorOrder, sector));
                // Decode sector data
                denibblizeSector(trackNibbles, pos + 3, trackData, offset);
                // Skip to end of sector
                pos = locatePattern(pos, trackNibbles, 0x0de, 0x0aa, 0x0eb);
            }
            // Write track to disk
            RandomAccessFile disk;
            try {
                disk = new RandomAccessFile(diskPath, "rws");
                disk.seek(headerLength + track * 256 * SECTOR_COUNT);
                disk.write(trackData);
                disk.close();
            } catch (FileNotFoundException ex) {
                Logger.getLogger(FloppyDisk.class.getName()).log(Level.SEVERE, null, ex);
            } catch (IOException ex) {
                Logger.getLogger(FloppyDisk.class.getName()).log(Level.SEVERE, null, ex);
            }
        } catch (Throwable ex) {
            Logger.getLogger(FloppyDisk.class.getName()).log(Level.SEVERE, null, ex);
        }
    }

    private int locatePattern(int pos, byte[] data, int... pattern) throws Throwable {
        int max = data.length;
        while (!matchPattern(pos, data, pattern)) {
            pos = (pos + 1) % data.length;
            max--;
            if (max < 0) {
                throw new Throwable("Could not match pattern!");
            }
        }
//        System.out.print("Found pattern at "+pos+": ");
//        for (int i : pattern) {System.out.print(Integer.toString( i & 0x0ff, 16)+" ");}
//        System.out.println();
        return pos;
    }

    private boolean matchPattern(int pos, byte[] data, int... pattern) {
        int matched = 0;
        for (int i : pattern) {
            int d = data[pos] & 0x0ff;
            if (d != i) {
                if (matched > 1) {
                    System.out.println("Warning: Issue when interpreting nibbilized disk data: at position " + pos + " pattern byte " + Integer.toString(i, 16) + " doesn't match " + Integer.toString(d, 16));
                }
                return false;
            }
            pos = (pos + 1) % data.length;
            matched++;
        }
        return true;
    }

    private void denibblizeSector(byte[] source, int pos, byte[] trackData, int offset) {
        int[] temp = new int[342];
        int current = pos;
        int last = 0;
        // Un-encode raw data, leaving with pre-nibblized bytes
        for (int i = temp.length - 1; i > 255; i--) {
            int t = NIBBLE_62_REVERSE[0x0ff & source[current++]];
            temp[i] = t ^ last;
            last ^= t;
        }
        for (int i = 0; i < 256; i++) {
            int t = NIBBLE_62_REVERSE[0x0ff & source[current++]];
            temp[i] = t ^ last;
            last ^= t;
        }

        // Now decode the pre-nibblized bytes
        int p = temp.length - 1;
        for (int i = 0; i < 256; i++) {
            int a = (temp[i] << 2);
            a = a + ((temp[p] & 1) << 1) + ((temp[p] & 2) >> 1);
            trackData[i + offset] = (byte) a;
            temp[p] = temp[p] >> 2;
            p--;
            if (p < 256) {
                p = temp.length - 1;
            }
        }
    }

    private int reverseLoopkup(int[] table, int value) {
        for (int i = 0; i < table.length; i++) {
            if (table[i] == value) {
                return i;
            }
        }
        return -1;
    }
}
