import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
  getUnresolvedFacetsFromText,
  resolveFacets,
  getFacetsFromText,
  getTagsFromFacets,
  getLinkUrlsFromText,
  stripLeadingOrTrailingLink,
  richTextToString,
} from "/js/facetHelpers.js";

// Mock identity resolver
function createMockIdentityResolver(handleToDidMap = {}) {
  return {
    resolveHandle: async (handle) => {
      if (handleToDidMap[handle]) {
        return handleToDidMap[handle];
      }
      throw new Error(`Could not resolve handle: ${handle}`);
    },
  };
}

describe("getUnresolvedFacetsFromText", () => {
  it("should return empty array for null/undefined text", () => {
    assert.deepEqual(getUnresolvedFacetsFromText(null), []);
    assert.deepEqual(getUnresolvedFacetsFromText(undefined), []);
    assert.deepEqual(getUnresolvedFacetsFromText(""), []);
  });

  it("should detect links with valid TLDs", () => {
    const text = "Check out example.com for more info";
    const facets = getUnresolvedFacetsFromText(text);

    assert.deepEqual(facets.length, 1);
    assert.deepEqual(
      facets[0].features[0].$type,
      "app.bsky.richtext.facet#link",
    );
    assert.deepEqual(facets[0].features[0].uri, "https://example.com");
  });

  it("should detect links with https protocol", () => {
    const text = "Visit https://example.com today";
    const facets = getUnresolvedFacetsFromText(text);

    assert.deepEqual(facets.length, 1);
    assert.deepEqual(facets[0].features[0].uri, "https://example.com");
  });

  it("should detect links with http protocol", () => {
    const text = "Visit http://example.com today";
    const facets = getUnresolvedFacetsFromText(text);

    assert.deepEqual(facets.length, 1);
    assert.deepEqual(facets[0].features[0].uri, "http://example.com");
  });

  it("should strip trailing punctuation from links", () => {
    const text = "Check out example.com.";
    const facets = getUnresolvedFacetsFromText(text);

    assert.deepEqual(facets.length, 1);
    assert.deepEqual(facets[0].features[0].uri, "https://example.com");
  });

  it("should not strip closing paren when URI contains a matching paren", () => {
    const text = "see https://en.wikipedia.org/wiki/Foo_(bar) for context";
    const facets = getUnresolvedFacetsFromText(text);

    const links = facets.filter(
      (f) => f.features[0].$type === "app.bsky.richtext.facet#link",
    );
    assert.deepEqual(links.length, 1);
    assert.deepEqual(
      links[0].features[0].uri,
      "https://en.wikipedia.org/wiki/Foo_(bar)",
    );
  });

  it("should strip a trailing closing paren when URI has none", () => {
    const text = "see (example.com) for more";
    const facets = getUnresolvedFacetsFromText(text);

    const links = facets.filter(
      (f) => f.features[0].$type === "app.bsky.richtext.facet#link",
    );
    assert.deepEqual(links.length, 1);
    assert.deepEqual(links[0].features[0].uri, "https://example.com");
  });

  it("should not detect bare domains with invalid TLDs as links", () => {
    const text = "visit foo.invalidtld today";
    const facets = getUnresolvedFacetsFromText(text);

    const links = facets.filter(
      (f) => f.features[0].$type === "app.bsky.richtext.facet#link",
    );
    assert.deepEqual(links.length, 0);
  });

  it("should not detect a domain inside an email address", () => {
    const text = "Email me at user@example.com";
    const facets = getUnresolvedFacetsFromText(text);

    const links = facets.filter(
      (f) => f.features[0].$type === "app.bsky.richtext.facet#link",
    );
    assert.deepEqual(links.length, 0);
  });

  it("should detect hashtags", () => {
    const text = "Hello #world and #coding";
    const facets = getUnresolvedFacetsFromText(text);

    const hashtags = facets.filter(
      (f) => f.features[0].$type === "app.bsky.richtext.facet#tag",
    );
    assert.deepEqual(hashtags.length, 2);
    assert.deepEqual(hashtags[0].features[0].tag, "world");
    assert.deepEqual(hashtags[1].features[0].tag, "coding");
  });

  it("should detect cashtags", () => {
    const text = "Bought $AAPL and $tsla today";
    const facets = getUnresolvedFacetsFromText(text);

    const tags = facets.filter(
      (f) => f.features[0].$type === "app.bsky.richtext.facet#tag",
    );
    assert.deepEqual(tags.length, 2);
    assert.deepEqual(tags[0].features[0].tag, "$AAPL");
    assert.deepEqual(tags[1].features[0].tag, "$TSLA");
  });

  it("should not detect cashtags mid-word", () => {
    const text = "email me at foo$bar.com or pay me $5";
    const facets = getUnresolvedFacetsFromText(text);

    const tags = facets.filter(
      (f) => f.features[0].$type === "app.bsky.richtext.facet#tag",
    );
    assert.deepEqual(tags.length, 0);
  });

  it("should compute correct byte indices for cashtags", () => {
    const text = "Buy $AAPL now";
    const facets = getUnresolvedFacetsFromText(text);

    const tags = facets.filter(
      (f) => f.features[0].$type === "app.bsky.richtext.facet#tag",
    );
    assert.deepEqual(tags.length, 1);
    assert.deepEqual(tags[0].index.byteStart, 4);
    assert.deepEqual(tags[0].index.byteEnd, 9);
  });

  it("should detect mentions", () => {
    const text = "Hello @alice.bsky.social and @bob.bsky.social";
    const facets = getUnresolvedFacetsFromText(text);

    const mentions = facets.filter(
      (f) => f.features[0].$type === "app.bsky.richtext.facet#mention",
    );
    assert.deepEqual(mentions.length, 2);
    assert.deepEqual(mentions[0].features[0].handle, "alice.bsky.social");
    assert.deepEqual(mentions[1].features[0].handle, "bob.bsky.social");
  });

  it("should detect mixed content", () => {
    const text = "Hey @alice.bsky.social check out example.com #cool";
    const facets = getUnresolvedFacetsFromText(text);

    const links = facets.filter(
      (f) => f.features[0].$type === "app.bsky.richtext.facet#link",
    );
    const hashtags = facets.filter(
      (f) => f.features[0].$type === "app.bsky.richtext.facet#tag",
    );
    const mentions = facets.filter(
      (f) => f.features[0].$type === "app.bsky.richtext.facet#mention",
    );

    assert.deepEqual(links.length, 1);
    assert.deepEqual(hashtags.length, 1);
    assert.deepEqual(mentions.length, 1);
  });

  it("should have correct byte indices", () => {
    const text = "Hi @bob.com";
    const facets = getUnresolvedFacetsFromText(text);

    assert.deepEqual(facets.length, 1);
    assert.deepEqual(facets[0].index.byteStart, 3);
    assert.deepEqual(facets[0].index.byteEnd, 11);
  });

  it("should not detect mentions without valid TLD", () => {
    const text = "Hi @bob";
    const facets = getUnresolvedFacetsFromText(text);

    const mentions = facets.filter(
      (f) => f.features[0].$type === "app.bsky.richtext.facet#mention",
    );
    assert.deepEqual(mentions.length, 0);
  });

  it("should detect mentions preceded by an open paren", () => {
    const text = "see (@alice.bsky.social)";
    const facets = getUnresolvedFacetsFromText(text);

    const mentions = facets.filter(
      (f) => f.features[0].$type === "app.bsky.richtext.facet#mention",
    );
    assert.deepEqual(mentions.length, 1);
    assert.deepEqual(mentions[0].features[0].handle, "alice.bsky.social");
  });

  it("should detect hashtags only when preceded by start or whitespace", () => {
    const text = "no#match but #yes works";
    const facets = getUnresolvedFacetsFromText(text);

    const tags = facets.filter(
      (f) => f.features[0].$type === "app.bsky.richtext.facet#tag",
    );
    assert.deepEqual(tags.length, 1);
    assert.deepEqual(tags[0].features[0].tag, "yes");
  });

  it("should not detect hashtags consisting only of digits", () => {
    const text = "Read #123 carefully";
    const facets = getUnresolvedFacetsFromText(text);

    const tags = facets.filter(
      (f) => f.features[0].$type === "app.bsky.richtext.facet#tag",
    );
    assert.deepEqual(tags.length, 0);
  });

  it("should strip trailing punctuation from hashtags", () => {
    const text = "Hello #world!";
    const facets = getUnresolvedFacetsFromText(text);

    const tags = facets.filter(
      (f) => f.features[0].$type === "app.bsky.richtext.facet#tag",
    );
    assert.deepEqual(tags.length, 1);
    assert.deepEqual(tags[0].features[0].tag, "world");
  });

  it("should not parse email addresses as mentions", () => {
    const text = "Contact me at user@example.com for info";
    const facets = getUnresolvedFacetsFromText(text);

    const mentions = facets.filter(
      (f) => f.features[0].$type === "app.bsky.richtext.facet#mention",
    );
    assert.deepEqual(mentions.length, 0);
  });

  it("should handle multibyte characters in byte indices", () => {
    const text = "Hello 世界 @alice.bsky.social";
    const facets = getUnresolvedFacetsFromText(text);

    const mentions = facets.filter(
      (f) => f.features[0].$type === "app.bsky.richtext.facet#mention",
    );
    assert.deepEqual(mentions.length, 1);
    // 'Hello ' = 6 bytes, '世界' = 6 bytes (3 each), ' ' = 1 byte = 13 bytes before @
    assert.deepEqual(mentions[0].index.byteStart, 13);
  });
});

