/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

import { useState, useEffect, useRef, act } from 'react';
import { renderWithProviders } from '../../../test-utils/render.js';
import { Box, Text } from 'ink';
import { ScrollableList, type ScrollableListRef } from './ScrollableList.js';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { waitFor } from '../../../test-utils/async.js';

// Mock useStdout to provide a fixed size for testing
vi.mock('ink', async (importOriginal) => {
  const actual = await importOriginal<typeof import('ink')>();
  return {
    ...actual,
    useStdout: () => ({
      stdout: {
        columns: 80,
        rows: 24,
        on: vi.fn(),
        off: vi.fn(),
        write: vi.fn(),
      },
    }),
  };
});

interface Item {
  id: string;
  title: string;
}

const getLorem = (index: number) =>
  Array(10)
    .fill(null)
    .map(() => 'lorem ipsum '.repeat((index % 3) + 1).trim())
    .join('\n');

const TestComponent = ({
  initialItems = 1000,
  onAddItem,
  onRef,
}: {
  initialItems?: number;
  onAddItem?: (addItem: () => void) => void;
  onRef?: (ref: ScrollableListRef<Item> | null) => void;
}) => {
  const [items, setItems] = useState<Item[]>(() =>
    Array.from({ length: initialItems }, (_, i) => ({
      id: String(i),
      title: `Item ${i + 1}`,
    })),
  );

  const listRef = useRef<ScrollableListRef<Item>>(null);

  useEffect(() => {
    onAddItem?.(() => {
      setItems((prev) => [
        ...prev,
        {
          id: String(prev.length),
          title: `Item ${prev.length + 1}`,
        },
      ]);
    });
  }, [onAddItem]);

  useEffect(() => {
    if (onRef) {
      onRef(listRef.current);
    }
  }, [onRef]);

  return (
    <Box flexDirection="column" width={80} height={24} padding={1}>
      <Box flexGrow={1} borderStyle="round" borderColor="cyan">
        <ScrollableList
          ref={listRef}
          data={items}
          renderItem={({ item, index }) => (
            <Box flexDirection="column" paddingBottom={2}>
              <Box
                sticky
                flexDirection="column"
                width={78}
                opaque
                stickyChildren={
                  <Box flexDirection="column" width={78} opaque>
                    <Text>{item.title}</Text>
                    <Box
                      borderStyle="single"
                      borderTop={true}
                      borderBottom={false}
                      borderLeft={false}
                      borderRight={false}
                      borderColor="gray"
                    />
                  </Box>
                }
              >
                <Text>{item.title}</Text>
              </Box>
              <Text color="gray">{getLorem(index)}</Text>
            </Box>
          )}
          estimatedItemHeight={() => 14}
          keyExtractor={(item) => item.id}
          hasFocus={true}
          initialScrollIndex={Number.MAX_SAFE_INTEGER}
        />
      </Box>
      <Text>Count: {items.length}</Text>
    </Box>
  );
};
describe('ScrollableList Demo Behavior', () => {
  beforeEach(() => {
    vi.stubEnv('NODE_ENV', 'test');
  });

  afterEach(() => {
    vi.unstubAllEnvs();
  });

  it('should scroll to bottom when new items are added and stop when scrolled up', async () => {
    let addItem: (() => void) | undefined;
    let listRef: ScrollableListRef<Item> | null = null;
    let lastFrame: (options?: { allowEmpty?: boolean }) => string | undefined;
    let waitUntilReady: () => Promise<void>;

    let result: ReturnType<typeof renderWithProviders>;

    await act(async () => {
      result = renderWithProviders(
        <TestComponent
          onAddItem={(add) => {
            addItem = add;
          }}
          onRef={async (ref) => {
            listRef = ref;
          }}
        />,
      );
      lastFrame = result.lastFrame;
      waitUntilReady = result.waitUntilReady;
    });

    await waitUntilReady!();

    // Initial render should show Item 1000
    expect(lastFrame!()).toContain('Item 1000');
    expect(lastFrame!()).toContain('Count: 1000');

    // Add item 1001
    await act(async () => {
      addItem?.();
    });
    await waitUntilReady!();

    await waitFor(() => {
      expect(lastFrame!()).toContain('Count: 1001');
    });
    expect(lastFrame!()).toContain('Item 1001');
    expect(lastFrame!()).not.toContain('Item 990'); // Should have scrolled past it

    // Add item 1002
    await act(async () => {
      addItem?.();
    });
    await waitUntilReady!();

    await waitFor(() => {
      expect(lastFrame!()).toContain('Count: 1002');
    });
    expect(lastFrame!()).toContain('Item 1002');
    expect(lastFrame!()).not.toContain('Item 991');

    // Scroll up directly via ref
    await act(async () => {
      listRef?.scrollBy(-5);
    });
    await waitUntilReady!();

    // Add item 1003 - should NOT be visible because we scrolled up
    await act(async () => {
      addItem?.();
    });
    await waitUntilReady!();

    await waitFor(() => {
      expect(lastFrame!()).toContain('Count: 1003');
    });
    expect(lastFrame!()).not.toContain('Item 1003');

    await act(async () => {
      result.unmount();
    });
  });

  it('should display sticky header when scrolled past the item', async () => {
    let listRef: ScrollableListRef<Item> | null = null;
    const StickyTestComponent = () => {
      const items = Array.from({ length: 100 }, (_, i) => ({
        id: String(i),
        title: `Item ${i + 1}`,
      }));

      const ref = useRef<ScrollableListRef<Item>>(null);
      useEffect(() => {
        listRef = ref.current;
      }, []);

      return (
        <Box flexDirection="column" width={80} height={10}>
          <ScrollableList
            ref={ref}
            data={items}
            renderItem={({ item, index }) => (
              <Box flexDirection="column" height={3}>
                {index === 0 ? (
                  <Box
                    sticky
                    stickyChildren={<Text>[STICKY] {item.title}</Text>}
                  >
                    <Text>[Normal] {item.title}</Text>
                  </Box>
                ) : (
                  <Text>[Normal] {item.title}</Text>
                )}
                <Text>Content for {item.title}</Text>
                <Text>More content for {item.title}</Text>
              </Box>
            )}
            estimatedItemHeight={() => 3}
            keyExtractor={(item) => item.id}
            hasFocus={true}
          />
        </Box>
      );
    };

    let lastFrame: () => string | undefined;
    let waitUntilReady: () => Promise<void>;
    let result: ReturnType<typeof renderWithProviders>;
    await act(async () => {
      result = renderWithProviders(<StickyTestComponent />);
      lastFrame = result.lastFrame;
      waitUntilReady = result.waitUntilReady;
    });

    await waitUntilReady!();

    // Initially at top, should see Normal Item 1
    await waitFor(() => {
      expect(lastFrame!()).toContain('[Normal] Item 1');
    });
    expect(lastFrame!()).not.toContain('[STICKY] Item 1');

    // Scroll down slightly. Item 1 (height 3) is now partially off-screen (-2), so it should stick.
    await act(async () => {
      listRef?.scrollBy(2);
    });
    await waitUntilReady!();

    // Now Item 1 should be stuck
    await waitFor(() => {
      expect(lastFrame!()).toContain('[STICKY] Item 1');
    });
    expect(lastFrame!()).not.toContain('[Normal] Item 1');

    // Scroll further down to unmount Item 1.
    // Viewport height 10, item height 3. Scroll to 10.
    // startIndex should be around 2, so Item 1 (index 0) is unmounted.
    await act(async () => {
      listRef?.scrollTo(10);
    });
    await waitUntilReady!();

    await waitFor(() => {
      expect(lastFrame!()).not.toContain('[STICKY] Item 1');
    });

    // Scroll back to top
    await act(async () => {
      listRef?.scrollTo(0);
    });
    await waitUntilReady!();

    // Should be normal again
    await waitFor(() => {
      expect(lastFrame!()).toContain('[Normal] Item 1');
    });
    expect(lastFrame!()).not.toContain('[STICKY] Item 1');

    await act(async () => {
      result.unmount();
    });
  });

  describe('Keyboard Navigation', () => {
    it('should handle scroll keys correctly', async () => {
      let listRef: ScrollableListRef<Item> | null = null;
      let lastFrame: (options?: { allowEmpty?: boolean }) => string | undefined;
      let stdin: { write: (data: string) => void };
      let waitUntilReady: () => Promise<void>;

      const items = Array.from({ length: 50 }, (_, i) => ({
        id: String(i),
        title: `Item ${i}`,
      }));

      let result: ReturnType<typeof renderWithProviders>;
      await act(async () => {
        result = renderWithProviders(
          <Box flexDirection="column" width={80} height={10}>
            <ScrollableList
              ref={(ref) => {
                listRef = ref;
              }}
              data={items}
              renderItem={({ item }) => <Text>{item.title}</Text>}
              estimatedItemHeight={() => 1}
              keyExtractor={(item) => item.id}
              hasFocus={true}
            />
          </Box>,
        );
        lastFrame = result.lastFrame;
        stdin = result.stdin;
        waitUntilReady = result.waitUntilReady;
      });

      await waitUntilReady!();

      // Initial state
      expect(lastFrame!()).toContain('Item 0');
      expect(listRef).toBeDefined();
      expect(listRef!.getScrollState()?.scrollTop).toBe(0);

      // Scroll Down (Shift+Down) -> \x1b[b
      await act(async () => {
        stdin.write('\x1b[b');
      });
      await waitUntilReady!();

      await waitFor(() => {
        expect(listRef?.getScrollState()?.scrollTop).toBeGreaterThan(0);
      });

      // Scroll Up (Shift+Up) -> \x1b[a
      await act(async () => {
        stdin.write('\x1b[a');
      });
      await waitUntilReady!();

      await waitFor(() => {
        expect(listRef?.getScrollState()?.scrollTop).toBe(0);
      });

      // Page Down -> \x1b[6~
      await act(async () => {
        stdin.write('\x1b[6~');
      });
      await waitUntilReady!();

      await waitFor(() => {
        // Height is 10, so should scroll ~10 units
        expect(listRef?.getScrollState()?.scrollTop).toBeGreaterThanOrEqual(9);
      });

      // Page Up -> \x1b[5~
      await act(async () => {
        stdin.write('\x1b[5~');
      });
      await waitUntilReady!();

      await waitFor(() => {
        expect(listRef?.getScrollState()?.scrollTop).toBeLessThan(2);
      });

      // End -> \x1b[1;5F (Ctrl+End)
      await act(async () => {
        stdin.write('\x1b[1;5F');
      });
      await waitUntilReady!();

      await waitFor(() => {
        // Total 50 items, height 10. Max scroll ~40.
        expect(listRef?.getScrollState()?.scrollTop).toBeGreaterThan(30);
      });

      // Home -> \x1b[1;5H (Ctrl+Home)
      await act(async () => {
        stdin.write('\x1b[1;5H');
      });
      await waitUntilReady!();

      await waitFor(() => {
        expect(listRef?.getScrollState()?.scrollTop).toBe(0);
      });

      await act(async () => {
        // Let the scrollbar fade out animation finish
        await new Promise((resolve) => setTimeout(resolve, 1600));
        result.unmount();
      });
    });
  });

  describe('Width Prop', () => {
    it('should apply the width prop to the container', async () => {
      const items = [{ id: '1', title: 'Item 1' }];
      let lastFrame: (options?: { allowEmpty?: boolean }) => string | undefined;
      let waitUntilReady: () => Promise<void>;

      let result: ReturnType<typeof renderWithProviders>;
      await act(async () => {
        result = renderWithProviders(
          <Box width={100} height={20}>
            <ScrollableList
              data={items}
              renderItem={({ item }) => <Text>{item.title}</Text>}
              estimatedItemHeight={() => 1}
              keyExtractor={(item) => item.id}
              hasFocus={true}
              width={50}
            />
          </Box>,
        );
        lastFrame = result.lastFrame;
        waitUntilReady = result.waitUntilReady;
      });

      await waitUntilReady!();

      await waitFor(() => {
        expect(lastFrame()).toContain('Item 1');
      });

      await act(async () => {
        result.unmount();
      });
    });
  });

  it('regression: remove last item and add 2 items when scrolled to bottom', async () => {
    let listRef: ScrollableListRef<Item> | null = null;
    let setItemsFunc: React.Dispatch<React.SetStateAction<Item[]>> | null =
      null;

    const TestComp = () => {
      const [items, setItems] = useState<Item[]>(
        Array.from({ length: 10 }, (_, i) => ({
          id: String(i),
          title: `Item ${i}`,
        })),
      );
      useEffect(() => {
        setItemsFunc = setItems;
      }, []);

      return (
        <Box flexDirection="column" width={80} height={5}>
          <ScrollableList
            ref={(ref) => {
              listRef = ref;
            }}
            data={items}
            renderItem={({ item }) => <Text>{item.title}</Text>}
            estimatedItemHeight={() => 1}
            keyExtractor={(item) => item.id}
            hasFocus={true}
            initialScrollIndex={Number.MAX_SAFE_INTEGER}
          />
        </Box>
      );
    };

    let result: ReturnType<typeof renderWithProviders>;
    await act(async () => {
      result = renderWithProviders(<TestComp />);
    });

    await result!.waitUntilReady();

    // Scrolled to bottom, max scroll = 10 - 5 = 5
    await waitFor(() => {
      expect(listRef?.getScrollState()?.scrollTop).toBe(5);
    });

    // Remove last element and add 2 elements
    await act(async () => {
      setItemsFunc!((prev) => {
        const next = prev.slice(0, prev.length - 1);
        next.push({ id: '10', title: 'Item 10' });
        next.push({ id: '11', title: 'Item 11' });
        return next;
      });
    });

    await result!.waitUntilReady();

    // Auto scrolls to new bottom: max scroll = 11 - 5 = 6
    await waitFor(() => {
      expect(listRef?.getScrollState()?.scrollTop).toBe(6);
    });

    // Scroll up slightly
    await act(async () => {
      listRef?.scrollBy(-2);
    });
    await result!.waitUntilReady();

    await waitFor(() => {
      expect(listRef?.getScrollState()?.scrollTop).toBe(4);
    });

    // Scroll back to bottom
    await act(async () => {
      listRef?.scrollToEnd();
    });
    await result!.waitUntilReady();

    await waitFor(() => {
      expect(listRef?.getScrollState()?.scrollTop).toBe(6);
    });

    // Add two more elements
    await act(async () => {
      setItemsFunc!((prev) => [
        ...prev,
        { id: '12', title: 'Item 12' },
        { id: '13', title: 'Item 13' },
      ]);
    });

    await result!.waitUntilReady();

    // Auto scrolls to bottom: max scroll = 13 - 5 = 8
    await waitFor(() => {
      expect(listRef?.getScrollState()?.scrollTop).toBe(8);
    });

    result!.unmount();
  });

  it('regression: bottom-most element changes size but list does not update', async () => {
    let listRef: ScrollableListRef<Item> | null = null;
    let expandLastFunc: (() => void) | null = null;

    const ItemWithState = ({
      item,
      isLast,
    }: {
      item: Item;
      isLast: boolean;
    }) => {
      const [expanded, setExpanded] = useState(false);
      useEffect(() => {
        if (isLast) {
          expandLastFunc = () => setExpanded(true);
        }
      }, [isLast]);
      return (
        <Box flexDirection="column">
          <Text>{item.title}</Text>
          {expanded && <Text>Expanded content</Text>}
        </Box>
      );
    };

    const TestComp = () => {
      // items array is stable
      const [items] = useState(() =>
        Array.from({ length: 5 }, (_, i) => ({
          id: String(i),
          title: `Item ${i}`,
        })),
      );

      return (
        <Box flexDirection="column" width={80} height={4}>
          <ScrollableList
            ref={(ref) => {
              listRef = ref;
            }}
            data={items}
            renderItem={({ item, index }) => (
              <ItemWithState item={item} isLast={index === 4} />
            )}
            estimatedItemHeight={() => 1}
            keyExtractor={(item) => item.id}
            hasFocus={true}
            initialScrollIndex={Number.MAX_SAFE_INTEGER}
          />
        </Box>
      );
    };

    let result: ReturnType<typeof renderWithProviders>;
    await act(async () => {
      result = renderWithProviders(<TestComp />);
    });

    await result!.waitUntilReady();

    // Initially, total height is 5. viewport is 4. scroll is 1.
    await waitFor(() => {
      expect(listRef?.getScrollState()?.scrollTop).toBe(1);
    });

    // Expand the last item locally, without re-rendering the list!
    await act(async () => {
      expandLastFunc!();
    });

    await result!.waitUntilReady();

    // The total height becomes 6. It should remain scrolled to bottom, so scroll becomes 2.
    // This is expected to FAIL currently because VirtualizedList won't remeasure
    // unless data changes or container height changes.
    await waitFor(
      () => {
        expect(listRef?.getScrollState()?.scrollTop).toBe(2);
      },
      { timeout: 1000 },
    );

    result!.unmount();
  });

  it('regression: prepending items does not corrupt heights (total height correct)', async () => {
    let listRef: ScrollableListRef<Item> | null = null;
    let setItemsFunc: React.Dispatch<React.SetStateAction<Item[]>> | null =
      null;

    const TestComp = () => {
      // Items 1 to 5. Item 1 is very tall.
      const [items, setItems] = useState<Item[]>(
        Array.from({ length: 5 }, (_, i) => ({
          id: String(i + 1),
          title: `Item ${i + 1}`,
        })),
      );
      useEffect(() => {
        setItemsFunc = setItems;
      }, []);

      return (
        <Box flexDirection="column" width={80} height={10}>
          <ScrollableList
            ref={(ref) => {
              listRef = ref;
            }}
            data={items}
            renderItem={({ item }) => (
              <Box height={item.id === '1' ? 10 : 2}>
                <Text>{item.title}</Text>
              </Box>
            )}
            estimatedItemHeight={() => 2}
            keyExtractor={(item) => item.id}
            hasFocus={true}
            initialScrollIndex={Number.MAX_SAFE_INTEGER}
          />
        </Box>
      );
    };

    let result: ReturnType<typeof renderWithProviders>;
    await act(async () => {
      result = renderWithProviders(<TestComp />);
    });

    await result!.waitUntilReady();

    // Scroll is at bottom.
    // Heights: Item 1: 10, Item 2: 2, Item 3: 2, Item 4: 2, Item 5: 2.
    // Total height = 18. Container = 10. Max scroll = 8.
    await waitFor(() => {
      expect(listRef?.getScrollState()?.scrollTop).toBe(8);
    });

    // Prepend an item!
    await act(async () => {
      setItemsFunc!((prev) => [{ id: '0', title: 'Item 0' }, ...prev]);
    });

    await result!.waitUntilReady();

    // Now items: 0(2), 1(10), 2(2), 3(2), 4(2), 5(2).
    // Total height = 20. Container = 10. Max scroll = 10.
    // Auto-scrolls to bottom because it was sticking!
    await waitFor(() => {
      expect(listRef?.getScrollState()?.scrollTop).toBe(10);
    });

    result!.unmount();
  });
});
