/***************************************************************************
 *   crengine-ng                                                           *
 *   Copyright (C) 2007-2013,2015 Vadim Lopatin <coolreader.org@gmail.com> *
 *   Copyright (C) 2010 Alexander V. Nikolaev <avn@daemon.hole.ru>         *
 *   Copyright (C) 2018 EXL <exlmotodev@gmail.com>                         *
 *   Copyright (C) 2020 Konstantin Potapov <pkbo@users.sourceforge.net>    *
 *   Copyright (C) 2020 poire-z <poire-z@users.noreply.github.com>         *
 *   Copyright (C) 2020,2021,2023 Aleksey Chernov <valexlin@gmail.com>     *
 *                                                                         *
 *   This program is free software; you can redistribute it and/or         *
 *   modify it under the terms of the GNU General Public License           *
 *   as published by the Free Software Foundation; either version 2        *
 *   of the License, or (at your option) any later version.                *
 *                                                                         *
 *   This program 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 General Public License for more details.                          *
 *                                                                         *
 *   You should have received a copy of the GNU General Public License     *
 *   along with this program; if not, write to the Free Software           *
 *   Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,            *
 *   MA 02110-1301, USA.                                                   *
 ***************************************************************************/

/**
 * \file hist.cpp
 * \brief file history and bookmarks container
 */

#include <stdint.h>

#include <crhist.h>
#include <lvxmlparsercallback.h>
#include <lvstreamutils.h>
#include <ldomdocument.h>
#include <crlog.h>

#include "lvxml/lvxmlparser.h"
#include "lvxml/lvxmlutils.h"

void CRFileHist::clear() {
    _records.clear();
}

