package eu.sblendorio.bbs.tenants;

import static eu.sblendorio.bbs.core.Colors.CYAN;
import static eu.sblendorio.bbs.core.Colors.GREEN;
import static eu.sblendorio.bbs.core.Colors.GREY3;
import static eu.sblendorio.bbs.core.Colors.LIGHT_GREEN;
import static eu.sblendorio.bbs.core.Colors.LIGHT_RED;
import static eu.sblendorio.bbs.core.Colors.RED;
import static eu.sblendorio.bbs.core.Colors.WHITE;
import static eu.sblendorio.bbs.core.Keys.CASE_LOCK;
import static eu.sblendorio.bbs.core.Keys.LOWERCASE;
import static eu.sblendorio.bbs.core.Keys.REVOFF;
import static eu.sblendorio.bbs.core.Keys.REVON;
import static java.util.Arrays.asList;
import static org.apache.commons.codec.CharEncoding.UTF_8;
import static org.apache.commons.codec.digest.DigestUtils.sha256Hex;
import static org.apache.commons.collections4.CollectionUtils.isEmpty;
import static org.apache.commons.lang3.StringUtils.EMPTY;
import static org.apache.commons.lang3.StringUtils.defaultString;
import static org.apache.commons.lang3.StringUtils.equalsIgnoreCase;
import static org.apache.commons.lang3.StringUtils.isBlank;
import static org.apache.commons.lang3.StringUtils.isNotBlank;
import static org.apache.commons.lang3.StringUtils.isNumeric;
import static org.apache.commons.lang3.StringUtils.lowerCase;
import static org.apache.commons.lang3.StringUtils.trim;
import static org.apache.commons.lang3.math.NumberUtils.toInt;

import java.io.File;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Statement;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedList;
import java.util.List;
import java.util.Properties;
import java.util.UUID;

import org.apache.commons.lang3.StringUtils;
import org.apache.commons.text.WordUtils;

import eu.sblendorio.bbs.core.PetsciiThread;

public class UserLogon extends PetsciiThread {

    protected static final String DB_FILE = System.getProperty("user.home") + "/bbs-data.db";
    protected static final Properties properties;
    SecureRandom random;

    static {
        properties = new Properties();
        properties.setProperty("characterEncoding", UTF_8);
        properties.setProperty("encoding", "\"" + UTF_8 + "\"");
    }

    private Connection conn = null;
    private User user;

    public static class User {
        public final Long id;
        public final String nick;
        public final String realname;
        public final String email;

        public User(Long id, String nick, String realname, String email) {
            this.id = id;
            this.nick = nick;
            this.realname = realname;
            this.email = email;
        }
    }

    public static class Message {
        public final Long rowId;
        public final String userFrom;
        public final String userTo;
        public final Date dateTime;
        private boolean isRead;
        public final String subject;
        public final String message;
        public final boolean receiverExists;

        public Message(Long rowId, String userFrom, String userTo, Date dateTime, boolean isRead, String subject, String message, boolean receiverExists) {
            this.rowId = rowId;
            this.userFrom = userFrom;
            this.userTo = userTo;
            this.dateTime = dateTime;
            this.isRead = isRead;
            this.subject = subject;
            this.message = message;
            this.receiverExists = receiverExists;
        }

        public void setIsRead(boolean value)  {
            this.isRead = value;
        }
    }

    private void openConnection() throws Exception {
        if (!new File(DB_FILE).exists()) createDatabase(properties);
        if (conn == null || conn.isClosed())
            conn = DriverManager.getConnection("jdbc:sqlite:"+ DB_FILE, properties);
    }

    public void init() throws Exception {
        try {
            random = SecureRandom.getInstance("SHA1PRNG");
        } catch (NoSuchAlgorithmException e) {
            random = null;
        }
        user = null;
        try {
            user = (User) parent.getCustomObject();
        } catch (NullPointerException | ClassCastException e) {
            log("User not logged " + e.getClass().getName() + " " + e.getMessage() );
        }
        openConnection();
    }