describe("resolveFacets", () => {
  it("should pass through non-mention facets unchanged", async () => {
    const facets = [
      {
        index: { byteStart: 0, byteEnd: 11 },
        features: [
          { $type: "app.bsky.richtext.facet#link", uri: "https://example.com" },
        ],
      },
      {
        index: { byteStart: 15, byteEnd: 20 },
        features: [{ $type: "app.bsky.richtext.facet#tag", tag: "test" }],
      },
    ];

    const resolver = createMockIdentityResolver();
    const resolved = await resolveFacets(facets, resolver);

    assert.deepEqual(resolved.length, 2);
    assert.deepEqual(resolved[0].features[0].uri, "https://example.com");
    assert.deepEqual(resolved[1].features[0].tag, "test");
  });

  it("should resolve mention handles to DIDs", async () => {
    const facets = [
      {
        index: { byteStart: 0, byteEnd: 18 },
        features: [
          {
            $type: "app.bsky.richtext.facet#mention",
            handle: "alice.bsky.social",
          },
        ],
      },
    ];

    const resolver = createMockIdentityResolver({
      "alice.bsky.social": "did:plc:alice123",
    });
    const resolved = await resolveFacets(facets, resolver);

    assert.deepEqual(resolved.length, 1);
    assert.deepEqual(
      resolved[0].features[0].$type,
      "app.bsky.richtext.facet#mention",
    );
    assert.deepEqual(resolved[0].features[0].did, "did:plc:alice123");
  });

  it("should skip mentions that already have DIDs", async () => {
    const facets = [
      {
        index: { byteStart: 0, byteEnd: 18 },
        features: [
          { $type: "app.bsky.richtext.facet#mention", did: "did:plc:existing" },
        ],
      },
    ];

    const resolver = createMockIdentityResolver();
    const resolved = await resolveFacets(facets, resolver);

    assert.deepEqual(resolved.length, 1);
    assert.deepEqual(resolved[0].features[0].did, "did:plc:existing");
  });

  it("should exclude mentions that cannot be resolved", async () => {
    const facets = [
      {
        index: { byteStart: 0, byteEnd: 18 },
        features: [
          { $type: "app.bsky.richtext.facet#mention", handle: "unknown.user" },
        ],
      },
    ];

    const resolver = createMockIdentityResolver({});
    const resolved = await resolveFacets(facets, resolver);

    assert.deepEqual(resolved.length, 0);
  });

  it("should pass through facets with no features", async () => {
    const facets = [
      { index: { byteStart: 0, byteEnd: 5 } },
      { index: { byteStart: 6, byteEnd: 10 }, features: [] },
    ];

    const resolver = createMockIdentityResolver();
    const resolved = await resolveFacets(facets, resolver);

    assert.deepEqual(resolved, facets);
  });

  it("should handle mixed resolved and unresolved mentions", async () => {
    const facets = [
      {
        index: { byteStart: 0, byteEnd: 18 },
        features: [
          {
            $type: "app.bsky.richtext.facet#mention",
            handle: "alice.bsky.social",
          },
        ],
      },
      {
        index: { byteStart: 20, byteEnd: 38 },
        features: [
          { $type: "app.bsky.richtext.facet#mention", handle: "unknown.user" },
        ],
      },
      {
        index: { byteStart: 40, byteEnd: 50 },
        features: [{ $type: "app.bsky.richtext.facet#tag", tag: "test" }],
      },
    ];

    const resolver = createMockIdentityResolver({
      "alice.bsky.social": "did:plc:alice123",
    });
    const resolved = await resolveFacets(facets, resolver);

    assert.deepEqual(resolved.length, 2);
    assert.deepEqual(resolved[0].features[0].tag, "test");
    assert.deepEqual(resolved[1].features[0].did, "did:plc:alice123");
  });
});