/// XML parser callback interface
class CRHistoryFileParserCallback: public LVXMLParserCallback
{
protected:
    LVFileFormatParser* _parser;
    CRFileHist* _hist;
    CRBookmark* _curr_bookmark;
    CRFileHistRecord* _curr_file;
    enum state_t
    {
        in_xml,
        in_fbm,
        in_file,
        in_file_info,
        in_bm_list,
        in_bm,
        in_start_point,
        in_end_point,
        in_header_txt,
        in_selection_txt,
        in_comment_txt,
        in_title,
        in_author,
        in_series,
        in_filename,
        in_filepath,
        in_filesize,
        in_dom_version
    };
    state_t state;
public:
    ///
    CRHistoryFileParserCallback(CRFileHist* hist)
            : _hist(hist)
            , _curr_bookmark(NULL)
            , _curr_file(NULL) {
        state = in_xml;
    }
    virtual lUInt32 getFlags() {
        return TXTFLG_PRE;
    }
    /// called on parsing start
    virtual void OnStart(LVFileFormatParser* parser) {
        _parser = parser;
        parser->SetSpaceMode(false);
    }
    /// called on parsing end
    virtual void OnStop() {
    }
    /// called on opening tag end
    virtual void OnTagBody() {
    }
    /// add named BLOB data to document
    virtual bool OnBlob(lString32 name, const lUInt8* data, int size) {
        CR_UNUSED3(name, data, size);
        return true;
    }
    /// called on opening tag
    virtual ldomNode* OnTagOpen(const lChar32* nsname, const lChar32* tagname) {
        CR_UNUSED(nsname);
        if (lStr_cmp(tagname, "FictionBookMarks") == 0 && state == in_xml) {
            state = in_fbm;
        } else if (lStr_cmp(tagname, "file") == 0 && state == in_fbm) {
            state = in_file;
            _curr_file = new CRFileHistRecord();
        } else if (lStr_cmp(tagname, "file-info") == 0 && state == in_file) {
            state = in_file_info;
        } else if (lStr_cmp(tagname, "bookmark-list") == 0 && state == in_file) {
            state = in_bm_list;
        } else if (lStr_cmp(tagname, "doc-title") == 0 && state == in_file_info) {
            state = in_title;
        } else if (lStr_cmp(tagname, "doc-author") == 0 && state == in_file_info) {
            state = in_author;
        } else if (lStr_cmp(tagname, "doc-series") == 0 && state == in_file_info) {
            state = in_series;
        } else if (lStr_cmp(tagname, "doc-filename") == 0 && state == in_file_info) {
            state = in_filename;
        } else if (lStr_cmp(tagname, "doc-filepath") == 0 && state == in_file_info) {
            state = in_filepath;
        } else if (lStr_cmp(tagname, "doc-filesize") == 0 && state == in_file_info) {
            state = in_filesize;
        } else if (lStr_cmp(tagname, "doc-dom-version") == 0 && state == in_file_info) {
            state = in_dom_version;
        } else if (lStr_cmp(tagname, "bookmark") == 0 && state == in_bm_list) {
            state = in_bm;
            _curr_bookmark = new CRBookmark();
        } else if (lStr_cmp(tagname, "start-point") == 0 && state == in_bm) {
            state = in_start_point;
        } else if (lStr_cmp(tagname, "end-point") == 0 && state == in_bm) {
            state = in_end_point;
        } else if (lStr_cmp(tagname, "header-text") == 0 && state == in_bm) {
            state = in_header_txt;
        } else if (lStr_cmp(tagname, "selection-text") == 0 && state == in_bm) {
            state = in_selection_txt;
        } else if (lStr_cmp(tagname, "comment-text") == 0 && state == in_bm) {
            state = in_comment_txt;
        }
        return NULL;
    }
    /// called on closing
    virtual void OnTagClose(const lChar32* nsname, const lChar32* tagname, bool self_closing_tag = false) {
        if (lStr_cmp(nsname, "FictionBookMarks") == 0 && state == in_fbm) {
            state = in_xml;
        } else if (lStr_cmp(tagname, "file") == 0 && state == in_file) {
            state = in_fbm;
            if (_curr_file)
                _hist->getRecords().add(_curr_file);
            _curr_file = NULL;
        } else if (lStr_cmp(tagname, "file-info") == 0 && state == in_file_info) {
            state = in_file;
        } else if (lStr_cmp(tagname, "bookmark-list") == 0 && state == in_bm_list) {
            state = in_file;
        } else if (lStr_cmp(tagname, "doc-title") == 0 && state == in_title) {
            state = in_file_info;
        } else if (lStr_cmp(tagname, "doc-author") == 0 && state == in_author) {
            state = in_file_info;
        } else if (lStr_cmp(tagname, "doc-series") == 0 && state == in_series) {
            state = in_file_info;
        } else if (lStr_cmp(tagname, "doc-filename") == 0 && state == in_filename) {
            state = in_file_info;
        } else if (lStr_cmp(tagname, "doc-filepath") == 0 && state == in_filepath) {
            state = in_file_info;
        } else if (lStr_cmp(tagname, "doc-filesize") == 0 && state == in_filesize) {
            state = in_file_info;
        } else if (lStr_cmp(tagname, "doc-dom-version") == 0 && state == in_dom_version) {
            state = in_file_info;
        } else if (lStr_cmp(tagname, "bookmark") == 0 && state == in_bm) {
            state = in_bm_list;
            if (_curr_bookmark) {
                if (_curr_bookmark->getType() == bmkt_lastpos) {
                    _curr_file->setLastPos(_curr_bookmark);
                    delete _curr_bookmark;
                } else {
                    _curr_file->getBookmarks().add(_curr_bookmark);
                }
                _curr_bookmark = NULL;
            }
        } else if (lStr_cmp(tagname, "start-point") == 0 && state == in_start_point) {
            state = in_bm;
        } else if (lStr_cmp(tagname, "end-point") == 0 && state == in_end_point) {
            state = in_bm;
        } else if (lStr_cmp(tagname, "header-text") == 0 && state == in_header_txt) {
            state = in_bm;
        } else if (lStr_cmp(tagname, "selection-text") == 0 && state == in_selection_txt) {
            state = in_bm;
        } else if (lStr_cmp(tagname, "comment-text") == 0 && state == in_comment_txt) {
            state = in_bm;
        }
    }
    /// called on element attribute
    virtual void OnAttribute(const lChar32* nsname, const lChar32* attrname, const lChar32* attrvalue) {
        CR_UNUSED(nsname);
        if (lStr_cmp(attrname, "type") == 0 && state == in_bm) {
            static const char* tnames[] = { "lastpos", "position", "comment", "correction" };
            for (int i = 0; i < 4; i++) {
                if (lStr_cmp(attrvalue, tnames[i]) == 0) {
                    _curr_bookmark->setType((bmk_type)i);
                    return;
                }
            }
        } else if (lStr_cmp(attrname, "shortcut") == 0 && state == in_bm) {
            int n = lString32(attrvalue).atoi();
            _curr_bookmark->setShortcut(n);
        } else if (lStr_cmp(attrname, "percent") == 0 && state == in_bm) {
            int n1 = 0, n2 = 0;
            int i = 0;
            for (; attrvalue[i] >= '0' && attrvalue[i] <= '9'; i++)
                n1 = n1 * 10 + (attrvalue[i] - '0');
            if (attrvalue[i] == '.') {
                i++;
                if (attrvalue[i] >= '0' && attrvalue[i] <= '9')
                    n2 = (attrvalue[i++] - '0') * 10;
                if (attrvalue[i] >= '0' && attrvalue[i] <= '9')
                    n2 = (attrvalue[i++] - '0');
            }
            _curr_bookmark->setPercent(n1 * 100 + n2);
        } else if (lStr_cmp(attrname, "timestamp") == 0 && state == in_bm) {
            time_t n1 = 0;
            int i = 0;
            for (; attrvalue[i] >= '0' && attrvalue[i] <= '9'; i++)
                n1 = n1 * 10 + (attrvalue[i] - '0');
            _curr_bookmark->setTimestamp(n1);
        } else if (lStr_cmp(attrname, "page") == 0 && state == in_bm) {
            _curr_bookmark->setBookmarkPage(lString32(attrvalue).atoi());
        }
    }
    /// called on text
    virtual void OnText(const lChar32* text, int len, lUInt32 flags) {
        CR_UNUSED(flags);
        lString32 txt(text, len);
        switch (state) {
            case in_start_point:
                _curr_bookmark->setStartPos(txt);
                break;
            case in_end_point:
                _curr_bookmark->setEndPos(txt);
                break;
            case in_header_txt:
                _curr_bookmark->setTitleText(txt);
                break;
            case in_selection_txt:
                _curr_bookmark->setPosText(txt);
                break;
            case in_comment_txt:
                _curr_bookmark->setCommentText(txt);
                break;
            case in_author:
                _curr_file->setAuthor(txt);
                break;
            case in_title:
                _curr_file->setTitle(txt);
                break;
            case in_series:
                _curr_file->setSeries(txt);
                break;
            case in_filename:
                _curr_file->setFileName(txt);
                break;
            case in_filepath:
                _curr_file->setFilePath(txt);
                break;
            case in_filesize: {
                lInt64 size = 0;
                if (txt.atoi(size))
                    _curr_file->setFileSize(size);
                break;
            }
            case in_dom_version:
                _curr_file->setDOMversion(txt.atoi());
                break;
            default:
                break;
        }
    }
    /// destructor
    virtual ~CRHistoryFileParserCallback() {
        if (_curr_file)
            delete _curr_file;
    }
};