    @Override
    public void doLoop() throws Exception {
        init();
        String username;
        String password;
        cls();
        write(CASE_LOCK, LOWERCASE);
        write(LOGO_BYTES);
        write(GREY3);
        newline();
        println("Enter 'P' for privacy policy");
        newline();
        while (user == null) {
            do {
                print("USERID or 'NEW': ");
                flush(); username = readLine();
                if (isBlank(username)) return;
                if (equalsIgnoreCase(username, "p")) {
                    showPrivacyPolicy();
                    cls();
                    write(CASE_LOCK, LOWERCASE);
                    write(LOGO_BYTES);
                    write(GREY3);
                    newline();
                    println("Enter 'P' as USERID for privacy policy");
                    newline();
                } else if (equalsIgnoreCase(username, "new")) {
                    if (createNewUser()) {
                        write(GREEN); println("User created successfully.");
                    } else {
                        write(RED); println("Operation aborted.");
                    }
                    write(GREY3);
                    newline();
                }
            } while (equalsIgnoreCase(username, "new") || equalsIgnoreCase(username, "p"));
            print("PASSWORD: ");
            flush(); password = readPassword();
            user = getUser(username, password);
            if (user == null) {
                write(RED);
                newline();
                write(REVON); println("Wrong username or password"); write(REVOFF);
                newline();
                write(GREY3);
            }
        }
        try {
            parent.setCustomObject(user);
        } catch (NullPointerException e) {
            // do nothing
        }
        listMessages(false);
    }

    public void listUsers() throws Exception {
        cls();
        write(LOGO_BYTES);
        write(GREY3);
        List<User> users = getUsers();
        int i = 0;
        for (User u: users) {
            ++i;
            write(CYAN);
            print(u.nick);
            write(GREY3);
            String realname = u.realname;
            if (isNotBlank(realname) && (u.nick + realname).length() > 36)
                realname = realname.substring(0, 33 - u.nick.length()) + "...";
            println(isBlank(realname) ? EMPTY : " (" + realname + ")");
            if (i % 19 == 0 && i < users.size()) {
                newline();
                write(WHITE); print("ANY KEY FOR NEXT PAGE, '.' TO GO BACK "); write(GREY3);
                flush(); resetInput(); int ch = readKey(); resetInput();
                if (ch == '.') return;
                cls();
                write(LOGO_BYTES);
                write(GREY3);
            }
        }
        newline();
        write(WHITE); print("PRESS ANY KEY TO GO BACK "); write(GREY3);
        flush(); resetInput(); readKey(); resetInput();
    }

    public void sendMessageGui() throws Exception {
        sendMessageGui(null, null);
    }

    public void sendMessageGui(String toUser, String toSubject) throws Exception {
        String receipt;
        String subject;
        boolean ok = false;

        if (toUser != null) {
            receipt = toUser;
            subject = defaultString(toSubject);
            print("send to: "); println(receipt);
            print("subject: "); println(subject);
        }
        else {
            do {
                print("send to (? for user list): ");
                flush(); receipt = readLine();
                if (isBlank(receipt)) return;
                ok = existsUser(receipt);
                if (!ok && !"?".equals(receipt)) println("WARN: not existing user");
                if ("?".equals(receipt)) {
                    listUsers();
                    newline();
                    newline();
                }
            } while (!ok);

            print("subject: ");
            flush(); subject = readLine();
            if (isBlank(subject)) return;
        }
        newline();
        println("Message (end with EMPTY LINE)");
        println("-----------------------------");
        String line;
        String message = EMPTY;
        do {
            flush();
            line = readLine();
            if (isNotBlank(line))
                message += line + "\n";
        } while (isNotBlank(line));

        sendMessage(user.nick, receipt, subject, message);
        newline(); write(WHITE);
        print("MESSAGE SENT - PRESS ANY KEY ");
        write(GREY3);
        flush(); resetInput(); readKey(); resetInput();
    }

    public void sendMessage(String from, String to, String subject, String message) throws Exception {
        try (PreparedStatement ps = conn.prepareStatement("INSERT INTO messages (user_from, user_to, datetime, is_read, subject, message) values (?,?,?,?,?,?)")) {
            ps.setString(1, from);
            ps.setString(2, to);
            ps.setLong(3, System.currentTimeMillis());
            ps.setLong(4, 0);
            ps.setString(5, subject);
            ps.setString(6, message);
            ps.executeUpdate();
        }
    }

