/**
 * Tests for the ProjectsPage component.
 */

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '../utils';
import { ProjectsPage, ProjectModal } from '../../pages/ProjectsPage';
import { eligibleParents } from '../../utils/projectTree';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';

const mockProjects = [
  {
    id: 1,
    name: 'Functional Parts',
    description: 'Useful household items',
    color: '#00ae42',
    archive_count: 10,
    total_print_time_seconds: 36000,
    total_filament_grams: 500,
    created_at: '2024-01-01T00:00:00Z',
    updated_at: '2024-01-15T00:00:00Z',
  },
  {
    id: 2,
    name: 'Art Collection',
    description: 'Decorative prints',
    color: '#ff5500',
    archive_count: 5,
    total_print_time_seconds: 18000,
    total_filament_grams: 200,
    created_at: '2024-01-05T00:00:00Z',
    updated_at: '2024-01-10T00:00:00Z',
  },
];

describe('ProjectsPage', () => {
  beforeEach(() => {
    server.use(
      http.get('/api/v1/projects/', () => {
        return HttpResponse.json(mockProjects);
      }),
      http.post('/api/v1/projects/', async ({ request }) => {
        const body = await request.json() as { name: string };
        return HttpResponse.json({ id: 3, name: body.name, color: '#00ae42', archive_count: 0 });
      }),
      http.delete('/api/v1/projects/:id', () => {
        return HttpResponse.json({ success: true });
      })
    );
  });

  describe('rendering', () => {
    it('renders the page title', async () => {
      render(<ProjectsPage />);

      await waitFor(() => {
        expect(screen.getByText('Projects')).toBeInTheDocument();
      });
    });

    it('shows project cards', async () => {
      render(<ProjectsPage />);

      await waitFor(() => {
        expect(screen.getByText('Functional Parts')).toBeInTheDocument();
        expect(screen.getByText('Art Collection')).toBeInTheDocument();
      });
    });

    it('shows project descriptions', async () => {
      render(<ProjectsPage />);

      await waitFor(() => {
        expect(screen.getByText('Useful household items')).toBeInTheDocument();
        expect(screen.getByText('Decorative prints')).toBeInTheDocument();
      });
    });
  });

  describe('project info', () => {
    it('shows archive count', async () => {
      render(<ProjectsPage />);

      await waitFor(() => {
        // Project cards should show archive counts
        expect(screen.getByText('Functional Parts')).toBeInTheDocument();
      });
    });

    it('shows project colors', async () => {
      render(<ProjectsPage />);

      await waitFor(() => {
        const functionalParts = screen.getByText('Functional Parts');
        expect(functionalParts).toBeInTheDocument();
        // Color is applied as style
      });
    });
  });

  describe('create project', () => {
    it('has new project button', async () => {
      render(<ProjectsPage />);

      await waitFor(() => {
        expect(screen.getByText('New Project')).toBeInTheDocument();
      });
    });

    it('opens create modal on click', async () => {
      const user = userEvent.setup();
      render(<ProjectsPage />);

      await waitFor(() => {
        expect(screen.getByText('New Project')).toBeInTheDocument();
      });

      await user.click(screen.getByText('New Project'));

      // Modal should open - look for modal content
      await waitFor(() => {
        // Modal may show "Create Project" or similar text
        const modalContent = screen.queryByText(/create/i) ||
                           screen.queryByRole('dialog') ||
                           screen.queryByText(/name/i);
        expect(modalContent).toBeTruthy();
      });
    });
  });

  describe('empty state', () => {
    it('shows empty state when no projects', async () => {
      server.use(
        http.get('/api/v1/projects/', () => {
          return HttpResponse.json([]);
        })
      );

      render(<ProjectsPage />);

      await waitFor(() => {
        // Either empty state message or the page title should be visible
        const emptyMsg = screen.queryByText(/no projects/i);
        const pageTitle = screen.queryByText('Projects');
        expect(emptyMsg || pageTitle).toBeTruthy();
      });
    });
  });

  // #1155 — URL link icon + cover image thumbnail on project cards.
  describe('URL link and cover image (#1155)', () => {
    it('renders an external-link icon next to the project name when URL is set', async () => {
      server.use(
        http.get('/api/v1/projects/', () =>
          HttpResponse.json([
            {
              ...mockProjects[0],
              url: 'https://makerworld.com/models/12345',
              cover_image_filename: null,
            },
          ])
        )
      );

      render(<ProjectsPage />);

      const link = await screen.findByLabelText(/Open project URL/i);
      expect(link).toBeInTheDocument();
      expect(link.getAttribute('href')).toBe('https://makerworld.com/models/12345');
      expect(link.getAttribute('target')).toBe('_blank');
      expect(link.getAttribute('rel')).toContain('noopener');
    });

    it('does not render the link icon when URL is not set', async () => {
      // Default fixture has no `url` field — verify the icon is absent.
      render(<ProjectsPage />);

      await waitFor(() => {
        expect(screen.getByText('Functional Parts')).toBeInTheDocument();
      });
      expect(screen.queryByLabelText(/Open project URL/i)).not.toBeInTheDocument();
    });

    it('clicking the URL link does not bubble to the card onClick', async () => {
      server.use(
        http.get('/api/v1/projects/', () =>
          HttpResponse.json([
            {
              ...mockProjects[0],
              url: 'https://example.com',
              cover_image_filename: null,
            },
          ])
        )
      );

      const user = userEvent.setup();
      render(<ProjectsPage />);

      const link = await screen.findByLabelText(/Open project URL/i);
      // Prevent the underlying anchor from triggering jsdom navigation noise
      // — we only need the propagation guard verified.
      link.addEventListener('click', (e) => e.preventDefault(), { once: true });
      await user.click(link);

      // No navigate / detail-page transition should have happened. Card root
      // is still rendered.
      expect(screen.getByText('Functional Parts')).toBeInTheDocument();
    });

    it('renders a cover image thumbnail when cover_image_filename is set', async () => {
      server.use(
        http.get('/api/v1/projects/', () =>
          HttpResponse.json([
            {
              ...mockProjects[0],
              url: null,
              cover_image_filename: 'cover_abc.png',
            },
          ])
        )
      );

      render(<ProjectsPage />);

      const img = await screen.findByAltText(/Project cover photo/i);
      expect(img).toBeInTheDocument();
      // Card thumbnail uses the GET endpoint URL, project.id is 1.
      expect(img.getAttribute('src')).toContain('/projects/1/cover-image');
    });

    it('renders the portal-mounted hover preview only while the thumbnail is hovered (#1155)', async () => {
      // The portal escapes the project card's ``overflow-hidden``, which
      // would otherwise clip a 384×384 popover anchored to a 40×40
      // thumbnail. Pin the contract end-to-end:
      //   - no preview in the DOM by default (mouseenter not fired yet)
      //   - mouseenter mounts a popover at document.body level (not
      //     nested in the card subtree, which would re-introduce the
      //     clipping bug)
      //   - mouseleave unmounts it
      //   - the popover ``<img>`` points at the same cover-image URL as
      //     the small thumbnail (object-contain so portrait/landscape
      //     MakerWorld photos aren't cropped)
      const { fireEvent } = await import('@testing-library/react');

      server.use(
        http.get('/api/v1/projects/', () =>
          HttpResponse.json([
            {
              ...mockProjects[0],
              url: null,
              cover_image_filename: 'cover_abc.png',
            },
          ])
        )
      );

      render(<ProjectsPage />);

      const thumb = await screen.findByAltText(/Project cover photo/i);
      // Default: no popover yet.
      expect(document.querySelectorAll('[aria-hidden="true"] img').length).toBe(0);

      // Hover: walk up to the wrapper div the component attaches its
      // mouseenter to. The portal mounts under document.body, NOT
      // nested in the card subtree.
      const wrapper = thumb.closest('[class*="flex-shrink-0"]') as HTMLElement;
      expect(wrapper).not.toBeNull();
      fireEvent.mouseEnter(wrapper);

      const previewImg = document.querySelector('[aria-hidden="true"] img') as HTMLImageElement | null;
      expect(previewImg).not.toBeNull();
      expect(previewImg!.getAttribute('src')).toContain('/projects/1/cover-image');
      expect(previewImg!.className).toContain('object-contain');

      // The portal mounts on document.body (or one of its direct
      // descendants), not inside the card — that's the whole point of
      // the portal, so a future refactor that drops the portal would
      // re-introduce the clipping regression.
      const popover = previewImg!.closest('[aria-hidden="true"]') as HTMLElement;
      expect(popover.closest('[class*="rounded-xl"]')).toBeNull();

      // Unmount on leave.
      fireEvent.mouseLeave(wrapper);
      expect(document.querySelectorAll('[aria-hidden="true"] img').length).toBe(0);
    });

    it('does not render a hover preview when there is no cover image', async () => {
      server.use(
        http.get('/api/v1/projects/', () =>
          HttpResponse.json([
            { ...mockProjects[0], url: null, cover_image_filename: null },
          ])
        )
      );

      render(<ProjectsPage />);

      await screen.findByText(mockProjects[0].name);
      expect(screen.queryByAltText(/Project cover photo/i)).toBeNull();
      // No aria-hidden img should ever appear because no thumbnail to
      // hover means the portal-mounting component never renders.
      expect(document.querySelectorAll('[aria-hidden="true"] img').length).toBe(0);
    });
  });

  describe('modal scrolls on short viewports (#1642)', () => {
    /**
     * Reporter on a Pi screen couldn't reach the Save button when editing a
     * project because the modal had no max-h / overflow. The structural fix
     * puts a max-h on the card, the form fields in a `flex-1 overflow-y-auto`
     * wrapper, and the Save/Cancel buttons in a `flex-shrink-0` sibling so
     * they're always visible regardless of scroll position.
     *
     * jsdom doesn't compute layout heights so we can't simulate the actual
     * overflow. We pin the structure instead: the scrollable wrapper exists,
     * the Save button is NOT a descendant of it, and the card has a max-h.
     * A future refactor that removes any of these would re-introduce the bug.
     */
    const editableProject = {
      id: 7,
      name: 'Spool holder',
      description: null,
      color: '#00ae42',
      url: null,
      cover_image_filename: null,
      archive_count: 0,
      total_print_time_seconds: 0,
      total_filament_grams: 0,
      target_plates_count: null,
      target_parts_count: null,
      tags: null,
      due_date: null,
      priority: null,
      budget: null,
      status: 'active' as const,
      created_at: '2024-01-01T00:00:00Z',
      updated_at: '2024-01-01T00:00:00Z',
    };

    it('renders the action footer outside the scrollable fields wrapper', () => {
      render(
        <ProjectModal
          project={editableProject}
          onClose={() => {}}
          onSave={() => {}}
          isLoading={false}
          currencySymbol="€"
          t={((k: string) => k) as never}
        />,
      );

      const saveButton = screen.getByRole('button', { name: 'common.save' });
      const scrollable = document.querySelector('.overflow-y-auto');
      expect(scrollable).not.toBeNull();
      // The save button must live OUTSIDE the scrollable region — otherwise
      // a long form pushes it below the fold on short viewports (#1642).
      expect(scrollable!.contains(saveButton)).toBe(false);
    });

    it('caps the modal card height so it cannot exceed the viewport', () => {
      render(
        <ProjectModal
          project={editableProject}
          onClose={() => {}}
          onSave={() => {}}
          isLoading={false}
          currencySymbol="€"
          t={((k: string) => k) as never}
        />,
      );

      // Card has max-h set so it never extends past the viewport — without
      // this, vertical-center alignment pushes the bottom of the modal
      // (including the action footer) off-screen.
      const card = document.querySelector('.max-h-\\[calc\\(100vh-2rem\\)\\]');
      expect(card).not.toBeNull();
    });
  });

  describe('edit dialog seeds itself from the list payload (#2536)', () => {
    /**
     * The same ProjectModal is opened from the projects list and from the
     * project detail page. The detail page hands it a full project; the list
     * hands it a list item. Reporter saw an empty tags field when editing from
     * the list, because the list payload didn't carry tags — and, unreported,
     * the dialog then submitted its default priority over the stored one.
     */
    const listItem = {
      id: 7,
      name: 'Spool holder',
      description: null,
      color: '#00ae42',
      status: 'active',
      target_count: null,
      target_parts_count: null,
      budget: null,
      tags: 'prototype,client-work',
      due_date: '2026-08-01T12:00:00Z',
      priority: 'high',
      created_at: '2024-01-01T00:00:00Z',
      archive_count: 0,
      total_items: 0,
      completed_count: 0,
      failed_count: 0,
      queue_count: 0,
      progress_percent: null,
      archives: [],
      url: null,
      cover_image_filename: null,
    };

    const renderModal = (onSave: (data: unknown) => void) =>
      render(
        <ProjectModal
          project={listItem}
          onClose={() => {}}
          onSave={onSave as never}
          isLoading={false}
          currencySymbol="€"
          t={((k: string) => k) as never}
        />,
      );

    const tagsInput = () => screen.getByPlaceholderText('projects.tagsPlaceholder') as HTMLInputElement;
    const dueDateInput = () => document.querySelector('input[type="date"]') as HTMLInputElement;
    const prioritySelect = () =>
      Array.from(document.querySelectorAll('select')).find((s) =>
        s.querySelector('option[value="urgent"]'),
      ) as HTMLSelectElement;

    it('prefills tags, due date and priority from a list item', () => {
      renderModal(() => {});

      expect(tagsInput().value).toBe('prototype,client-work');
      expect(dueDateInput().value).toBe('2026-08-01');
      expect(prioritySelect().value).toBe('high');
    });

    it('does not downgrade a stored priority when saving an untouched field', async () => {
      const user = userEvent.setup();
      const onSave = vi.fn();
      renderModal(onSave);

      await user.click(screen.getByRole('button', { name: 'common.save' }));

      // Before the fix the dialog fell back to its 'normal' default and sent
      // that, silently demoting a high/urgent project edited from the list.
      expect(onSave).toHaveBeenCalledWith(
        expect.objectContaining({ priority: 'high', tags: 'prototype,client-work' }),
      );
    });

    it('sends null when an existing tag list is cleared', async () => {
      const user = userEvent.setup();
      const onSave = vi.fn();
      renderModal(onSave);

      await user.clear(tagsInput());
      await user.click(screen.getByRole('button', { name: 'common.save' }));

      // undefined would drop the key from the PATCH body and the backend would
      // keep the old tags — the field has to be cleared explicitly.
      expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ tags: null }));
    });
  });
  describe('nesting projects under a master project (#1264)', () => {
    const listItem = (over: Record<string, unknown>) => ({
      id: 1,
      name: 'Airframe',
      description: null,
      color: '#00ae42',
      status: 'active',
      target_count: null,
      target_parts_count: null,
      target_sets: null,
      budget: null,
      tags: null,
      due_date: null,
      priority: 'normal',
      created_at: '2024-01-01T00:00:00Z',
      archive_count: 0,
      total_items: 0,
      completed_count: 0,
      failed_count: 0,
      queue_count: 0,
      progress_percent: null,
      parent_id: null,
      child_count: 0,
      archives: [],
      url: null,
      cover_image_filename: null,
      ...over,
    });

    describe('eligibleParents', () => {
      it('offers every other project when creating a new one', () => {
        const projects = [listItem({ id: 1 }), listItem({ id: 2, name: 'Wing' })];

        expect(eligibleParents(projects, undefined).map((p) => p.id)).toEqual([1, 2]);
      });

      it('never offers the project itself', () => {
        const projects = [listItem({ id: 1 }), listItem({ id: 2, name: 'Wing' })];

        expect(eligibleParents(projects, 1).map((p) => p.id)).toEqual([2]);
      });

      it('never offers a project already nested underneath', () => {
        // The API rejects a cycle, so offering one would only produce an error
        // the user can do nothing about.
        const projects = [
          listItem({ id: 1 }),
          listItem({ id: 2, name: 'Wing', parent_id: 1 }),
          listItem({ id: 3, name: 'Unrelated' }),
        ];

        expect(eligibleParents(projects, 1).map((p) => p.id)).toEqual([3]);
      });

      it('excludes a grandchild listed before its own parent', () => {
        // Reversion-proof for a single-pass version: the list arrives in update
        // order, so a grandchild can precede the parent that blocks it.
        const projects = [
          listItem({ id: 3, name: 'Spar', parent_id: 2 }),
          listItem({ id: 2, name: 'Wing', parent_id: 1 }),
          listItem({ id: 1 }),
        ];

        expect(eligibleParents(projects, 1)).toEqual([]);
      });
    });

    it('sends 0 to clear a parent, because null would read as omitted', async () => {
      const user = userEvent.setup();
      const onSave = vi.fn();
      server.use(http.get('/api/v1/projects/', () => HttpResponse.json([listItem({ id: 9, name: 'Other' })])));

      render(
        <ProjectModal
          project={listItem({ id: 1, parent_id: 9 })}
          onClose={() => {}}
          onSave={onSave as never}
          isLoading={false}
          currencySymbol="EUR"
          t={((k: string) => k) as never}
        />,
      );

      const parentSelect = () =>
        Array.from(document.querySelectorAll('select')).find((s) =>
          s.querySelector('option[value=""]'),
        ) as HTMLSelectElement;
      await waitFor(() => expect(parentSelect().value).toBe('9'));

      await user.selectOptions(parentSelect(), '');
      await user.click(screen.getByRole('button', { name: 'common.save' }));

      expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ parent_id: 0 }));
    });

    it('draws a sub-project inside its parent group, not loose in the grid', async () => {
      server.use(
        http.get('/api/v1/projects/', () =>
          HttpResponse.json([
            listItem({ id: 1, name: 'Airframe', child_count: 1 }),
            listItem({ id: 2, name: 'Wing', parent_id: 1 }),
          ]),
        ),
      );

      render(<ProjectsPage />);

      await waitFor(() => {
        expect(screen.getByText('Sub-projects of Airframe')).toBeInTheDocument();
      });
      // Two cards sitting columns apart cannot show that they belong together,
      // however they are captioned — the child has to be physically inside the
      // parent's group.
      const group = screen.getByText('Sub-projects of Airframe').closest('.col-span-full');
      expect(group).not.toBeNull();
      expect(group!.textContent).toContain('Wing');
      // And the caption is then redundant.
      expect(screen.queryByText('Part of Airframe')).not.toBeInTheDocument();
    });

    it('keeps a sub-project visible when the filter hides its parent', async () => {
      server.use(
        http.get('/api/v1/projects/', ({ request }) => {
          const status = new URL(request.url).searchParams.get('status');
          const all = [
            listItem({ id: 1, name: 'Airframe', status: 'completed', child_count: 1 }),
            listItem({ id: 2, name: 'Wing', parent_id: 1 }),
          ];
          return HttpResponse.json(status ? all.filter((p) => p.status === status) : all);
        }),
      );

      // The page opens on the Active filter, which already hides the completed
      // parent and leaves its active child with nowhere to nest.
      render(<ProjectsPage />);

      // Nothing to nest under, so it stays a top-level card — and then the
      // caption is the only thing that can say where it belongs.
      await waitFor(() => {
        expect(screen.getByText('Part of Airframe')).toBeInTheDocument();
      });
      expect(screen.queryByText('Sub-projects of Airframe')).not.toBeInTheDocument();
    });

    it('still shows every project when the data holds a parent cycle', async () => {
      // Grouping hides anything that has a visible parent, and a cycle has no
      // root — so A -> B -> A would take both projects off the page entirely.
      // A database written before the API refused a cycle can still hold one.
      server.use(
        http.get('/api/v1/projects/', () =>
          HttpResponse.json([
            listItem({ id: 1, name: 'Alpha', parent_id: 2 }),
            listItem({ id: 2, name: 'Beta', parent_id: 1 }),
          ]),
        ),
      );

      render(<ProjectsPage />);

      await waitFor(() => {
        expect(screen.getByText('Alpha')).toBeInTheDocument();
      });
      // Exactly once each: following the cycle round would redraw them.
      expect(screen.getAllByText('Alpha')).toHaveLength(1);
      expect(screen.getAllByText('Beta')).toHaveLength(1);
    });

    it('nests a grandchild under its own parent, not under the top of the tree', async () => {
      server.use(
        http.get('/api/v1/projects/', () =>
          HttpResponse.json([
            listItem({ id: 1, name: 'Airframe', child_count: 1 }),
            listItem({ id: 2, name: 'Wing', parent_id: 1, child_count: 1 }),
            listItem({ id: 3, name: 'Spar', parent_id: 2 }),
          ]),
        ),
      );

      render(<ProjectsPage />);

      await waitFor(() => {
        expect(screen.getByText('Sub-projects of Wing')).toBeInTheDocument();
      });
      const wingGroup = screen.getByText('Sub-projects of Wing').closest('.col-span-full');
      expect(wingGroup!.textContent).toContain('Spar');
      // Flattening every descendant onto the root would lose the depth.
      expect(wingGroup!.textContent).not.toContain('Airframe');
    });
  });
});