bool CRFileHist::loadFromStream(LVStreamRef stream) {
    CRHistoryFileParserCallback cb(this);
    LVXMLParser parser(stream, &cb);
    if (!parser.CheckFormat())
        return false;
    if (!parser.Parse())
        return false;
    return true;
}

static void putTagValue(LVStream* stream, int level, const char* tag, lString32 value) {
    for (int i = 0; i < level; i++)
        *stream << "  ";
    *stream << "<" << tag;
    if (value.empty()) {
        *stream << "/>\r\n";
    } else {
        *stream << ">" << UnicodeToUtf8(value).c_str() << "</" << tag << ">\r\n";
    }
}

static void putTag(LVStream* stream, int level, const char* tag) {
    for (int i = 0; i < level; i++)
        *stream << "  ";
    *stream << "<" << tag << ">\r\n";
}

static void putBookmark(LVStream* stream, CRBookmark* bmk) {
    static const char* tnames[] = { "lastpos", "position", "comment", "correction" };
    const char* tname = bmk->getType() >= bmkt_lastpos && bmk->getType() <= bmkt_correction ? tnames[bmk->getType()] : "unknown";
    char bmktag[256];
    snprintf(bmktag, 256, "bookmark type=\"%s\" percent=\"%d.%02d%%\" timestamp=\"%d\" shortcut=\"%d\" page=\"%d\"", tname,
             bmk->getPercent() / 100, bmk->getPercent() % 100,
             (int)bmk->getTimestamp(), (int)bmk->getShortcut(), (int)bmk->getBookmarkPage());
    putTag(stream, 3, bmktag);
    putTagValue(stream, 4, "start-point", bmk->getStartPos());
    putTagValue(stream, 4, "end-point", bmk->getEndPos());
    putTagValue(stream, 4, "header-text", bmk->getTitleText());
    putTagValue(stream, 4, "selection-text", bmk->getPosText());
    putTagValue(stream, 4, "comment-text", bmk->getCommentText());
    putTag(stream, 3, "/bookmark");
}