    public void listMessages(boolean onlyUnread) throws Exception {
        List<Message> messages = getMessages(user.nick, onlyUnread);

        int pagesize = 12;
        int offset = 0;
        String cmd;
        do {
            int size = messages.size();
            if (onlyUnread && size == 0) {
                onlyUnread = false;
                messages = getMessages(user.nick, onlyUnread);
                size = messages.size();
            }
            long unread = countUnreadMessages(user.nick);
            cls();
            write(LOGO_BYTES);
            write(GREY3);
            println("Got " + size  + (onlyUnread ? " unread" : EMPTY) + " message" + (size != 1 ? "s" : EMPTY) + (onlyUnread || unread == 0 ? EMPTY : " (" + unread + " unread)") + ".");
            newline();
            for (int i=offset; i<Math.min(offset+pagesize, size); ++i) {
                int i1=i+1;
                Message m = messages.get(i);
                DateFormat df = new SimpleDateFormat("yyyy-MM-dd");
                DateFormat tf = new SimpleDateFormat("hh:mm:ssa");
                String nowString= df.format(new Date(System.currentTimeMillis()));
                String date = df.format(m.dateTime);
                if (date.equals(nowString)) date = tf.format(m.dateTime);
                String subject = isNotBlank(m.subject) ? m.subject : defaultString(m.message).replaceAll("[\r\n]", " ");
                if (isNotBlank(subject) && (1+(""+i1).length()+1+10+1+m.userFrom.length()+1+m.subject.length() )>39)
                    subject = subject.substring(0,39-(1+(""+i1).length()+1+10+1+m.userFrom.length()+1));
                write(LIGHT_RED); print((m.isRead ? " " : "*"));
                write(WHITE); print(i1 + " ");
                write(GREY3); print(date + " ");
                write(m.receiverExists ? CYAN : RED); print(m.userFrom);
                print(" ");
                write(WHITE); print(subject);
                newline();
            }
            write(GREY3);
            write(WHITE);println("_______________________________________");write(GREY3);

            write(REVON); print(" U "); write(REVOFF); print(" List users ");
            write(REVON); print(" M "); write(REVOFF); print(" New message ");
            write(REVON); print(" . "); write(REVOFF); println(" Exit");

            write(REVON); print(" A "); write(REVOFF); print(" All messag ");
            write(REVON); print(" R "); write(REVOFF); println(" Only unread messages");

            write(REVON); print(" # "); write(REVOFF); print(" Read message number  ");
            write(REVON); print(" K "); write(REVOFF); println(" User prefs");

            write(REVON); print(" + "); write(REVOFF); print(" Next page ");
            write(REVON); print(" - "); write(REVOFF); print(" Prev page ");
            write(REVON); print(" P "); write(REVOFF); println(" Privacy");

            write(WHITE);println(StringUtils.repeat(chr(163), 39));write(GREY3);
            print("> ");
            flush(); cmd = readLine();
            cmd = defaultString(trim(lowerCase(cmd)));
            int index = toInt(cmd.replace("#", EMPTY));
            if ("+".equals(cmd) && (offset+pagesize<size)) {
                offset += pagesize;
            } else if ("-".equals(cmd) && offset > 0) {
                offset -= pagesize;
            } else if ("u".equals(cmd)) {
                listUsers();
            } else if ("a".equals(cmd)) {
                onlyUnread = false;
            } else if ("r".equals(cmd)) {
                onlyUnread = true;
            } else if ("m".equals(cmd)) {
                sendMessageGui();
            } else if ("p".equals(cmd)) {
                showPrivacyPolicy();
            } else if ("k".equals(cmd)) {
                userPreferences();
            } else if (isNumeric(cmd.replace("#", EMPTY)) && index>0 && index<=size) {
                displayMessage(messages.get(index - 1));
            }
            messages = getMessages(user.nick, onlyUnread);
        } while (!".".equals(cmd));

    }