describe("getFacetsFromText", () => {
  it("should extract and resolve facets from text", async () => {
    const text = "Hello @alice.bsky.social";
    const resolver = createMockIdentityResolver({
      "alice.bsky.social": "did:plc:alice123",
    });

    const facets = await getFacetsFromText(text, resolver);

    assert.deepEqual(facets.length, 1);
    assert.deepEqual(
      facets[0].features[0].$type,
      "app.bsky.richtext.facet#mention",
    );
    assert.deepEqual(facets[0].features[0].did, "did:plc:alice123");
  });

  it("should handle text with no facets", async () => {
    const text = "Just plain text";
    const resolver = createMockIdentityResolver();

    const facets = await getFacetsFromText(text, resolver);

    assert.deepEqual(facets.length, 0);
  });

  it("should handle text with only hashtags and links", async () => {
    const text = "Check #this at example.com";
    const resolver = createMockIdentityResolver();

    const facets = await getFacetsFromText(text, resolver);

    assert.deepEqual(facets.length, 2);
  });
});

describe("getTagsFromFacets", () => {
  it("should return only tag facets", () => {
    const facets = [
      {
        index: { byteStart: 0, byteEnd: 5 },
        features: [{ $type: "app.bsky.richtext.facet#tag", tag: "hello" }],
      },
      {
        index: { byteStart: 10, byteEnd: 21 },
        features: [
          { $type: "app.bsky.richtext.facet#link", uri: "https://example.com" },
        ],
      },
      {
        index: { byteStart: 25, byteEnd: 30 },
        features: [{ $type: "app.bsky.richtext.facet#tag", tag: "world" }],
      },
    ];

    const tags = getTagsFromFacets(facets);

    assert.deepEqual(tags.length, 2);
    assert.deepEqual(tags[0].features[0].tag, "hello");
    assert.deepEqual(tags[1].features[0].tag, "world");
  });

  it("should return empty array when no tags present", () => {
    const facets = [
      {
        index: { byteStart: 0, byteEnd: 11 },
        features: [
          { $type: "app.bsky.richtext.facet#link", uri: "https://example.com" },
        ],
      },
      {
        index: { byteStart: 15, byteEnd: 33 },
        features: [
          { $type: "app.bsky.richtext.facet#mention", did: "did:plc:abc123" },
        ],
      },
    ];

    const tags = getTagsFromFacets(facets);

    assert.deepEqual(tags.length, 0);
  });

  it("should return empty array for empty facets array", () => {
    const tags = getTagsFromFacets([]);

    assert.deepEqual(tags.length, 0);
  });

  it("should skip facets with no features", () => {
    const facets = [
      { index: { byteStart: 0, byteEnd: 5 } },
      { index: { byteStart: 6, byteEnd: 10 }, features: [] },
      {
        index: { byteStart: 12, byteEnd: 17 },
        features: [{ $type: "app.bsky.richtext.facet#tag", tag: "hello" }],
      },
    ];

    const tags = getTagsFromFacets(facets);

    assert.deepEqual(tags.length, 1);
    assert.deepEqual(tags[0].features[0].tag, "hello");
  });

  it("should filter out mentions", () => {
    const facets = [
      {
        index: { byteStart: 0, byteEnd: 5 },
        features: [{ $type: "app.bsky.richtext.facet#tag", tag: "test" }],
      },
      {
        index: { byteStart: 10, byteEnd: 28 },
        features: [
          { $type: "app.bsky.richtext.facet#mention", did: "did:plc:user123" },
        ],
      },
    ];

    const tags = getTagsFromFacets(facets);

    assert.deepEqual(tags.length, 1);
    assert.deepEqual(tags[0].features[0].$type, "app.bsky.richtext.facet#tag");
  });
});