bool CRFileHist::saveToStream(LVStream* targetStream) {
    LVStreamRef streamref = LVCreateMemoryStream(NULL, 0, false, LVOM_WRITE);
    LVStream* stream = streamref.get();
    const char* xml_hdr = "\xef\xbb\xbf<?xml version=\"1.0\" encoding=\"utf-8\"?>\r\n<FictionBookMarks>\r\n";
    const char* xml_ftr = "</FictionBookMarks>\r\n";
    //const char * crlf = "\r\n";
    *stream << xml_hdr;
    for (int i = 0; i < _records.length(); i++) {
        CRFileHistRecord* rec = _records[i];
        putTag(stream, 1, "file");
        putTag(stream, 2, "file-info");
        putTagValue(stream, 3, "doc-title", rec->getTitle());
        putTagValue(stream, 3, "doc-author", rec->getAuthor());
        putTagValue(stream, 3, "doc-series", rec->getSeries());
        putTagValue(stream, 3, "doc-filename", rec->getFileName());
        putTagValue(stream, 3, "doc-filepath", rec->getFilePath());
        putTagValue(stream, 3, "doc-filesize", lString32::itoa((unsigned int)rec->getFileSize()));
        putTagValue(stream, 3, "doc-dom-version", lString32::itoa((unsigned int)rec->getDOMversion()));
        putTag(stream, 2, "/file-info");
        putTag(stream, 2, "bookmark-list");
        putBookmark(stream, rec->getLastPos());
        for (int j = 0; j < rec->getBookmarks().length(); j++) {
            CRBookmark* bmk = rec->getBookmarks()[j];
            putBookmark(stream, bmk);
        }
        putTag(stream, 2, "/bookmark-list");
        putTag(stream, 1, "/file");
    }
    *stream << xml_ftr;
    LVPumpStream(targetStream, stream);
    return true;
}

static void splitFName(lString32 pathname, lString32& path, lString32& name) {
    //
    int spos = -1;
    for (spos = pathname.length() - 1; spos >= 0; spos--) {
        lChar32 ch = pathname[spos];
        if (ch == '\\' || ch == '/') {
            break;
        }
    }
    if (spos >= 0) {
        path = pathname.substr(0, spos + 1);
        name = pathname.substr(spos + 1, pathname.length() - spos - 1);
    } else {
        path.clear();
        name = pathname;
    }
}

CRBookmark* CRFileHistRecord::setShortcutBookmark(int shortcut, ldomXPointer ptr) {
    if (ptr.isNull())
        return NULL;
    CRBookmark* bmk = new CRBookmark(ptr);
    bmk->setType(bmkt_pos);
    bmk->setShortcut(shortcut);
    for (int i = 0; i < _bookmarks.length(); i++) {
        if (_bookmarks[i]->getShortcut() == shortcut) {
            _bookmarks[i] = bmk;
            return bmk;
        }
    }
    _bookmarks.insert(0, bmk);
    return bmk;
}

