"""Unit tests for nostr.py."""
from contextlib import contextmanager
import copy
from datetime import timedelta
import logging
import secrets
from unittest.mock import patch
from urllib.parse import urlparse

from oauth_dropins.webutil import testutil
from oauth_dropins.webutil.testutil import requests_response
from oauth_dropins.webutil import util
from oauth_dropins.webutil.util import HTTP_TIMEOUT, json_dumps, json_loads
import requests
from secp256k1 import PrivateKey, PublicKey
from websockets.exceptions import ConnectionClosedOK, ConnectionClosedError

from .. import nostr
from ..nostr import (
  BECH32_RE,
  bech32_decode,
  bech32_encode,
  from_as1,
  id_for,
  id_to_uri,
  is_bech32,
  KIND_ARTICLE,
  KIND_AUTH,
  KIND_CONTACTS,
  KIND_DELETE,
  KIND_NOTE,
  KIND_PROFILE,
  KIND_REACTION,
  KIND_REPOST,
  to_as1,
  uri_for,
  uri_to_id,
)

NOW_TS = int(testutil.NOW.timestamp())
NOW_ISO = testutil.NOW.isoformat()
THEN_TS = NOW_TS - 1
THEN_ISO = (testutil.NOW - timedelta(seconds=1)).isoformat()

ID = '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'
URI = 'nostr:note180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkws4c58hj'

PRIVKEY = '1fb759a121ece5e22da48cb20989813fddfc9b2896a4e0207b4ffdd9a0bd189b'
# note that this doesn't have the original 0x03 prefix generated by the
# secp256k1 library! we add it back (as 0x02) in sign() and verify()
# https://github.com/snarfed/bridgy-fed/issues/446#issuecomment-2927452801
PUBKEY = '32c0619b9f66600bc928eb0680aa578a41fc16bf3bfb600d41c6ce6733cd530c'
# bech32-encoded keys
NPUB_URI = 'nostr:npub1xtqxrxulvesqhjfgavrgp2jh3fqlc94l80akqr2pcm8xwv7d2vxqdvp2h2'
NSEC_URI = 'nostr:nsec1r7m4ngfpanj7ytdy3jeqnzvp8lwlexegj6jwqgrmfl7ang9arzdsv9gn7f'
# signature of NOTE_NOSTR with PRIVKEY
SIG = '65b42db33486f669fa4dff3dba2ed914dcda886d47177a747e5e574e1a87cd4da23b54350dba758ecd91d48625f5345c8516458c76bebf60b0de89d12fa76a11'

NOTE_NOSTR = {
  'kind': KIND_NOTE,
  'id': '4a57c7a1dde3bfe13076db485c4f09756e54447f6389dbf6864d4139bc40a214',
  'pubkey': PUBKEY,
  'content': 'Something to say',
  'tags': [],
  'created_at': 1641092645,
  'sig': SIG,
}
NOTE_AS1 = {
  'objectType': 'note',
  'id': 'nostr:note1fftu0gwauwl7zvrkmdy9cncfw4h9g3rlvwyaha5xf4qnn0zq5g2qrlw3zf',
  'author': NPUB_URI,
  'content': 'Something to say',
  'published': '2022-01-02T03:04:05+00:00',
}


logger = logging.getLogger(__name__)


class FakeConnection:
  """Fake of :class:`websockets.sync.client.ClientConnection`."""

  @classmethod
  def reset(cls):
    cls.relays = []
    cls.sent = []
    cls.to_receive = []
    cls.closed = False
    cls.recv_err = cls.send_err = None

  @classmethod
  def send(cls, msg):
    if cls.send_err:
      raise cls.send_err

    assert not cls.closed
    cls.sent.append(json_loads(msg))
    logger.info(msg)

  @classmethod
  def recv(cls, timeout=None):
    assert timeout == HTTP_TIMEOUT

    if not cls.to_receive:
      closed = True
      raise cls.recv_err or ConnectionClosedOK(None, None)

    msg = cls.to_receive.pop(0)
    logger.info(msg)
    return json_dumps(msg)


@contextmanager
def fake_connect(uri, open_timeout=None, close_timeout=None, **kwargs):
  """Fake of :func:`websockets.sync.client.connect`."""
  assert open_timeout == HTTP_TIMEOUT
  assert close_timeout == HTTP_TIMEOUT
  FakeConnection.relays.append(uri)
  FakeConnection.remote_address = (urlparse(uri).netloc, 'port')
  yield FakeConnection