    public void displayMessage(Message m) throws Exception {
        DateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        cls();
        write(LOGO_BYTES);
        write(GREY3);
        println("From: "+ m.userFrom);
        println("To:   "+ m.userTo);
        println("Date: "+ df.format(m.dateTime));
        println("Subj: "+ m.subject);
        println(StringUtils.repeat(chr(163),39));
        String[] lines = defaultString(m.message).split("\n");
        for (String line: lines)
            println(WordUtils.wrap(line, 39, "\r", true ));
        markAsRead(m);
        newline();
        print("press "); write(WHITE); print("'R'"); write(GREY3); print(" to REPLY, any key to go back.");
        flush();
        resetInput(); int ch = readKey();
        if (ch == 'r' || ch == 'R') {
            newline();
            newline();
            sendMessageGui(m.userFrom, m.subject);
        }
    }

    void markAsRead(Message m) throws Exception {
        m.setIsRead(true);
        try (PreparedStatement ps = conn.prepareStatement("update messages set is_read=1 where rowid=?")) {
            ps.setLong(1, m.rowId);
            ps.executeUpdate();
        }
    }

    public List<User> getUsers() throws Exception {
        List<User> result = new LinkedList<>();
        try (Statement s = conn.createStatement();
                ResultSet r = s.executeQuery("select id, nick, realname, email from users order by nick")) {
            while (r.next())
                result.add(new User(
                    r.getLong("id"),
                    r.getString("nick"),
                    r.getString("realname"),
                    r.getString("email")
            ));
        }
        return result;
    }

    public List<Message> getMessages(String userTo, boolean onlyUnread) throws Exception {
        List<Message> result = new ArrayList<>();
        try (PreparedStatement ps = conn.prepareStatement("" +
                "SELECT messages.rowid, user_from, user_to, datetime, is_read, subject, message, id FROM messages LEFT JOIN users ON user_from=nick WHERE user_to=? "+
                (onlyUnread ? " AND is_read = 0 " : EMPTY) + " ORDER BY datetime DESC") ) {
            ps.setString(1, userTo);
            try (ResultSet rs = ps.executeQuery()) {
                while (rs.next())
                    result.add(new Message(
                            rs.getLong("rowid"),
                            rs.getString("user_from"),
                            rs.getString("user_to"),
                            new Date(rs.getLong("datetime")),
                            rs.getLong("is_read") != 0,
                            rs.getString("subject"),
                            rs.getString("message"),
                            rs.getString("id") != null
                    ));
            }
        }
        return result;
    }

    public void userPreferences() throws Exception {
        int ch;
        do {
            cls();
            write(LOGO_BYTES);
            write(GREY3);
            println("User preferences [" + user.nick + "]");
            newline();
            write(REVON); print(" 1 "); write(REVOFF); println(" Change password");
            write(REVON); print(" 2 "); write(REVOFF); println(" Change realname");
            write(REVON); print(" 3 "); write(REVOFF); println(" Erase user");
            write(REVON); print(" . "); write(REVOFF); println(" Back to messages");
            newline();
            flush(); resetInput();
            ch = readKey();

            if (ch == '1')  {

            } else if (ch == '2') {
                newline();
                print("Real name: ");
                flush();
                String newName = readLine();
                if (isNotBlank(newName)) {
                    user = changeUserName(user, newName);
                    newline();
                    write(LIGHT_GREEN); println("Real name change successfully"); write(GREY3);
                    flush();
                    resetInput(); readKey();
                }
            } else if (ch == '3') {
                write(RED);
                write(REVON); println("         ");
                write(REVON); println(" WARNING ");
                write(REVON); println("         "); write(REVOFF);
                newline();
                println("This choice will erase your account");
                print("Are you sure? (Y/N) ");
                flush(); resetInput();
                int erase = readKey();
                if (erase == 'y' || erase == 'Y') {
                    newline();
                    newline();
                    killUser(user.nick);
                    write(REVON); println("                      ");
                    write(REVON); println(" USER FINALLY DELETED ");
                    write(REVON); println("                      "); write(REVOFF);
                    write(GREY3); newline();
                    println("PRESS ANY KEY TO EXIT");
                    readKey();
                    throw new UserRemovedException();
                }
            }
        } while (ch != '.');
    }