CRBookmark* CRFileHistRecord::getShortcutBookmark(int shortcut) {
    for (int i = 0; i < _bookmarks.length(); i++) {
        if (_bookmarks[i]->getShortcut() == shortcut && _bookmarks[i]->getType() == bmkt_pos)
            return _bookmarks[i];
    }
    return NULL;
}

#define MAX_SHORTCUT_BOOKMARKS 64

/// returns first available placeholder for new bookmark, -1 if no more space
int CRFileHistRecord::getLastShortcutBookmark() {
    int last = -1;
    for (int i = 0; i < _bookmarks.length(); i++) {
        if (_bookmarks[i]->getShortcut() > 0 && _bookmarks[i]->getShortcut() > last && _bookmarks[i]->getShortcut() < MAX_SHORTCUT_BOOKMARKS && _bookmarks[i]->getType() == bmkt_pos)
            last = _bookmarks[i]->getShortcut();
    }
    return last;
}

/// returns first available placeholder for new bookmark, -1 if no more space
int CRFileHistRecord::getFirstFreeShortcutBookmark() {
    //int last = -1;
    char flags[MAX_SHORTCUT_BOOKMARKS + 1] = { 0 };
    for (int i = 0; i < _bookmarks.length(); i++) {
        if (_bookmarks[i]->getShortcut() > 0 && _bookmarks[i]->getShortcut() < MAX_SHORTCUT_BOOKMARKS && _bookmarks[i]->getType() == bmkt_pos)
            flags[_bookmarks[i]->getShortcut()] = 1;
    }
    for (int j = 1; j < MAX_SHORTCUT_BOOKMARKS; j++) {
        if (flags[j] == 0)
            return j;
    }
    return -1;
}

int CRFileHist::findEntry(const lString32& fname, const lString32& fpath, lvsize_t sz) const {
    CR_UNUSED(fpath);
    for (int i = 0; i < _records.length(); i++) {
        CRFileHistRecord* rec = _records[i];
        if (rec->getFileName().compare(fname))
            continue;
        if (rec->getFileSize() != sz) {
            CRLog::warn("CRFileHist::findEntry() Filename matched %s but sizes are different %d!=%d", LCSTR(fname), sz, rec->getFileSize());
            continue;
        }
        return i;
    }
    return -1;
}

void CRFileHist::makeTop(int index) {
    if (index <= 0 || index >= _records.length())
        return;
    CRFileHistRecord* rec = _records[index];
    for (int i = index; i > 0; i--)
        _records[i] = _records[i - 1];
    _records[0] = rec;
}

CRFileHistRecord* CRFileHist::getRecord(const lString32& fileName, size_t fileSize) {
    lString32 name;
    lString32 path;
    splitFName(fileName, path, name);
    int index = findEntry(name, path, (lvsize_t)fileSize);
    if (index >= 0) {
        return _records[index];
    }
    return NULL;
}

void CRFileHistRecord::setLastPos(CRBookmark* bmk) {
    _lastpos = *bmk;
}

void CRFileHistRecord::convertBookmarks(ldomDocument* doc, int newDOMversion) {
    // TODO: Don't call tinyNodeCollection::setDOMVersionRequested()
    // but directly use functions ldomDocument::createXPointerV1() & ldomDocument::createXPointerV2().
    for (int i = 0; i < getBookmarks().length(); i++) {
        CRBookmark* bmk = getBookmarks()[i];

        if (bmk->isValid()) {
            if (bmk->getType() != bmkt_lastpos) {
                doc->setDOMVersionRequested(getDOMversion());
                ldomXPointer p = doc->createXPointer(bmk->getStartPos());
                if (!p.isNull()) {
                    doc->setDOMVersionRequested(newDOMversion);
                    bmk->setStartPos(p.toString());
                }
                lString32 endPos = bmk->getEndPos();
                if (!endPos.empty()) {
                    doc->setDOMVersionRequested(getDOMversion());
                    p = doc->createXPointer(endPos);
                    if (!p.isNull()) {
                        doc->setDOMVersionRequested(newDOMversion);
                        bmk->setEndPos(p.toString());
                    }
                }
            }
        }
    }
    setDOMversion(newDOMversion);
}

