"""Install ijson==3.4.0 websockets==15.0.1. One catalog writer per local database."""
import gzip
import json
import os
import sys
import time
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
from merchant import API, open_store


class Resync(Exception):
    pass


def checkpoint(db):
    row = db.execute('SELECT body FROM metadata WHERE id=1').fetchone()
    return json.loads(row[0]) if row else {}


def save(db, meta):
    db.execute('INSERT OR REPLACE INTO metadata VALUES (1, ?)', (json.dumps(meta),))


def install_snapshot(db, items, meta):
    db.execute('BEGIN IMMEDIATE')
    try:
        db.execute('DROP TABLE IF EXISTS catalog_stage')
        db.execute('CREATE TABLE catalog_stage(id TEXT PRIMARY KEY, body TEXT)')
        count = 0
        for item in items:
            if not isinstance(item.get('id'), str):
                raise ValueError('Invalid catalog item')
            db.execute('INSERT INTO catalog_stage VALUES (?, ?)', (item['id'], json.dumps(item)))
            count += 1
        if meta['count'] != count:
            raise ValueError('Incomplete snapshot or item count mismatch')
        db.execute('DROP TABLE catalog')
        db.execute('ALTER TABLE catalog_stage RENAME TO catalog')
        save(db, {**meta, 'snapshot_at': time.time()})
        db.commit()
    except BaseException:
        db.rollback()
        raise


def download_snapshot(db, force=False):
    import ijson
    old = checkpoint(db)
    headers = {'Authorization': 'Bearer ' + os.environ['TRADEON_API_KEY'], 'Accept-Encoding': 'gzip'}
    if not force and old.get('etag'):
        headers['If-None-Match'] = old['etag']
    try:
        response = urlopen(Request(API + '/api/v1/merchant/items/full-dump', headers=headers), timeout=120)
    except HTTPError as error:
        if error.code == 304 and old.get('etag'):
            error.close()
            # Its headers describe the cached snapshot, not the cursor advanced by WS.
            return old
        raise
    with response:
        meta = {'etag': response.headers.get('ETag'), 'cursor': response.headers.get('X-Catalog-Cursor'),
                'epoch': response.headers.get('X-Catalog-Epoch'), 'count': int(response.headers['X-Items-Count'])}
        body = gzip.GzipFile(fileobj=response) if response.headers.get('Content-Encoding') == 'gzip' else response
        install_snapshot(db, ijson.items(body, 'items.item', use_float=True), meta)
    return checkpoint(db)


def apply_frame(db, frame, send):
    meta = checkpoint(db)
    if frame.get('v') != 1:
        raise ValueError('Unsupported WS version')
    if frame['type'] == 'hello':
        if frame['epoch'] != meta.get('epoch'):
            raise Resync('Epoch changed')
        return  # Never persist hello.cursor as processed.
    if frame['type'] == 'ping':
        send(json.dumps({'type': 'pong'}))
        if frame.get('engine') == 'degraded':
            raise Resync('Engine degraded: use full dumps')
        return
    if frame['type'] != 'batch':
        return
    if frame['epoch'] != meta.get('epoch'):
        raise Resync('Epoch changed')
    if frame['cursor'] == meta.get('cursor'):
        return
    with db:
        for event in frame['events']:
            if event['type'] == 'offer.added':
                if event['id'] != event['offer']['id']:
                    raise ValueError('Offer ID mismatch')
                db.execute('INSERT OR REPLACE INTO catalog VALUES (?, ?)', (event['id'], json.dumps(event['offer'])))
            elif event['type'] == 'offer.removed':
                db.execute('DELETE FROM catalog WHERE id=?', (event['id'],))
            elif event['type'] == 'offer.price_changed':
                row = db.execute('SELECT body FROM catalog WHERE id=?', (event['id'],)).fetchone()
                if row:
                    db.execute('UPDATE catalog SET body=? WHERE id=?',
                               (json.dumps({**json.loads(row[0]), 'price': event['price']}), event['id']))
        save(db, {**meta, 'cursor': frame['cursor']})


def recovery(code):
    return 'stop' if code == 4003 else 'snapshot' if code == 4001 else 'reconnect'


def session(db):
    from websockets.sync.client import connect
    from websockets.exceptions import ConnectionClosed, InvalidStatus
    meta = checkpoint(db)
    try:
        with connect(API.replace('https:', 'wss:') + '/api/v1/merchant/ws?cursor=' + quote(meta['cursor'], safe=''),
                     additional_headers={'Authorization': 'Bearer ' + os.environ['TRADEON_API_KEY']},
                     max_size=64 * 1024 * 1024, proxy=None) as ws:
            while time.time() - meta['snapshot_at'] < 3600:
                try:
                    raw = ws.recv(timeout=20)
                except TimeoutError:
                    continue
                apply_frame(db, json.loads(raw), ws.send)
            return 'snapshot'  # Hourly refresh covers attributes absent from WS.
    except Resync:
        return 'snapshot'
    except InvalidStatus as error:
        return 'stop' if error.response.status_code in (400, 401, 403) else 'reconnect'
    except ConnectionClosed as error:
        return recovery(error.rcvd.code if error.rcvd else 1006)
    except OSError:
        return 'reconnect'


def run(db, download=download_snapshot, stream=session, wait=time.sleep):
    from ijson.common import JSONError
    needs_snapshot = not checkpoint(db).get('cursor')
    delay = 1
    while True:
        if needs_snapshot:
            try:
                meta = download(db, force=True)
            except (OSError, ValueError, EOFError, JSONError) as error:
                print(error)
                wait(120)
                continue
            if not meta.get('cursor') or not meta.get('epoch'):
                wait(120)
                continue
        mode = stream(db)
        if mode == 'stop':
            raise RuntimeError('Connection stopped: fix credentials or connection limit before restarting')
        needs_snapshot = mode == 'snapshot'
        wait(120 if needs_snapshot else delay)
        delay = min(30, delay * 2)


if __name__ == '__main__':
    database = open_store()
    try:
        if sys.argv[1:] == ['snapshot']:
            print(download_snapshot(database))
        else:
            run(database)
    finally:
        database.close()