describe("richTextToString", () => {
  it("should return empty string for null/undefined text", () => {
    assert.deepEqual(richTextToString(null, []), "");
    assert.deepEqual(richTextToString(undefined, []), "");
    assert.deepEqual(richTextToString("", []), "");
  });

  it("should return text unchanged when no facets are provided", () => {
    assert.deepEqual(richTextToString("hello world", []), "hello world");
    assert.deepEqual(richTextToString("hello world", null), "hello world");
    assert.deepEqual(richTextToString("hello world", undefined), "hello world");
  });

  it("should replace a shortened link with its full URI", () => {
    const text = "check this out: example.com/foo...";
    const facets = [
      {
        index: { byteStart: 16, byteEnd: 34 },
        features: [
          {
            $type: "app.bsky.richtext.facet#link",
            uri: "https://example.com/foo/bar/baz",
          },
        ],
      },
    ];
    assert.deepEqual(
      richTextToString(text, facets),
      "check this out: https://example.com/foo/bar/baz",
    );
  });

  it("should leave non-link facets (mentions, tags) as display text", () => {
    const text = "hi @alice.test #hello";
    const facets = [
      {
        index: { byteStart: 3, byteEnd: 14 },
        features: [
          {
            $type: "app.bsky.richtext.facet#mention",
            did: "did:plc:alice",
          },
        ],
      },
      {
        index: { byteStart: 15, byteEnd: 21 },
        features: [{ $type: "app.bsky.richtext.facet#tag", tag: "hello" }],
      },
    ];
    assert.deepEqual(richTextToString(text, facets), "hi @alice.test #hello");
  });

  it("should handle multiple link facets in order", () => {
    const text = "see a.co/x and b.co/y end";
    const facets = [
      {
        index: { byteStart: 15, byteEnd: 21 },
        features: [
          {
            $type: "app.bsky.richtext.facet#link",
            uri: "https://b.co/y/full",
          },
        ],
      },
      {
        index: { byteStart: 4, byteEnd: 10 },
        features: [
          {
            $type: "app.bsky.richtext.facet#link",
            uri: "https://a.co/x/full",
          },
        ],
      },
    ];
    assert.deepEqual(
      richTextToString(text, facets),
      "see https://a.co/x/full and https://b.co/y/full end",
    );
  });

  it("should handle multibyte characters correctly", () => {
    const text = "héllo example.com/x";
    // "héllo " = 7 bytes (é is 2 bytes), link starts at byte 7
    const facets = [
      {
        index: { byteStart: 7, byteEnd: 20 },
        features: [
          {
            $type: "app.bsky.richtext.facet#link",
            uri: "https://example.com/x/full",
          },
        ],
      },
    ];
    assert.deepEqual(
      richTextToString(text, facets),
      "héllo https://example.com/x/full",
    );
  });
});