class NostrTest(testutil.TestCase):

  def setUp(self):
    super().setUp()
    FakeConnection.reset()

  def test_id_for(self):
    event = {
        'pubkey': 'fed987',
        'created_at': NOW_TS,
        'kind': KIND_NOTE,
        'content': 'My plain text',
    }
    id = '9adfa2330b391539f46548ff2e088ea964a2f7374898c7335a86e914cbf2e769'

    self.assertEqual(id, id_for(event))

    event['sig'] = 'foo'
    self.assertEqual(id, id_for(event))

  def test_uri_for(self):
    self.assertEqual('nostr:note14vfqpdvxk8',
                     uri_for({'kind': KIND_NOTE, 'id': 'ab12'}))
    self.assertEqual('nostr:nprofile14vfql8g7xk',
                     uri_for({'kind': KIND_PROFILE, 'id': 'ab12'}))
    self.assertEqual('nostr:nevent14vfqwk95np',
                     uri_for({'kind': KIND_DELETE, 'id': 'ab12'}))

  def test_id_to_uri(self):
    self.assertEqual(URI, id_to_uri('note', ID))

  def test_uri_to_id(self):
    self.assertEqual(ID, uri_to_id(URI))
    self.assertEqual('http://not/nostr', uri_to_id('http://not/nostr'))

  def test_is_bech32(self):
    self.assertTrue(is_bech32('nostr:npubabc'))
    self.assertTrue(is_bech32('neventabc'))
    self.assertFalse(is_bech32('abc'))
    self.assertFalse(is_bech32(None))

  def test_id_and_asign(self):
    event = copy.deepcopy(NOTE_NOSTR)
    del event['id']
    del event['sig']
    nostr.id_and_sign(event, NSEC_URI)
    self.assertEqual(SIG, event['sig'])

  def test_verify(self):
    missing_sig = copy.copy(NOTE_NOSTR)
    del missing_sig['sig']

    for bad in (
        {},
        missing_sig,
        {**NOTE_NOSTR, 'sig': None},
        {**NOTE_NOSTR, 'id': 'bad', 'sig': SIG},
        {**NOTE_NOSTR, 'pubkey': 'bad', 'sig': SIG},
    ):
      with self.subTest(event=bad):
        self.assertFalse(nostr.verify(bad))

    self.assertTrue(nostr.verify(NOTE_NOSTR))

  def test_pubkey_from_privkey(self):
    self.assertEqual(PUBKEY, nostr.pubkey_from_privkey(PRIVKEY))

  def test_to_from_as1_actor_profile(self):
    person = {
      'objectType': 'person',
      'id': NPUB_URI,
      'displayName': 'Alice',
      'summary': 'It me',
      'image': [
        'http://alice/pic',
        {
          'url': 'http://ban/ner',
          'objectType': 'featured',
        },
      ],
      'username': 'alice.com',
      'url': 'http://ali/ce',
      'urls': [
        'http://ali/ce',
        'https://github.com/semisol',
        'https://twitter.com/semisol_public',
        'https://bitcoinhackers.org/@semisol',
        'https://t.me/1087295469',
      ],
      'published': NOW_ISO,
    }
    event = {
      'kind': KIND_PROFILE,
      'pubkey': PUBKEY,
      'id': '5c3dbf8540d62ceee2b8c6f4dde6c732ec13f795f4ad8b46aff5b653a6265a51',
      'content': json_dumps({
        'name': 'Alice',
        'about': 'It me',
        'picture': 'http://alice/pic',
        'nip05': '_@alice.com',
        # https://nips.nostr.com/24
        'banner': 'http://ban/ner',
        'website': 'http://ali/ce',
      }, sort_keys=True),
      'tags': [
        ['i', 'github:semisol', '-'],
        ['i', 'twitter:semisol_public', '-'],
        ['i', 'mastodon:bitcoinhackers.org/@semisol', '-'],
        ['i', 'telegram:1087295469', '-'],
      ],
      'created_at': NOW_TS,
    }
    self.assert_equals(person, to_as1(event))

    # we don't try to detect which URLs might be Mastodon
    del event['tags'][2]
    self.assert_equals(event, from_as1(person))

  def test_from_as1_minimal(self):
    self.assert_equals({
      'kind': KIND_PROFILE,
      'content': json_dumps({'name': 'Alice'}),
      'tags': [],
    }, from_as1({
      'objectType': 'person',
      'displayName': 'Alice',
    }))

  def test_from_as1_username_not_user_at_domain_no_nip05(self):
    self.assert_equals({
      'kind': KIND_PROFILE,
      'content': json_dumps({'name': 'Alice'}),
      'tags': [],
    }, from_as1({
      'objectType': 'person',
      'displayName': 'Alice',
      'username': 'foo',
    }))

  def test_from_as1_actor_privkey_sets_pubkey(self):
    self.assert_equals({
      'kind': KIND_PROFILE,
      'id': '8be34ca85471dcb2306ca005182d4468eede8e3a979f84b80f1a9616e84f4c74',
      'pubkey': PUBKEY,
      'content': json_dumps({'name': 'Alice'}),
      'tags': [],
      'sig': '54173e03ea1608c1c99b40532a68c824c3e2558628286d13271277f8811d08823484d4708a299182310c2a5480aa3966772c99214531937437fc900a361288f0',
      'created_at': NOW_TS,
    }, from_as1({
      'objectType': 'person',
      'displayName': 'Alice',
    }, privkey=NSEC_URI))

  def test_to_as1_profile_bad_nip05(self):
    self.assert_equals({
      'objectType': 'person',
      'id': NPUB_URI,
    }, to_as1({
      'kind': KIND_PROFILE,
      'pubkey': PUBKEY,
      'content': '{"nip05": {}}',
    }))

  def test_to_as1_profile_no_content(self):
    self.assert_equals({
      'objectType': 'person',
      'id': NPUB_URI,
    }, to_as1({
      'kind': KIND_PROFILE,
      'pubkey': PUBKEY,
    }))

  def test_to_from_as1_note(self):
    note = {
      'objectType': 'note',
      'id': 'nostr:note1fftu0gwauwl7zvrkmdy9cncfw4h9g3rlvwyaha5xf4qnn0zq5g2qrlw3zf',
      'author': NPUB_URI,
      'content': 'Something to say',
      'published': NOW_ISO,
    }
    event = {
      'kind': KIND_NOTE,
      'id': '4a57c7a1dde3bfe13076db485c4f09756e54447f6389dbf6864d4139bc40a214',
      'pubkey': PUBKEY,
      'content': 'Something to say',
      'created_at': NOW_TS,
      'tags': [],
    }
    self.assert_equals(note, to_as1(event))
    self.assert_equals(event, from_as1(note))

  def test_to_from_as1_note_html_content(self):
    self.assert_equals({
      **NOTE_NOSTR,
      'content': 'hi _there_\n\n  * 1\n  * 2\n',
    }, from_as1({
      **NOTE_AS1,
      'content': '<p>hi <em>there</em></p><ul><li>1<li>2</ul>',
    }), ignore=['id', 'sig'])

  def test_from_as1_post_activity(self):
    self.assert_equals(NOTE_NOSTR, from_as1({
      'objectType': 'activity',
      'verb': 'post',
      'object': NOTE_AS1,
    }), ignore=['sig'])

  def test_from_as1_update_activity(self):
    self.assert_equals(NOTE_NOSTR, from_as1({
      'objectType': 'activity',
      'verb': 'update',
      'object': NOTE_AS1,
    }), ignore=['sig'])

  def test_from_as1_post_activity_with_proxy_tag(self):
    self.assert_equals({
      **NOTE_NOSTR,
      'tags': [['proxy', 'https://example.com/note/123', 'activitypub']],
    }, from_as1({
      'objectType': 'activity',
      'verb': 'post',
      'object': {
        **NOTE_AS1,
        'id': 'https://example.com/note/123',
      },
    }, from_protocol='activitypub'), ignore=['id', 'sig'])

  def test_from_as1_reject_activity_not_implemented(self):
    with self.assertRaises(NotImplementedError):
      from_as1({
        'objectType': 'activity',
        'verb': 'reject',
        'object': NOTE_AS1,
      })

  def test_from_as1_with_privkey(self):
    self.assert_equals(NOTE_NOSTR, from_as1(NOTE_AS1, NSEC_URI))

  def test_from_as1_privkey_sets_pubkey(self):
    self.assert_equals(NOTE_NOSTR, from_as1({
      **NOTE_AS1,
      'author': None,
    }, NSEC_URI))

  def test_to_from_as1_note_subject_tag(self):
    note = {
      'objectType': 'note',
      'content': 'Something to say',
      'title': 'my thing',
    }
    event = {
      'kind': KIND_NOTE,
      'content': 'Something to say',
      'tags': [
        ['title', 'my thing'],
        ['subject', 'my thing'],
      ],
    }
    self.assert_equals(note, to_as1(event))
    self.assert_equals(event, from_as1(note))

  def test_to_from_as1_note_with_hashtag(self):
    note = {
      'objectType': 'note',
      'id': 'nostr:note14l6czwmx00j20e9y6hf3j6ufsu2s7eenr09esgj5nd0the4gg0hs6r3z83',
      'author': NPUB_URI,
      'content': 'Something to say',
      'published': NOW_ISO,
      'tags': [{
        'objectType': 'hashtag',
        'displayName': 'foo',
      }, {
        'objectType': 'hashtag',
        'displayName': 'bar',
      }],
    }
    event = {
      'kind': KIND_NOTE,
      'id': 'aff5813b667be4a7e4a4d5d3196b8987150f67331bcb9822549b5ebbe6a843ef',
      'pubkey': PUBKEY,
      'content': 'Something to say',
      'created_at': NOW_TS,
      'tags': [
        ['t', 'foo'],
        ['t', 'bar'],
      ],
    }
    self.assert_equals(note, to_as1(event))
    self.assert_equals(event, from_as1(note))

  def test_to_from_as1_note_with_image(self):
    note = {
      'objectType': 'note',
      'id': 'nostr:note1jxrmjm4etr7h8g4kxtfg90y487z2fnwdkvgr3n4hjcexgchjzw6sqyvauu',
      'author': NPUB_URI,
      'content': 'Something to say',
      'published': NOW_ISO,
      'image': [
        'http://pic/1.png',
        {
          'objectType': 'image',
          'url': 'http://pic/2',
          'mimeType': 'image/jpeg',
          'displayName': 'my alt text',
        },
      ],
    }
    event = {
      'kind': KIND_NOTE,
      'id': '9187b96eb958fd73a2b632d282bc953f84a4cdcdb31038ceb796326462f213b5',
      'pubkey': PUBKEY,
      'content': 'Something to say http://pic/1.png http://pic/2',
      'created_at': NOW_TS,
      'tags': [[
        'imeta',
        'url http://pic/1.png',
        'm image/png',
      ], [
        'imeta',
        'url http://pic/2',
        'm image/jpeg',
        'alt my alt text',
      ]],
    }

    self.assert_equals(event, from_as1(note))

    note['image'][0] = {
      'objectType': 'image',
      'url': 'http://pic/1.png',
      'mimeType': 'image/png',
    }
    self.assert_equals(note, to_as1(event))

  def test_to_from_as1_note_with_video_audio(self):
    note = {
      'objectType': 'note',
      'id': 'nostr:note1pp5kxadk3fa7vscpa6wr9yvmls2c2u3rr7n7wm6sx86g4eng0k3s9a78rl',
      'author': NPUB_URI,
      'content': 'Something to say',
      'published': NOW_ISO,
      'attachments': [{
        'objectType': 'audio',
        'stream': {'url': 'http://a/podcast.mp3'},
      }, {
        'objectType': 'video',
        # TODO
        # 'displayName': 'my alt text',
        'stream': {'url': 'http://a/vidjo.mov'},
      }],
    }
    event = {
      'kind': KIND_NOTE,
      'id': '08696375b68a7be64301ee9c32919bfc158572231fa7e76f5031f48ae6687da3',
      'pubkey': PUBKEY,
      'content': 'Something to say http://a/podcast.mp3 http://a/vidjo.mov',
      'created_at': NOW_TS,
      'tags': [[
        'imeta',
        'url http://a/podcast.mp3',
        'm audio/mpeg',
      ], [
        'imeta',
        'url http://a/vidjo.mov',
        'm video/quicktime',
      ]],
    }

    self.assert_equals(event, from_as1(note))

    note['attachments'][0]['stream']['mimeType'] = 'audio/mpeg'
    note['attachments'][1]['stream']['mimeType'] = 'video/quicktime'
    self.assert_equals(note, to_as1(event))

  def test_to_from_as1_note_with_location(self):
    note = {
      'objectType': 'note',
      'id': 'nostr:note1kml0zacf4ej7m9l6275hqh70pkyffrt9jdvcjkz9zmvw3xj6crrsy4p42x',
      'author': NPUB_URI,
      'content': 'Something to say',
      'published': NOW_ISO,
      'location': {
        'displayName': 'my house',
      },
    }
    event = {
      'kind': KIND_NOTE,
      'id': 'b6fef17709ae65ed97fa57a9705fcf0d88948d65935989584516d8e89a5ac0c7',
      'pubkey': PUBKEY,
      'content': 'Something to say',
      'created_at': NOW_TS,
      'tags': [
        ['location', 'my house'],
      ],
    }
    self.assert_equals(note, to_as1(event))
    self.assert_equals(event, from_as1(note))

  def test_to_from_as1_article(self):
    note = {
      'objectType': 'article',
      'id': 'nostr:note15ftymxrdxn506ygx66zvke7j3pjp35lnu7dfcfrn7l6kdz9f634q9m2crn',
      'author': NPUB_URI,
      'title': 'a thing',
      'summary': 'about the thing',
      'content': 'Something to say',
      'published': NOW_ISO,
    }
    event = {
      'kind': KIND_ARTICLE,
      'id': 'a2564d986d34e8fd1106d684cb67d2886418d3f3e79a9c2473f7f56688a9d46a',
      'pubkey': PUBKEY,
      'content': 'Something to say',
      'created_at': NOW_TS,
      'tags': [
        # TODO: NIP-33 'd' tag for slug
        ['published_at', str(NOW_TS)],
        ['title', 'a thing'],
        ['subject', 'a thing'],
        ['summary', 'about the thing'],
      ],
    }
    self.assert_equals(note, to_as1(event))
    self.assert_equals(event, from_as1(note))

  def test_from_as1_with_proxy_tag(self):
    self.assert_equals({
      **NOTE_NOSTR,
      'tags': [['proxy', 'at://did:plc:123/app.bsky.feed.post/456', 'atproto']],
    }, from_as1({
      **NOTE_AS1,
      'id': 'at://did:plc:123/app.bsky.feed.post/456',
    }, from_protocol='atproto'), ignore=['id', 'sig'])

    # no id in input AS2 object
    self.assert_equals(NOTE_NOSTR, from_as1(NOTE_AS1, from_protocol='activitypub'),
                      ignore=['id', 'sig'])

  def test_to_from_as1_reply(self):
    reply = {
      'objectType': 'note',
      'id': 'nostr:note1nrjs3nf6lqjt69z3wm3sw99vm8m3yqpafut866prz2c9wmhjckcq87dgct',
      'author': NPUB_URI,
      'published': NOW_ISO,
      'content': 'I hereby reply',
      'inReplyTo': 'nostr:nevent1xnxsm5fasn',
    }
    event = {
      'kind': KIND_NOTE,
      'id': '98e508cd3af824bd145176e30714acd9f712003d4f167d682312b0576ef2c5b0',
      'pubkey': PUBKEY,
      'content': 'I hereby reply',
      'tags': [
        ['e', '34cd', '', 'reply'],
      ],
      'created_at': NOW_TS,
    }

    self.assert_equals(reply, to_as1(event))
    self.assert_equals(event, from_as1(reply))

    event['tags'] = [
        ['e', '34cd', 'reelaay', 'reply'],
    ]
    self.assert_equals(event, from_as1(reply, remote_relay='reelaay'), ignore=['id'])

  def test_to_from_as1_repost(self):
    note_uri = 'nostr:note1yr87vss2mtw7esxwfgkepte7q0jrrfwnpcvmkk0r0z7eg8vtlfkq8t0m83'
    repost = {
      'objectType': 'activity',
      'verb': 'share',
      'published': NOW_ISO,
      'object': {
        'objectType': 'note',
        'id': note_uri,
        'author': NPUB_URI,
        'content': 'The orig post',
        'published': THEN_ISO,
      },
    }

    post_id = '20cfe6420adaddecc0ce4a2d90af3e03e431a5d30e19bb59e378bd941d8bfa6c'
    event = {
      'kind': KIND_REPOST,
      'content': json_dumps({
        'kind': KIND_NOTE,
        'id': post_id,
        'pubkey': PUBKEY,
        'content': 'The orig post',
        'created_at': THEN_TS,
        'tags': [],
      }, sort_keys=True),
      'tags': [
        ['e', post_id, '', 'mention'],
        ['p', PUBKEY],
      ],
      'created_at': NOW_TS,
    }

    self.assert_equals(repost, to_as1(event))
    self.assert_equals(event, from_as1(repost))

    event['tags'][0:1] = [
        ['e', post_id, 'reelaay', 'mention'],
    ]
    self.assert_equals(event, from_as1(repost, remote_relay='reelaay'), ignore=['id'])

    del event['content']
    repost['object'] = note_uri
    self.assert_equals(repost, to_as1(event))

  def test_to_from_as1_like(self):
    like = {
      'objectType': 'activity',
      'verb': 'like',
      'published': NOW_ISO,
      'object': 'nostr:nevent1xnxsm5fasn',
    }
    event = {
      'kind': KIND_REACTION,
      'content': '+',
      'tags': [['e', '34cd']],
      'created_at': NOW_TS,
    }

    self.assert_equals(like, to_as1(event))
    self.assert_equals(event, from_as1(like))

  def test_to_from_as1_dislike(self):
    like = {
      'objectType': 'activity',
      'verb': 'dislike',
      'published': NOW_ISO,
      'object': 'nostr:nevent1xnxsm5fasn',
    }
    event = {
      'kind': KIND_REACTION,
      'content': '-',
      'tags': [['e', '34cd']],
      'created_at': NOW_TS,
    }

    self.assert_equals(like, to_as1(event))
    self.assert_equals(event, from_as1(like))

  def test_to_from_as1_reaction(self):
    react = {
      'objectType': 'activity',
      'verb': 'react',
      'content': '😀',
      'published': NOW_ISO,
      'object': 'nostr:nevent1xnxsm5fasn',
    }
    event = {
      'kind': KIND_REACTION,
      'content': '😀',
      'tags': [['e', '34cd']],
      'created_at': NOW_TS,
    }

    self.assert_equals(react, to_as1(event))
    self.assert_equals(event, from_as1(react))

  def test_to_from_as1_delete(self):
    delete = {
      'objectType': 'activity',
      'verb': 'delete',
      'published': NOW_ISO,
      'object': 'nostr:nevent1xnxsm5fasn',
      'content': 'a note about the delete',
    }
    event = {
      'kind': KIND_DELETE,
      'content': 'a note about the delete',
      'tags': [['e', '34cd']],
      'created_at': NOW_TS,
    }

    self.assert_equals(delete, to_as1(event))
    self.assert_equals(event, from_as1(delete))

  def test_to_from_as1_followings(self):
    follow = {
      'objectType': 'activity',
      'verb': 'follow',
      'id': 'nostr:nevent16kakmkwdchnc9xf0r076zzup0lcvjj2pnk5myxka75qftvtd4vdqnvqcvt',
      'actor': NPUB_URI,
      'published': NOW_ISO,
      'object': [
        NPUB_URI,
        {'id': NPUB_URI, 'displayName': 'bob'},
      ],
      'content': 'not important',
    }
    event = {
      'kind': KIND_CONTACTS,
      'id': 'd5bb6dd9cdc5e782992f1bfda10b817ff0c949419da9b21addf50095b16dab1a',
      'pubkey': PUBKEY,
      'content': 'not important',
      'tags': [
        ['p', PUBKEY, '', ''],
        ['p', PUBKEY, '', 'bob'],
      ],
      'created_at': NOW_TS,
    }

    self.assert_equals(follow, to_as1(event))
    self.assert_equals(event, from_as1(follow))

    event['tags'] = [
        ['p', PUBKEY, 'reelaay', ''],
        ['p', PUBKEY, 'reelaay', 'bob'],
    ]
    self.assert_equals(event, from_as1(follow, remote_relay='reelaay'), ignore=['id'])

  @patch('requests.get', return_value=requests_response({'names': {'alice': 'b0635d'}}))
  def test_nip05_to_npub(self, mock_get):
    npub = nostr.nip05_to_npub('alice@example.com')
    self.assertEqual('npub1kp346yk70h6', npub)
    mock_get.assert_any_call('https://example.com/.well-known/nostr.json?name=alice',
                             timeout=HTTP_TIMEOUT, stream=True,
                             headers={'User-Agent': util.user_agent})

  @patch('requests.get', return_value=requests_response({'names': {'_': 'b0635d'}}))
  def test_nip05_to_npub_underscore_username(self, mock_get):
    npub = nostr.nip05_to_npub('_@example.com')
    self.assertEqual('npub1kp346yk70h6', npub)
    mock_get.assert_any_call('https://example.com/.well-known/nostr.json?name=_',
                             timeout=HTTP_TIMEOUT, stream=True,
                             headers={'User-Agent': util.user_agent})

  @patch('requests.get', return_value=requests_response({'names': {'_': 'b0635d'}}))
  def test_nip05_to_npub_bare_domain(self, mock_get):
    npub = nostr.nip05_to_npub('example.com')
    self.assertEqual('npub1kp346yk70h6', npub)
    mock_get.assert_any_call('https://example.com/.well-known/nostr.json?name=_',
                             timeout=HTTP_TIMEOUT, stream=True,
                             headers={'User-Agent': util.user_agent})

  @patch('requests.get', return_value=requests_response({'names': {'bob': 'b0635d'}}))
  def test_nip05_to_npub_user_not_found(self, mock_get):
    with self.assertRaises(ValueError) as cm:
      nostr.nip05_to_npub('alice@example.com')

    self.assertEqual('User alice not found at example.com', str(cm.exception))

  @patch('requests.get', return_value=requests_response('', status=404))
  def test_nip05_to_npub_http_error(self, mock_get):
    with self.assertRaises(requests.HTTPError):
      nostr.nip05_to_npub('alice@example.com')

    mock_get.assert_any_call('https://example.com/.well-known/nostr.json?name=alice',
                             timeout=HTTP_TIMEOUT, stream=True,
                             headers={'User-Agent': util.user_agent})

  @patch('requests.get', return_value=requests_response('not json'))
  def test_nip05_to_npub_invalid_json(self, mock_get):
    with self.assertRaises(ValueError):
      nostr.nip05_to_npub('alice@example.com')

    mock_get.assert_any_call('https://example.com/.well-known/nostr.json?name=alice',
                             timeout=HTTP_TIMEOUT, stream=True,
                             headers={'User-Agent': util.user_agent})

  def test_nip05_to_npub_invalid_input(self):
    for bad in ('', 'too@many@ats', 'alice@', '@example.com'):
      with self.subTest(nip05=bad):
        with self.assertRaises(ValueError):
          nostr.nip05_to_npub(bad)