lString32 CRBookmark::getChapterName(ldomXPointer ptr) {
    //CRLog::trace("CRBookmark::getChapterName()");
    lString32 chapter;
    int lastLevel = -1;
    bool foundAnySection = false;
    lUInt16 section_id = ptr.getNode()->getDocument()->getElementNameIndex(U"section");
    if (!ptr.isNull()) {
        ldomXPointerEx p(ptr);
        p.nextText();
        while (!p && !p.isNull()) {
            if (!p.prevElement())
                break;
            bool foundSection = p.findElementInPath(section_id) > 0;
            //(p.toString().pos("section") >=0 );
            foundAnySection = foundAnySection || foundSection;
            if (!foundSection && foundAnySection)
                continue;
            lString32 nname = p.getNode()->getNodeName();
            if (!nname.compare("title") || !nname.compare("h1") || !nname.compare("h2") || !nname.compare("h3")) {
                if (lastLevel != -1 && p.getLevel() >= lastLevel)
                    continue;
                lastLevel = p.getLevel();
                if (!chapter.empty())
                    chapter = " / " + chapter;
                chapter = p.getText(' ') + chapter;
                if (!p.parent())
                    break;
            }
        }
    }
    return chapter;
}

CRFileHistRecord* CRFileHist::savePosition(lString32 fpathname, size_t sz,
                                           const lString32& title,
                                           const lString32& author,
                                           const lString32& series,
                                           ldomXPointer ptr) {
    //CRLog::trace("CRFileHist::savePosition");
    lString32 name;
    lString32 path;
    splitFName(fpathname, path, name);
    CRBookmark bmk(ptr);
    //CRLog::trace("Bookmark created");
    int index = findEntry(name, path, (lvsize_t)sz);
    //CRLog::trace("findEntry exited");
    if (index >= 0) {
        makeTop(index);
        _records[0]->setLastPos(&bmk);
        _records[0]->setLastTime((time_t)time(0));
        return _records[0];
    }
    CRFileHistRecord* rec = new CRFileHistRecord();
    rec->setTitle(title);
    rec->setAuthor(author);
    rec->setSeries(series);
    rec->setFileName(name);
    rec->setFilePath(path);
    rec->setFileSize((lvsize_t)sz);
    rec->setLastPos(&bmk);
    rec->setLastTime((time_t)time(0));
    rec->setDOMversion(gDOMVersionCurrent);

    _records.insert(0, rec);
    //CRLog::trace("CRFileHist::savePosition - exit");
    return rec;
}

ldomXPointer CRFileHist::restorePosition(ldomDocument* doc, lString32 fpathname, size_t sz) {
    lString32 name;
    lString32 path;
    splitFName(fpathname, path, name);
    int index = findEntry(name, path, (lvsize_t)sz);
    if (index >= 0) {
        makeTop(index);
        return doc->createXPointer(_records[0]->getLastPos()->getStartPos());
    }
    return ldomXPointer();
}

CRBookmark::CRBookmark(ldomXPointer ptr)
        : _startpos(lString32::empty_str)
        , _endpos(lString32::empty_str)
        , _percent(0)
        , _type(0)
        , _shortcut(0)
        , _postext(lString32::empty_str)
        , _titletext(lString32::empty_str)
        , _commenttext(lString32::empty_str)
        , _timestamp(time_t(0))
        , _page(0) {
    //
    if (ptr.isNull())
        return;

    //CRLog::trace("CRBookmark::CRBookmark() started");
    lString32 path;

    //CRLog::trace("CRBookmark::CRBookmark() calling ptr.toPoint");
    lvPoint pt = ptr.toPoint();
    //CRLog::trace("CRBookmark::CRBookmark() calculating percent");
    ldomDocument* doc = ptr.getNode()->getDocument();
    int h = doc->getFullHeight();
    if (pt.y > 0 && h > 0) {
        if (pt.y < h) {
            _percent = (int)((lInt64)pt.y * 10000 / h);
        } else {
            _percent = 10000;
        }
    }
    //CRLog::trace("CRBookmark::CRBookmark() calling getChaptername");
    setTitleText(CRBookmark::getChapterName(ptr));
    _startpos = ptr.toString();
    //CRLog::debug("new Xpath: %s, old Xpath: %s", LCSTR(_startpos), LCSTR(ptr.toString(XPATH_USE_NAMES)));
    _timestamp = (time_t)time(0);
    lvPoint endpt = pt;
    endpt.y += 100;
    //CRLog::trace("CRBookmark::CRBookmark() creating xpointer for endp");
    ldomXPointer endptr = doc->createXPointer(endpt);
    //CRLog::trace("CRBookmark::CRBookmark() finished");
}