    public boolean createNewUser() throws Exception {
        String username;
        String password;
        String realname;
        String email;
        boolean notValid;
        newline();
        write(WHITE);
        println("ADDING NEW USER");
        println(StringUtils.repeat(chr(163), 15));
        write(GREY3);
        do {
            print("Username: ");
            flush(); username = readLine();
            if (isBlank(username)) return false;
            notValid = existsUser(username) || userInVault(username) || "?".equals(username) || "p".equalsIgnoreCase(username);
            if (notValid) println("WARN: Username not available");
        } while (notValid);
        print("Real name: "); flush(); realname = readLine();
        print("Email: "); flush(); email = readLine();
        do {
            print("Password: ");
            flush(); password = readPassword();
        } while (isBlank(password));
        write(LIGHT_RED); print("Do you confirm creation? (Y/N)"); write(GREY3);
        flush(); resetInput(); int key = readKey(); resetInput();
        newline();
        return (key=='Y' || key=='y') ? addUser(username, realname, email, password) : false;
    }

    public boolean addUser(String nick, String realname, String email, String password) throws Exception {
        if (existsUser(nick)) {
            return false;
        }

        try (PreparedStatement ps = conn.prepareStatement(
                "insert into users (nick, realname, email, salt, password) values (?,?,?,?,?)")) {
            String salt = generateId();
            String hash = sha256Hex(salt + password);
            ps.setString(1, nick);
            ps.setString(2, realname);
            ps.setString(3, email);
            ps.setString(4, salt);
            ps.setString(5, hash);
            ps.execute();
        }
        return true;
    }

    public long countTotalMessages(String nick) throws Exception {
        try (PreparedStatement ps = conn.prepareStatement("select count(*) from messages where user_to=?")) {
            ps.setString(1, nick);
            try (ResultSet rs = ps.executeQuery()) {
                return rs.next() ? rs.getLong(1) : 0;
            }
        }
    }
    public long countUnreadMessages(String nick) throws Exception {
        try (PreparedStatement ps = conn.prepareStatement("select count(*) from messages where user_to=? and is_read=0")) {
            ps.setString(1, nick);
            try (ResultSet rs = ps.executeQuery()) {
                return rs.next() ? rs.getLong(1) : 0;
            }
        }
    }

    public User getUserById(Long id) throws Exception {
        try (PreparedStatement ps = conn.prepareStatement("select id, nick, realname, email, salt, password from users where id=?")) {
            ps.setLong(1, id);
            try (ResultSet rs = ps.executeQuery()) {
                rs.next();
                return new User(rs.getLong("id"), rs.getString("nick"), rs.getString("realname"), rs.getString("email"));
            }
        }
    }

    public User changeUserName(User user, String newName) throws Exception {
        try (PreparedStatement ps = conn.prepareStatement("update users set realname=? where id=?")) {
            ps.setString(1, newName);
            ps.setLong(2, user.id);
            ps.executeUpdate();
        }

        return getUserById(user.id);
    }

    public boolean existsUser(String nick) throws Exception {
        try (PreparedStatement ps = conn.prepareStatement("select id, realname, email, salt, password from users where nick=?")) {
            ps.setString(1, nick);
            try (ResultSet rs = ps.executeQuery()) {
                return rs.next();
            }
        }
    }

    public boolean userInVault(String nick) throws Exception {
        try (PreparedStatement ps = conn.prepareStatement("select hash from user_vault where hash=?")) {
            ps.setString(1, sha256Hex(nick));
            try (ResultSet rs = ps.executeQuery()) {
                return rs.next();
            }
        }
    }

    public User getUser(String nick, String givenPassword) throws Exception {
        try (PreparedStatement ps = conn.prepareStatement("select id, realname, email, salt, password from users where nick=?");) {
            ps.setString(1, nick);
            try (ResultSet rs = ps.executeQuery()) {
                boolean found = rs.next();
                if (!found) return null;
                String salt = defaultString(rs.getString("salt"));
                String password = defaultString(rs.getString("password"));
                if (!sha256Hex(salt + givenPassword).equals(password)) return null;
                Long id = rs.getLong("id");
                String realname = rs.getString("realname");
                String email = rs.getString("email");
                return new User(id, nick, realname, email);
            }
        }
    }

    public void killUser(String nick) throws Exception {
        try (PreparedStatement ps = conn.prepareStatement("delete from users where nick=?")) {
            ps.setString(1, nick);
            ps.executeUpdate();
        }

        try (PreparedStatement ps = conn.prepareStatement("delete from messages where user_to=?")) {
            ps.setString(1, nick);
            ps.executeUpdate();
        }

        try (PreparedStatement ps = conn.prepareStatement("insert into user_vault (hash) values (?)")) {
            ps.setString(1, sha256Hex(nick));
            ps.executeUpdate();
        }
    }

