#!/usr/bin/env python3
"""HAM RD ADIF bridge. Python 3 stdlib; no radio control or account password.

python3 hamrd-log-bridge.py --file /path/to/wsjtx_log.adi
New records are queued from the initial end of the file. Use --from-start only
when intentionally importing the whole file (prefer the website import wizard).
The book key is requested with getpass and is never saved to disk.
"""
import argparse
import getpass
import hashlib
import json
import os
from pathlib import Path
import re
import sqlite3
import time
import urllib.error
import urllib.request

ENDPOINT = 'https://hamrd.ru/api/station-log-bridge.php'

def records(text):
    """Yield complete ADIF records with their ending character offsets."""
    pos = start = 0
    while pos < len(text):
        opening = text.find('<', pos)
        if opening < 0:
            return
        closing = text.find('>', opening)
        if closing < 0:
            return
        tag = text[opening + 1:closing]
        pos = closing + 1
        if tag.upper() == 'EOH':
            start = pos
        elif tag.upper() == 'EOR':
            yield text[start:pos], pos
            start = pos
        else:
            match = re.fullmatch(r'[A-Za-z][A-Za-z0-9_]*:(\d+)(?::[A-Za-z])?', tag)
            if not match:
                raise ValueError('Malformed ADIF tag; fix the source file before continuing')
            size = int(match[1])
            if pos + size > len(text):
                return
            pos += size

def send(key, payload):
    request = urllib.request.Request(ENDPOINT, data=json.dumps(payload).encode(),
        headers={'Content-Type': 'application/json', 'Authorization': 'Bearer ' + key}, method='POST')
    # Refuse redirects, so credentials cannot be forwarded to another endpoint.
    class NoRedirect(urllib.request.HTTPRedirectHandler):
        def redirect_request(self, req, fp, code, msg, headers, newurl):
            return None
    with urllib.request.build_opener(NoRedirect).open(request, timeout=25) as response:
        result = json.load(response)
        if not result.get('ok'):
            raise RuntimeError('HAM RD did not accept QSO; pending record retained')
        return result

def initial_offset(path, encoding):
    # Keep a record that the logger was still writing at initial startup.
    # Scan with the length-aware parser: a literal EOR inside COMMENT is not a boundary.
    raw = path.read_bytes()
    decoder = __import__('codecs').getincrementaldecoder(encoding)()
    text = decoder.decode(raw, final=False)
    complete = 0
    for _, end in records(text):
        complete = end
    return len(text[:complete].encode(encoding))