lString32 CRFileHistRecord::getLastTimeString(bool longFormat) {
    time_t t = getLastTime();
    tm* bt = localtime(&t);
    char str[22];
    if (!longFormat)
        snprintf(str, 22, "%02u.%02u.%04u", (uint8_t)bt->tm_mday, (uint8_t)(1 + bt->tm_mon), (uint16_t)(1900 + bt->tm_year));
    else
        snprintf(str, 22, "%02u.%02u.%04u %02u:%02u", (uint8_t)bt->tm_mday, (uint8_t)(1 + bt->tm_mon), (uint16_t)(1900 + bt->tm_year), (uint8_t)bt->tm_hour, (uint8_t)bt->tm_min);
    return Utf8ToUnicode(lString8(str));
}

#define START_TAG         "# start record"
#define END_TAG           "# end record"
#define START_TAG_BYTES   "# start record\n"
#define END_TAG_BYTES     "# end record\n"
#define ACTION_TAG        "ACTION"
#define ACTION_DELETE_TAG "DELETE"
#define ACTION_UPDATE_TAG "UPDATE"
#define FILE_TAG          "FILE"
#define TYPE_TAG          "TYPE"
#define START_POS_TAG     "STARTPOS"
#define END_POS_TAG       "ENDPOS"
#define TIMESTAMP_TAG     "TIMESTAMP"
#define PERCENT_TAG       "PERCENT"
#define SHORTCUT_TAG      "SHORTCUT"
#define TITLE_TEXT_TAG    "TITLETEXT"
#define POS_TEXT_TAG      "POSTEXT"
#define COMMENT_TEXT_TAG  "COMMENTTEXT"

static lString8 encodeText(lString32 text32) {
    if (text32.empty())
        return lString8::empty_str;
    lString8 text = UnicodeToUtf8(text32);
    lString8 buf;
    for (int i = 0; i < text.length(); i++) {
        char ch = text[i];
        switch (ch) {
            case '\\':
                buf << "\\\\";
                break;
            case '\n':
                buf << "\\n";
                break;
            case '\r':
                buf << "\\r";
                break;
            case '\t':
                buf << "\\t";
                break;
            default:
                buf << ch;
                break;
        }
    }
    return buf;
}

static lString32 decodeText(lString8 text) {
    if (text.empty())
        return lString32::empty_str;
    lString8 buf;
    bool lastControl = false;
    for (int i = 0; i < text.length(); i++) {
        char ch = buf[i];
        if (lastControl) {
            switch (ch) {
                case 'r':
                    buf.append(1, '\r');
                    break;
                case 'n':
                    buf.append(1, '\n');
                    break;
                case 't':
                    buf.append(1, '\t');
                    break;
                default:
                    buf.append(1, ch);
                    break;
            }
            lastControl = false;
            continue;
        }
        if (ch == '\\') {
            lastControl = true;
            continue;
        }
        buf.append(1, ch);
    }
    return Utf8ToUnicode(buf);
}

static int findBytes(lChar8* buf, int start, int end, const lChar8* pattern) {
    int len = lStr_len(pattern);
    for (int i = start; i <= end - len; i++) {
        int j = 0;
        for (; j < len; j++) {
            if (buf[i + j] != pattern[j])
                break;
        }
        if (j == len)
            return i;
    }
    return -1;
}

ChangeInfo::ChangeInfo(CRBookmark* bookmark, lString32 fileName, bool deleted)
        : _bookmark(bookmark ? new CRBookmark(*bookmark) : NULL)
        , _fileName(fileName)
        , _deleted(deleted) {
    _timestamp = bookmark && bookmark->getTimestamp() > 0 ? bookmark->getTimestamp() : (time_t)time(0);
}