describe("getLinkUrlsFromText", () => {
  it("returns urls for links in the text", () => {
    assert.deepEqual(
      getLinkUrlsFromText("check this https://bsky.app/profile/alice.test"),
      ["https://bsky.app/profile/alice.test"],
    );
  });

  it("normalizes scheme-less links to https", () => {
    assert.deepEqual(
      getLinkUrlsFromText("check bsky.app/profile/alice.test/post/3abc out"),
      ["https://bsky.app/profile/alice.test/post/3abc"],
    );
  });

  it("returns an empty array when there are no links", () => {
    assert.deepEqual(getLinkUrlsFromText("just some plain text"), []);
    assert.deepEqual(getLinkUrlsFromText(""), []);
  });
});

describe("stripLeadingOrTrailingLink", () => {
  const url = "https://bsky.app/profile/alice.test/post/3abc";

  it("strips a leading link", () => {
    assert.deepEqual(
      stripLeadingOrTrailingLink(`${url} check this out`, url),
      "check this out",
    );
  });

  it("strips a trailing link", () => {
    assert.deepEqual(
      stripLeadingOrTrailingLink(`check this out ${url}`, url),
      "check this out",
    );
  });

  it("returns an empty string for link-only text", () => {
    assert.deepEqual(stripLeadingOrTrailingLink(url, url), "");
    assert.deepEqual(stripLeadingOrTrailingLink(`  ${url} `, url), "");
  });

  it("leaves a mid-text link in place", () => {
    const text = `look at ${url} right there`;
    assert.deepEqual(stripLeadingOrTrailingLink(text, url), text);
  });

  it("strips a scheme-less link matching the normalized url", () => {
    assert.deepEqual(
      stripLeadingOrTrailingLink(
        "bsky.app/profile/alice.test/post/3abc so cool",
        url,
      ),
      "so cool",
    );
  });

  it("leaves text unchanged when the url does not match any link", () => {
    const text = `check this out ${url}`;
    assert.deepEqual(
      stripLeadingOrTrailingLink(text, "https://bsky.app/profile/other"),
      text,
    );
  });
});