def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--file', required=True, type=Path)
    parser.add_argument('--state', type=Path, default=Path('hamrd-bridge-state.sqlite'))
    parser.add_argument('--from-start', action='store_true')
    parser.add_argument('--encoding', choices=['utf-8', 'cp1251'], default='utf-8')
    args = parser.parse_args()
    path = args.file.expanduser().resolve(strict=True)
    key = getpass.getpass('HAM RD book key (hidden): ').strip()
    if not re.fullmatch(r'slb_[a-f0-9]{64}', key):
        raise ValueError('Invalid book key')
    previous_umask = os.umask(0o077)
    try:
        db = sqlite3.connect(args.state)
    finally:
        os.umask(previous_umask)
    db.executescript('CREATE TABLE IF NOT EXISTS cursor(scope TEXT PRIMARY KEY,offset INTEGER,prefix TEXT);'
        'CREATE TABLE IF NOT EXISTS pending(scope TEXT,mutation TEXT PRIMARY KEY,adif TEXT,done INTEGER DEFAULT 0);'
        'CREATE TABLE IF NOT EXISTS binding(path TEXT PRIMARY KEY,endpoint TEXT,user_id INTEGER,book_id INTEGER);')
    binding = db.execute('SELECT endpoint,user_id,book_id FROM binding WHERE path=?', (str(path),)).fetchone()
    verified = False
    try:
        identity = send(key, {'action': 'identity'})
        candidate = (ENDPOINT, identity['user_id'], identity['book_id'])
        if binding is not None and candidate != binding:
            raise RuntimeError('Key belongs to another book/account. Use a separate --state file.')
        binding = candidate
        with db:
            db.execute('INSERT OR IGNORE INTO binding VALUES(?,?,?,?)', (str(path), *binding))
        verified = True
    except urllib.error.HTTPError as error:
        if error.code in (401,403):
            raise SystemExit('Book key rejected. Pending queue retained; check or replace the key.')
        if binding is None:
            raise SystemExit('First setup could not verify the book. Source file unchanged.')
        print('Provider unavailable; collecting locally until verification succeeds.', flush=True)
    except (urllib.error.URLError, TimeoutError, OSError):
        if binding is None:
            raise SystemExit('First setup requires a connection to verify the book. Source file unchanged.')
        print('Offline startup: collecting locally; key will be verified before sending.', flush=True)
    scope = hashlib.sha256((str(path) + ':' + str(binding[1]) + ':' + str(binding[2])).encode()).hexdigest()
    row = db.execute('SELECT offset,prefix FROM cursor WHERE scope=?', (scope,)).fetchone()
    def fingerprint():
        with path.open('rb') as handle:
            return hashlib.sha256(handle.read(64)).hexdigest()
    if row is None:
        offset = 0 if args.from_start else initial_offset(path, args.encoding)
        with db:
            db.execute('INSERT INTO cursor VALUES(?,?,?)', (scope, offset, fingerprint()))
    print('Watching ADIF. Pending QSOs are durable; Ctrl+C stops safely.', flush=True)
    while True:
        offset, prefix = db.execute('SELECT offset,prefix FROM cursor WHERE scope=?', (scope,)).fetchone()
        # Truncation/replacement is a manual review, never silently replay old logs.
        if path.stat().st_size < offset or (offset >= 64 and fingerprint() != prefix):
            raise RuntimeError('Source file replaced or truncated. Review it, then use another --state file.')
        with path.open('rb') as handle:
            handle.seek(offset)
            chunk = handle.read(2 * 1024 * 1024)
        try:
            text = chunk.decode(args.encoding)
        except UnicodeDecodeError:
            # UTF-8 can be partially written at EOF; preserve the undecodable tail.
            text = chunk.decode(args.encoding, errors='strict') if args.encoding != 'utf-8' else __import__('codecs').getincrementaldecoder('utf-8')().decode(chunk, final=False)
        consumed = 0
        with db:
            for record, end in records(text):
                identity = hashlib.sha256((scope + ':' + str(offset + consumed) + ':' + record).encode()).hexdigest()
                db.execute('INSERT OR IGNORE INTO pending(scope,mutation,adif) VALUES(?,?,?)', (scope, identity, record))
                consumed = len(text[:end].encode(args.encoding))
            if consumed:
                db.execute('UPDATE cursor SET offset=?,prefix=? WHERE scope=?', (offset + consumed, fingerprint(), scope))
        pending = db.execute('SELECT mutation,adif FROM pending WHERE scope=? AND done=0 ORDER BY rowid LIMIT 20', (scope,)).fetchall()
        for identity, adif in pending:
            try:
                if not verified:
                    account = send(key, {'action': 'identity'})
                    if (ENDPOINT, account['user_id'], account['book_id']) != binding:
                        raise SystemExit('Key belongs to another book. Pending queue retained; use the correct key.')
                    verified = True
                result = send(key, {'mutation': identity, 'adif': adif})
                with db:
                    db.execute("UPDATE pending SET done=1,adif='' WHERE mutation=?", (identity,))
                print('Saved QSO #' + str(result['id']), flush=True)
            except urllib.error.HTTPError as error:
                # Do not print request/headers/credentials or remote HTML errors.
                print('HTTP', error.code, '— QSO remains queued. Check the book/key/source.', flush=True)
                if 400 <= error.code < 500 and error.code != 429:
                    raise SystemExit('Resolve this request before restarting; no records were discarded.')
                break
            except (urllib.error.URLError, TimeoutError, OSError):
                print('Network unavailable; retrying the same QSO later.', flush=True)
                break
        time.sleep(10)

if __name__ == '__main__':
    try:
        main()
    except KeyboardInterrupt:
        print('\nStopped. Pending queue retained.')