lString8 ChangeInfo::toString() {
    lString8 buf;
    buf << START_TAG << "\n";
    buf << FILE_TAG << "=" << encodeText(_fileName) << "\n";
    buf << ACTION_TAG << "=" << (_deleted ? ACTION_DELETE_TAG : ACTION_UPDATE_TAG) << "\n";
    buf << TIMESTAMP_TAG << "=" << fmt::decimal(_timestamp * 1000) << "\n";
    if (_bookmark) {
        buf << TYPE_TAG << "=" << fmt::decimal(_bookmark->getType()) << "\n";
        buf << START_POS_TAG << "=" << encodeText(_bookmark->getStartPos()) << "\n";
        buf << END_POS_TAG << "=" << encodeText(_bookmark->getEndPos()) << "\n";
        buf << PERCENT_TAG << "=" << fmt::decimal(_bookmark->getPercent()) << "\n";
        buf << SHORTCUT_TAG << "=" << fmt::decimal(_bookmark->getShortcut()) << "\n";
        buf << TITLE_TEXT_TAG << "=" << encodeText(_bookmark->getTitleText()) << "\n";
        buf << POS_TEXT_TAG << "=" << encodeText(_bookmark->getPosText()) << "\n";
        buf << COMMENT_TEXT_TAG << "=" << encodeText(_bookmark->getCommentText()) << "\n";
    }
    buf << END_TAG << "\n";
    return buf;
}

ChangeInfo* ChangeInfo::fromString(lString8 s) {
    lString8Collection rows(s, cs8("\n"));
    if (rows.length() < 3 || rows[0] != START_TAG || rows[rows.length() - 1] != END_TAG)
        return NULL;
    ChangeInfo* ci = new ChangeInfo();
    CRBookmark bmk;
    for (int i = 1; i < rows.length() - 1; i++) {
        lString8 row = rows[i];
        int p = row.pos("=");
        if (p < 1)
            continue;
        lString8 name = row.substr(0, p);
        lString8 value = row.substr(p + 1);
        if (name == ACTION_TAG) {
            ci->_deleted = (value == ACTION_DELETE_TAG);
        } else if (name == FILE_TAG) {
            ci->_fileName = decodeText(value);
        } else if (name == TYPE_TAG) {
            bmk.setType(value.atoi());
        } else if (name == START_POS_TAG) {
            bmk.setStartPos(decodeText(value));
        } else if (name == END_POS_TAG) {
            bmk.setEndPos(decodeText(value));
        } else if (name == TIMESTAMP_TAG) {
            ci->_timestamp = value.atoi64() / 1000;
            bmk.setTimestamp(ci->_timestamp);
        } else if (name == PERCENT_TAG) {
            bmk.setPercent(value.atoi());
        } else if (name == SHORTCUT_TAG) {
            bmk.setShortcut(value.atoi());
        } else if (name == TITLE_TEXT_TAG) {
            bmk.setTitleText(decodeText(value));
        } else if (name == POS_TEXT_TAG) {
            bmk.setPosText(decodeText(value));
        } else if (name == COMMENT_TEXT_TAG) {
            bmk.setCommentText(decodeText(value));
        }
    }
    if (bmk.isValid())
        ci->_bookmark = new CRBookmark(bmk);
    if (ci->_fileName.empty() || ci->_timestamp == 0 || (!ci->_bookmark && !ci->_deleted)) {
        delete ci;
        return NULL;
    }
    return ci;
}

ChangeInfo* ChangeInfo::fromBytes(lChar8* buf, int start, int end) {
    lString8 s(buf + start, end - start);
    return fromString(s);
}

bool ChangeInfo::findNextRecordBounds(lChar8* buf, int start, int end, int& recordStart, int& recordEnd) {
    int startTagPos = findBytes(buf, start, end, START_TAG_BYTES);
    if (startTagPos < 0)
        return false;
    int endTagPos = findBytes(buf, startTagPos, end, END_TAG_BYTES);
    if (endTagPos < 0)
        return false;
    recordStart = startTagPos;
    recordEnd = endTagPos + lStr_len(END_TAG_BYTES);
    return true;
}