    public void createDatabase(Properties properties) throws Exception {
        try (Connection conn = DriverManager.getConnection("jdbc:sqlite:" + DB_FILE, properties)) {

            try (Statement s = conn.createStatement()) {
                s.executeUpdate(
                        "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, nick TEXT, realname TEXT, email TEXT, salt text, password TEXT)");
            }

            try (Statement s = conn.createStatement()) {
                s.executeUpdate(
                        "CREATE TABLE messages (user_from TEXT, user_to TEXT, datetime INTEGER, is_read INTEGER, subject TEXT, message TEXT)");
            }

            try (Statement s = conn.createStatement()) {
                s.executeUpdate("CREATE TABLE user_vault (hash TEXT)");
            }
        }
    }

    private static final byte[] LOGO_BYTES = new byte[] {
            32, 32, 32, 32, 32, 28, -84, 32, 32, 32, 32, 32, 32, 32, 32, 32,
            32, 32, 32, 32, 32, -104, -69, 32, 32, 32, 32, 32, 32, 32, 32, 32,
            32, 32, 5, -81, -81, -81, -81, -81, -81, -81, 13, 18, 28, -95, -65, -110,
            -84, 18, -69, -110, -69, 18, -69, -110, -66, 18, -68, -110, -66, 18, -65, -110,
            -65, -104, -84, 18, -94, -110, -95, 18, -65, -110, -66, 18, -65, -69, -110, -84,
            18, -94, -110, -95, 18, -65, -68, -95, -69, -110, -65, 18, -95, -110, 32, -95,
            32, 32, 32, 18, 5, -48, -46, -55, -42, -63, -44, -59, -110, 13, 18, 28,
            -95, -110, 32, -68, 18, -68, -110, 32, -68, -69, -95, 32, -65, 18, -65, -110,
            -104, -68, -94, -95, -65, -69, -65, 18, -66, -110, -68, -94, -95, 18, -69, -110,
            -69, 18, -95, -95, -95, -110, -68, -94, -95, 30, -94, -94, 32, 18, 5, -45,
            -59, -61, -44, -55, -49, -50, -110, 13, 32, 32, 32, 32, 32, 32, 32, 32,
            32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32,
            32, 32, 32, -104, -94, -66, 32, 32, 32, 5, -93, -93, -93, -93, -93, -93,
            -93, 13
    };

    public String generateId() {
        if (random == null) return UUID.randomUUID().toString();
        byte[] bytes = new byte[32];
        random.nextBytes(bytes);
        StringBuffer sb = new StringBuffer();
        for (int i = 0; i < bytes.length; i++) {
            String theHex = Integer.toHexString(bytes[i] & 0xFF).toLowerCase();
            sb.append(theHex.length() == 1 ? "0" + theHex : theHex);
        }
        return sb.toString();
    }

    public void showPrivacyPolicy() throws Exception {
        List<String> rawText = readTextFile("gdpr/privacy-statement.txt");
        List<String> text = new ArrayList<>();
        for (String row: rawText)
            text.addAll(asList(WordUtils.wrap(row, 39, "\n", true).split("\n")));
        if (isEmpty(text)) return;
        int size = text.size();
        int pagesize = 18;
        int offset = 0;
        int cmd = 0;
        do {
            cls();
            write(LOGO_BYTES);
            write(GREY3);
            newline();
            for (int i = offset; i < Math.min(offset + pagesize, size); ++i) {
                println(text.get(i));

            }
            println();
            write(WHITE); print("SPACE");
            write(GREY3); print("=Next page [");
            write(WHITE); print("-");
            write(GREY3); print("]=Prev page [");
            write(WHITE); print(".");
            write(GREY3); print("]=EXIT");
            flush();
            resetInput();
            cmd = readKey();
            if (cmd == '.') {
                return;
            } else if (cmd == '-' && offset > 0) {
                offset -= pagesize;
            } else if (offset + pagesize < size) {
                offset += pagesize;
            }
        } while (true);
    }

    public static class UserRemovedException extends RuntimeException {}
}