class ClientTest(testutil.TestCase):
  last_token = None

  def setUp(self):
    super().setUp()

    self.last_token = 0
    self.mox.stubs.Set(secrets, 'token_urlsafe', self.token)

    FakeConnection.reset()

    nostr.connect = fake_connect

    self.nostr = nostr.Nostr(['ws://relay'], privkey=NSEC_URI)

  def token(self, length):
    self.last_token += 1
    return f'towkin {self.last_token}'

  def test_constructor_without_privkey(self):
    nostr.Nostr(['ws://relay'])  # just check that we don't crash

  def test_activity_id(self):
    FakeConnection.to_receive = [
      ['EVENT', 'towkin 1', NOTE_NOSTR],
      ['EOSE', 'towkin 1'],
      ['not', 'reached']
    ]

    self.assert_equals([NOTE_AS1], self.nostr.get_activities(activity_id='ab12'))

    self.assertEqual(['ws://relay'], FakeConnection.relays)
    self.assert_equals([
      ['REQ', 'towkin 1', {'ids': ['ab12'], 'limit': 20}],
      ['CLOSE', 'towkin 1'],
    ], FakeConnection.sent)
    self.assertEqual([['not', 'reached']], FakeConnection.to_receive)

  def test_user_id(self):
    events = [nostr.id_and_sign({
        **NOTE_NOSTR,
        'content': f"It's {i}",
        'id': None,
        'sig': None,
      } , NSEC_URI) for i in range(3)]

    notes = [{
      **NOTE_AS1,
      'id': id_to_uri('note', event['id']),
      'content': f"It's {i}",
    } for i, event in enumerate(events)]

    FakeConnection.to_receive = \
      [['EVENT', 'towkin 1', e] for e in events] + [['EOSE', 'towkin 1']]

    self.assert_equals(notes, self.nostr.get_activities(user_id='ab12', count=3))
    self.assert_equals([
      ['REQ', 'towkin 1', {'authors': ['ab12'], 'limit': 3}],
      ['CLOSE', 'towkin 1'],
    ], FakeConnection.sent)
    self.assertEqual([], FakeConnection.to_receive)

  def test_search(self):
    FakeConnection.to_receive = \
      [['EVENT', 'towkin 1', NOTE_NOSTR]] + [['EOSE', 'towkin 1']]

    self.assert_equals([NOTE_AS1],
                       self.nostr.get_activities(search_query='surch'))
    self.assert_equals([
      ['REQ', 'towkin 1', {'search': 'surch', 'limit': 20}],
      ['CLOSE', 'towkin 1'],
    ], FakeConnection.sent)

  def test_fetch_replies(self):
    reply_nostr = nostr.id_and_sign({
      'kind': KIND_NOTE,
      'pubkey': PUBKEY,
      'content': 'I hereby reply',
      'tags': [['e', NOTE_NOSTR['id'], 'TODO relay', 'reply']],
    }, NSEC_URI)

    reply_as1 = {
      'objectType': 'note',
      'id': id_to_uri('note', reply_nostr['id']),
      'author': NPUB_URI,
      'content': 'I hereby reply',
      'inReplyTo': id_to_uri('nevent', NOTE_NOSTR['id']),
      'published': NOW_ISO,
    }

    FakeConnection.to_receive = [
      ['EVENT', 'towkin 1', NOTE_NOSTR],
      ['EOSE', 'towkin 1'],
      ['EVENT', 'towkin 2', reply_nostr],
      ['EVENT', 'towkin 2', reply_nostr],
      ['EOSE', 'towkin 2'],
    ]

    self.assert_equals([
      {**NOTE_AS1, 'replies': {'totalItems': 2, 'items': [reply_as1] * 2}},
    ], self.nostr.get_activities(user_id=PUBKEY, fetch_replies=True))

    self.assertEqual(['ws://relay'], FakeConnection.relays)
    self.assert_equals([
      ['REQ', 'towkin 1', {'authors': [PUBKEY], 'limit': 20}],
      ['CLOSE', 'towkin 1'],
      ['REQ', 'towkin 2', {'#e': [NOTE_NOSTR['id']], 'limit': 20}],
      ['CLOSE', 'towkin 2'],
    ], FakeConnection.sent)

  def test_fetch_shares(self):
    repost_nostr = nostr.id_and_sign({
      'kind': KIND_REPOST,
      'pubkey': PUBKEY,
      'content': None,
      'tags': [['e', NOTE_NOSTR['id'], 'TODO relay', 'mention']],
    } , NSEC_URI)

    repost_as1 = {
      'objectType': 'activity',
      'actor': NPUB_URI,
      'verb': 'share',
      'id': id_to_uri('nevent', repost_nostr['id']),
      'object': id_to_uri('note', NOTE_NOSTR['id']),
      'published': NOW_ISO,
    }

    FakeConnection.to_receive = [
      ['EVENT', 'towkin 1', NOTE_NOSTR],
      ['EOSE', 'towkin 1'],
      ['EVENT', 'towkin 2', repost_nostr],
      ['EVENT', 'towkin 2', repost_nostr],
      ['EOSE', 'towkin 2'],
    ]

    self.assert_equals([
      {**NOTE_AS1, 'tags': [repost_as1, repost_as1]},
    ], self.nostr.get_activities(user_id=PUBKEY, fetch_shares=True))

    self.assertEqual(['ws://relay'], FakeConnection.relays)
    self.assert_equals([
      ['REQ', 'towkin 1', {'authors': [PUBKEY], 'limit': 20}],
      ['CLOSE', 'towkin 1'],
      ['REQ', 'towkin 2', {'#e': [NOTE_NOSTR['id']], 'limit': 20}],
      ['CLOSE', 'towkin 2'],
    ], FakeConnection.sent)

  def test_ok_false_closes_query(self):
    FakeConnection.to_receive = [
      ['OK', 'towkin 1', False],
      ['EVENT', 'towkin 1', NOTE_NOSTR],
    ]

    self.assert_equals([], self.nostr.get_activities())
    self.assert_equals([['EVENT', 'towkin 1', NOTE_NOSTR]], FakeConnection.to_receive)

  def test_create_note(self):
    FakeConnection.to_receive = [
      ['OK', NOTE_NOSTR['id'], True],
    ]

    expected = {
      **NOTE_NOSTR,
      'created_at': NOW_TS,
    }
    result = self.nostr.create(NOTE_AS1)
    self.assert_equals(expected, result.content)
    self.assert_equals([['EVENT', expected]], FakeConnection.sent)

  def test_create_note_default_pubkey(self):
    FakeConnection.to_receive = [
      ['OK', NOTE_NOSTR['id'], True],
    ]

    expected = {
      **NOTE_NOSTR,
      'created_at': NOW_TS,
    }
    result = self.nostr.create(NOTE_AS1)
    self.assert_equals(expected, result.content)
    self.assert_equals([['EVENT', expected]], FakeConnection.sent)

  def test_create_note_ok_false(self):
    FakeConnection.to_receive = [
      ['OK', NOTE_NOSTR['id'], False, 'foo bar'],
    ]

    result = self.nostr.create(NOTE_AS1)
    self.assertEqual('foo bar', result.error_plain)
    self.assertTrue(result.abort)

  def test_delete_note(self):
    id = 'nostr:npub1z24szqzphd'
    expected = nostr.id_and_sign({
      'pubkey': PUBKEY,
      'kind': KIND_DELETE,
      'tags': [['e', uri_to_id(id)]],
      'content': '',
      'created_at': NOW_TS,
    }, NSEC_URI)

    FakeConnection.to_receive = [
      ['OK', expected['id'], True],
    ]

    result = self.nostr.delete(id)
    self.assert_equals(expected, result.content, result)
    self.assert_equals([['EVENT', expected]], FakeConnection.sent)

  def test_get_actor_npub(self):
    profile = nostr.id_and_sign({
      'kind': KIND_PROFILE,
      'pubkey': PUBKEY,
      'content': json_dumps({
        'name': 'Alice',
        'nip05': '_@alice.com',
      }, sort_keys=True),
    } , NSEC_URI)

    FakeConnection.to_receive = [
      ['EVENT', 'towkin 1', profile],
      ['EOSE', 'towkin 1'],
    ]

    person = {
      'objectType': 'person',
      'id': NPUB_URI,
      'displayName': 'Alice',
      'username': 'alice.com',
      'published': NOW_ISO,
    }

    self.assert_equals(person, self.nostr.get_actor(user_id='nostr:npub1z24szqzphd'))
    self.assert_equals([
      ['REQ', 'towkin 1', {'authors': ['12ab'], 'kinds': [KIND_PROFILE], 'limit': 20}],
      ['CLOSE', 'towkin 1'],
    ], FakeConnection.sent)

  def test_query_connection_closed_ok_send_immediate(self):
    FakeConnection.send_err = ConnectionClosedOK(None, None)

    with fake_connect('wss://my-relay',
                      open_timeout=HTTP_TIMEOUT,
                      close_timeout=HTTP_TIMEOUT,
                      ) as ws:
      self.assertEqual([], self.nostr.query(ws, {}))

  def test_query_connection_closed_ok_recv_immediate(self):
    FakeConnection.recv_err = ConnectionClosedOK(None, None)

    with fake_connect('wss://my-relay',
                      open_timeout=HTTP_TIMEOUT,
                      close_timeout=HTTP_TIMEOUT,
                      ) as ws:
      self.assertEqual([], self.nostr.query(ws, {}))

  def test_query_connection_closed_ok_recv_partial_results(self):
    FakeConnection.to_receive = [
      ['EVENT', 'towkin 1', NOTE_NOSTR],
      ['EVENT', 'towkin 1', NOTE_NOSTR],
    ]
    FakeConnection.recv_err = ConnectionClosedOK(None, None)

    with fake_connect('wss://my-relay',
                      open_timeout=HTTP_TIMEOUT,
                      close_timeout=HTTP_TIMEOUT,
                      ) as ws:
      self.assert_equals([NOTE_NOSTR, NOTE_NOSTR], self.nostr.query(ws, {}))

  def test_query_connection_closed_error_recv_raises(self):
    FakeConnection.to_receive = [
      ['EVENT', 'towkin 1', NOTE_NOSTR],
    ]
    FakeConnection.recv_err = ConnectionClosedError(None, None)

    with fake_connect('wss://my-relay',
                      open_timeout=HTTP_TIMEOUT,
                      close_timeout=HTTP_TIMEOUT,
                      ) as ws:
      with self.assertRaises(ConnectionClosedError):
        self.nostr.query(ws, {})

  def test_query_signature_verification(self):
    no_sig = copy.deepcopy(NOTE_NOSTR)
    del no_sig['sig']

    no_pubkey = copy.deepcopy(NOTE_NOSTR)
    del no_pubkey['pubkey']

    FakeConnection.to_receive = [
      ['EVENT', 'towkin 1', NOTE_NOSTR],
      ['EVENT', 'towkin 1', {**NOTE_NOSTR, 'sig': 'badd'}],
      ['EVENT', 'towkin 1', no_sig],
      ['EVENT', 'towkin 1', no_pubkey],
      ['EOSE', 'towkin 1'],
    ]

    with fake_connect('wss://my-relay',
                      open_timeout=HTTP_TIMEOUT,
                      close_timeout=HTTP_TIMEOUT,
                      ) as ws:
      events = self.nostr.query(ws, {})

    # Only the valid event should be returned
    self.assert_equals([NOTE_NOSTR], events)

  def test_query_nip_42_auth(self):
    challenge = nostr.id_and_sign({
      'kind': KIND_AUTH,
      'pubkey': PUBKEY,
      'content': '',
      'tags': [
        ['relay', 'wss://my-relay/'],
        ['challenge', 'chall-lunge'],
      ],
    }, NSEC_URI)

    FakeConnection.to_receive = [
      ['AUTH', 'chall-lunge'],
      ['OK', challenge['id'], True, ''],
      ['EVENT', 'towkin 1', NOTE_NOSTR],
      ['EOSE', 'towkin 1'],
    ]

    with fake_connect('wss://my-relay',
                      open_timeout=HTTP_TIMEOUT,
                      close_timeout=HTTP_TIMEOUT,
                      ) as ws:
      events = self.nostr.query(ws, {})

    self.assert_equals([NOTE_NOSTR], events)
    self.assert_equals([
      ['AUTH', challenge],
      ['REQ', 'towkin 1', {'limit': 20}],
      ['CLOSE', 'towkin 1'],
    ], FakeConnection.sent)

  def test_user_url(self):
    self.assertEqual('https://coracle.social/people/nprofile123',
                     self.nostr.user_url('nprofile123'))
    self.assertEqual('https://coracle.social/people/nprofile123',
                     self.nostr.user_url('nostr:nprofile123'))

  def test_bech32_re(self):
    self.assertIsNone(BECH32_RE.match(ID))
    self.assertIsNone(BECH32_RE.match(URI))
    self.assertIsNotNone(BECH32_RE.match(URI.removeprefix('nostr:')))
