import { expect } from '@playwright/test';
import type { Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
import { formatCentsWithOrganizationDefaults } from './utils/money';
import {
    createProjectViaApi,
    createPublicProjectViaApi,
    createProjectMemberViaApi,
    createTaskViaApi,
    createClientViaApi,
    createTimeEntryViaApi,
    archiveProjectViaApi,
    updateOrganizationSettingViaApi,
} from './utils/api';

async function goToProjectsOverview(page: Page) {
    await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
}

// Helper to clear localStorage before tests that check persistence
async function clearProjectTableState(page: Page) {
    await page.evaluate(() => {
        localStorage.removeItem('project-table-state');
    });
}

// Create new project via modal
test('test that creating and deleting a new project via the modal works', async ({ page }) => {
    const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
    await goToProjectsOverview(page);
    await page.getByRole('button', { name: 'Create Project' }).click();
    await page.getByLabel('Project Name').fill(newProjectName);
    await Promise.all([
        page.getByRole('button', { name: 'Create Project' }).click(),
        page.waitForResponse(
            async (response) =>
                response.url().includes('/projects') &&
                response.request().method() === 'POST' &&
                response.status() === 201 &&
                (await response.json()).data.id !== null &&
                (await response.json()).data.color !== null &&
                (await response.json()).data.client_id === null &&
                (await response.json()).data.name === newProjectName
        ),
    ]);

    await expect(page.getByTestId('project_table')).toContainText(newProjectName);
    const moreButton = page.locator("[aria-label='Actions for Project " + newProjectName + "']");
    await moreButton.click();
    const deleteButton = page.locator("[aria-label='Delete Project " + newProjectName + "']");

    await Promise.all([
        deleteButton.click(),
        page.waitForResponse(
            async (response) =>
                response.url().includes('/projects') &&
                response.request().method() === 'DELETE' &&
                response.status() === 204
        ),
    ]);
    await expect(page.getByTestId('project_table')).not.toContainText(newProjectName);
});

// Helper to select a status filter using the new dropdown UI
async function selectStatusFilter(page: Page, status: 'Active' | 'Archived') {
    // Click the Filter button to open the dropdown
    await page.getByRole('button', { name: 'Filter projects' }).click();
    // Click on Status submenu
    await page.getByRole('menuitem', { name: 'Status' }).click();
    // Select the status option
    await page.getByRole('menuitem', { name: status }).click();
}

// Helper to remove status filter by clicking the X on the badge
async function removeStatusFilter(page: Page) {
    const statusBadge = page.getByTestId('status-filter-badge');
    // Click the remove button (second button in the badge, contains XMarkIcon)
    await statusBadge.locator('button').last().click();
}

test('test that archiving and unarchiving projects works', async ({ page, ctx }) => {
    const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
    await createProjectViaApi(ctx, { name: newProjectName });

    await goToProjectsOverview(page);
    await clearProjectTableState(page);
    await page.reload();
    await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });

    // Archive the project
    await page.getByRole('row').first().getByRole('button').click();
    await page.getByRole('menuitem').getByText('Archive').first().click();

    // Project should still be visible since default is "all" (no filter)
    await expect(page.getByText(newProjectName)).toBeVisible();

    // Apply Active filter - archived project should disappear
    await selectStatusFilter(page, 'Active');
    await expect(page.getByText(newProjectName)).not.toBeVisible();

    // Remove Active filter and apply Archived filter
    await removeStatusFilter(page);
    await selectStatusFilter(page, 'Archived');
    await expect(page.getByText(newProjectName)).toBeVisible();

    // Unarchive the project
    await page.getByRole('row').first().getByRole('button').click();
    await page.getByRole('menuitem').getByText('Unarchive').first().click();

    // Project should disappear from Archived view
    await expect(page.getByText(newProjectName)).not.toBeVisible();

    // Remove Archived filter and apply Active filter to see the project
    await removeStatusFilter(page);
    await selectStatusFilter(page, 'Active');
    await expect(page.getByText(newProjectName)).toBeVisible();
});

test('test that the client can be changed in the edit project modal', async ({ page, ctx }) => {
    const projectName = 'Edit Client Project ' + Math.floor(1 + Math.random() * 100000);
    const clientName = 'Assigned Client ' + Math.floor(1 + Math.random() * 100000);
    await createProjectViaApi(ctx, { name: projectName });
    const client = await createClientViaApi(ctx, { name: clientName });

    await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
    await expect(page.getByText(projectName)).toBeVisible({ timeout: 10000 });

    // Open the project's Edit modal.
    await page.getByRole('row').first().getByRole('button').click();
    await page.getByRole('menuitem').getByText('Edit').first().click();
    await expect(page.getByRole('dialog')).toBeVisible();

    // Open the client dropdown (currently "No Client"), confirm it focuses, and pick the client.
    await page.getByRole('dialog').getByRole('button', { name: 'No Client' }).click();
    const clientSearch = page.getByPlaceholder('Search for a client...');
    await expect(clientSearch).toBeFocused();
    await clientSearch.fill(clientName);
    await page.getByRole('option', { name: clientName }).click();

    // The trigger updates to the chosen client.
    await expect(page.getByRole('dialog').getByRole('button', { name: clientName })).toBeVisible();

    // Saving persists the client assignment.
    await Promise.all([
        page.getByRole('button', { name: 'Update Project' }).click(),
        page.waitForResponse(
            async (response) =>
                response.url().includes('/projects/') &&
                response.request().method() === 'PUT' &&
                response.status() === 200 &&
                (await response.json()).data.client_id === client.id
        ),
    ]);
});

test('test that updating billable rate works with existing time entries', async ({ page, ctx }) => {
    const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
    const newBillableRate = Math.round(Math.random() * 10000);
    await createProjectViaApi(ctx, { name: newProjectName });

    await goToProjectsOverview(page);
    await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });

    await page.getByRole('row').first().getByRole('button').click();
    await page.getByRole('menuitem').getByText('Edit').first().click();

    // Set billable default to Billable
    await page.getByRole('dialog').locator('#billable').click();
    await page.getByRole('option', { name: 'Billable', exact: true }).click();

    // Set billable rate to Custom Rate
    await page.getByRole('dialog').locator('#billableRateType').click();
    await page.getByRole('option', { name: 'Custom Rate' }).click();

    await page.getByPlaceholder('Billable Rate').fill(newBillableRate.toString());
    await page.getByRole('button', { name: 'Update Project' }).click();

    await Promise.all([
        page.locator('button').filter({ hasText: 'Yes, update existing time' }).click(),
        page.waitForRequest(
            async (request) =>
                request.url().includes('/projects/') &&
                request.method() === 'PUT' &&
                request.postDataJSON().billable_rate === newBillableRate * 100
        ),
        page.waitForResponse(
            async (response) =>
                response.url().includes('/projects/') &&
                response.request().method() === 'PUT' &&
                response.status() === 200 &&
                (await response.json()).data.billable_rate === newBillableRate * 100
        ),
    ]);
    await expect(
        page
            .getByRole('row')
            .first()
            .getByText(formatCentsWithOrganizationDefaults(newBillableRate * 100))
    ).toBeVisible();
});

test('test that creating a project with default billable rate works', async ({ page }) => {
    const newProjectName = 'Default Rate Project ' + Math.floor(1 + Math.random() * 10000);
    await goToProjectsOverview(page);
    await page.getByRole('button', { name: 'Create Project' }).click();
    await page.getByLabel('Project Name').fill(newProjectName);

    // Set billable default to Billable (leaves rate type as Default Rate)
    await page.getByRole('dialog').locator('#billable').click();
    await page.getByRole('option', { name: 'Billable', exact: true }).click();

    // Verify rate type is "Default Rate" and the rate input is disabled
    await expect(page.getByRole('dialog').locator('#billableRateType')).toContainText(
        'Default Rate'
    );
    await expect(page.getByPlaceholder('Billable Rate')).toBeDisabled();

    await Promise.all([
        page.getByRole('button', { name: 'Create Project' }).click(),
        page.waitForResponse(
            async (response) =>
                response.url().includes('/projects') &&
                response.request().method() === 'POST' &&
                response.status() === 201 &&
                (await response.json()).data.is_billable === true &&
                (await response.json()).data.billable_rate === null
        ),
    ]);

    await expect(page.getByTestId('project_table')).toContainText(newProjectName);
});

test('test that creating a non-billable project works', async ({ page }) => {
    const newProjectName = 'Non-Billable Project ' + Math.floor(1 + Math.random() * 10000);
    await goToProjectsOverview(page);
    await page.getByRole('button', { name: 'Create Project' }).click();
    await page.getByLabel('Project Name').fill(newProjectName);

    // Billable default should already be "Non-billable" by default
    await expect(page.getByRole('dialog').locator('#billable')).toContainText('Non-billable');

    await Promise.all([
        page.getByRole('button', { name: 'Create Project' }).click(),
        page.waitForResponse(
            async (response) =>
                response.url().includes('/projects') &&
                response.request().method() === 'POST' &&
                response.status() === 201 &&
                (await response.json()).data.is_billable === false &&
                (await response.json()).data.billable_rate === null
        ),
    ]);

    await expect(page.getByTestId('project_table')).toContainText(newProjectName);
});

test('test that creating a public project via the modal works', async ({ page }) => {
    const newProjectName = 'Public Project ' + Math.floor(1 + Math.random() * 10000);
    await goToProjectsOverview(page);
    await page.getByRole('button', { name: 'Create Project' }).click();
    await page.getByLabel('Project Name').fill(newProjectName);

    // Visibility defaults to Private — switch it to Public
    await expect(page.getByRole('dialog').locator('#visibility')).toContainText('Private');
    await page.getByRole('dialog').locator('#visibility').click();
    await page.getByRole('option', { name: 'Public' }).click();

    await Promise.all([
        page.getByRole('button', { name: 'Create Project' }).click(),
        page.waitForResponse(
            async (response) =>
                response.url().includes('/projects') &&
                response.request().method() === 'POST' &&
                response.status() === 201 &&
                (await response.json()).data.is_public === true
        ),
    ]);

    await expect(page.getByTestId('project_table')).toContainText(newProjectName);
});

test('test that changing a project to public via the edit modal works', async ({ page, ctx }) => {
    const newProjectName = 'Edit Visibility Project ' + Math.floor(1 + Math.random() * 10000);
    await createProjectViaApi(ctx, { name: newProjectName });

    await goToProjectsOverview(page);
    await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });

    const projectRow = page.getByRole('row').filter({ hasText: newProjectName }).first();
    await projectRow.getByRole('button').click();
    await page.locator(`[aria-label='Edit Project ${newProjectName}']`).click();

    // Loaded as Private — switch it to Public
    await expect(page.getByRole('dialog').locator('#visibility')).toContainText('Private');
    await page.getByRole('dialog').locator('#visibility').click();
    await page.getByRole('option', { name: 'Public' }).click();

    await Promise.all([
        page.getByRole('button', { name: 'Update Project' }).click(),
        page.waitForResponse(
            async (response) =>
                response.url().includes('/projects/') &&
                response.request().method() === 'PUT' &&
                response.status() === 200 &&
                (await response.json()).data.is_public === true
        ),
    ]);
});

test('test that switching from custom rate to default rate clears billable rate', async ({
    page,
    ctx,
}) => {
    const newProjectName = 'Rate Switch Project ' + Math.floor(1 + Math.random() * 10000);
    // Create a project with an existing custom billable rate
    await createProjectViaApi(ctx, {
        name: newProjectName,
        is_billable: true,
        billable_rate: 15000,
    });

    await goToProjectsOverview(page);
    await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });

    await page.getByRole('row').first().getByRole('button').click();
    await page.getByRole('menuitem').getByText('Edit').first().click();

    // Verify it loaded as Billable with Custom Rate
    await expect(page.getByRole('dialog').locator('#billable')).toContainText('Billable');
    await expect(page.getByRole('dialog').locator('#billableRateType')).toContainText(
        'Custom Rate'
    );

    // Switch to Default Rate
    await page.getByRole('dialog').locator('#billableRateType').click();
    await page.getByRole('option', { name: 'Default Rate' }).click();

    // Rate input should now be disabled
    await expect(page.getByPlaceholder('Billable Rate')).toBeDisabled();

    // Submit — billable_rate changes from 15000 to null, so confirmation dialog appears
    await page.getByRole('button', { name: 'Update Project' }).click();
    await Promise.all([
        page.locator('button').filter({ hasText: 'Yes, update existing time' }).click(),
        page.waitForResponse(
            async (response) =>
                response.url().includes('/projects/') &&
                response.request().method() === 'PUT' &&
                response.status() === 200 &&
                (await response.json()).data.is_billable === true &&
                (await response.json()).data.billable_rate === null
        ),
    ]);
});

test('test that switching from billable to non-billable preserves rate settings', async ({
    page,
    ctx,
}) => {
    const newProjectName = 'Billable Reset Project ' + Math.floor(1 + Math.random() * 10000);
    // Create a project with a custom billable rate
    await createProjectViaApi(ctx, {
        name: newProjectName,
        is_billable: true,
        billable_rate: 20000,
    });

    await goToProjectsOverview(page);
    await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });

    await page.getByRole('row').first().getByRole('button').click();
    await page.getByRole('menuitem').getByText('Edit').first().click();

    // Verify it loaded correctly as Billable with Custom Rate
    await expect(page.getByRole('dialog').locator('#billable')).toContainText('Billable');
    await expect(page.getByRole('dialog').locator('#billableRateType')).toContainText(
        'Custom Rate'
    );

    // Switch to Non-billable
    await page.getByRole('dialog').locator('#billable').click();
    await page.getByRole('option', { name: 'Non-billable' }).click();

    // Rate type should still be Custom Rate (not reset)
    await expect(page.getByRole('dialog').locator('#billableRateType')).toContainText(
        'Custom Rate'
    );

    // Submit and verify project is non-billable but keeps its custom rate
    await Promise.all([
        page.getByRole('button', { name: 'Update Project' }).click(),
        page.waitForResponse(
            async (response) =>
                response.url().includes('/projects/') &&
                response.request().method() === 'PUT' &&
                response.status() === 200 &&
                (await response.json()).data.is_billable === false &&
                (await response.json()).data.billable_rate === 20000
        ),
    ]);
});

test('test that editing an existing billable project with default rate loads correctly', async ({
    page,
    ctx,
}) => {
    const newProjectName = 'Default Rate Edit Project ' + Math.floor(1 + Math.random() * 10000);
    // Create a project that is billable but has no custom rate (= default rate)
    await createProjectViaApi(ctx, {
        name: newProjectName,
        is_billable: true,
        billable_rate: null,
    });

    await goToProjectsOverview(page);
    await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });

    await page.getByRole('row').first().getByRole('button').click();
    await page.getByRole('menuitem').getByText('Edit').first().click();

    // Verify it loaded as Billable with Default Rate
    await expect(page.getByRole('dialog').locator('#billable')).toContainText('Billable');
    await expect(page.getByRole('dialog').locator('#billableRateType')).toContainText(
        'Default Rate'
    );
    await expect(page.getByPlaceholder('Billable Rate')).toBeDisabled();
});

// Sorting tests
test('test that sorting projects by all columns works', async ({ page, ctx }) => {
    // Seed projects with distinct values for each sortable column
    const clientAlpha = await createClientViaApi(ctx, { name: 'Alpha Client' });
    const clientBeta = await createClientViaApi(ctx, { name: 'Beta Client' });

    // Project A: client Alpha, low billable rate, has estimated time, active
    const projectA = await createProjectViaApi(ctx, {
        name: 'AAA Project',
        client_id: clientAlpha.id,
        is_billable: true,
        billable_rate: 5000,
        estimated_time: 36000, // 10h
    });
    // Add 1h of time entries (10% progress)
    await createTimeEntryViaApi(ctx, {
        duration: '1h',
        projectId: projectA.id,
    });

    // Project B: client Beta, high billable rate, has estimated time, archived
    const projectB = await createProjectViaApi(ctx, {
        name: 'BBB Project',
        client_id: clientBeta.id,
        is_billable: true,
        billable_rate: 15000,
        estimated_time: 7200, // 2h
    });
    // Add 1h of time entries (50% progress)
    await createTimeEntryViaApi(ctx, {
        duration: '1h',
        projectId: projectB.id,
    });
    await archiveProjectViaApi(ctx, {
        ...projectB,
        client_id: clientBeta.id,
        billable_rate: 15000,
        estimated_time: 7200,
    });

    // Project C: no client, medium billable rate, no estimated time, active
    const projectC = await createProjectViaApi(ctx, {
        name: 'CCC Project',
        is_billable: true,
        billable_rate: 10000,
    });
    // Add 3h of time entries
    await createTimeEntryViaApi(ctx, {
        duration: '3h',
        projectId: projectC.id,
    });

    await goToProjectsOverview(page);
    await clearProjectTableState(page);
    await page.reload();
    await expect(page.getByTestId('project_table')).toBeVisible();
    await expect(page.getByText('AAA Project')).toBeVisible();
    await expect(page.getByText('BBB Project')).toBeVisible();
    await expect(page.getByText('CCC Project')).toBeVisible();

    // Helper to get the visual order of our seeded projects by reading
    // all row text in a single evaluate call (avoids locator timing issues)
    const seededNames = ['AAA Project', 'BBB Project', 'CCC Project'];
    const getOrder = async (): Promise<string[]> => {
        const allRowTexts = await page.evaluate(() => {
            const table = document.querySelector('[data-testid="project_table"]');
            if (!table) return [];
            const rows = table.querySelectorAll('[role="row"]');
            return Array.from(rows).map((row) => row.textContent ?? '');
        });
        const order: string[] = [];
        for (const text of allRowTexts) {
            const match = seededNames.find((name) => text.includes(name));
            if (match) order.push(match);
        }
        return order;
    };

    // Helper: click a column header and wait for sort to apply.
    // expectedFirstAmongSeeded = which of our 3 seeded projects should appear first
    const clickSortHeader = async (headerText: string, expectedFirstAmongSeeded: string) => {
        const header = page
            .locator('[data-testid="project_table"] .select-none', {
                hasText: headerText,
            })
            .first();
        await header.click();
        // Wait until the expected project appears before the others among our seeded set
        await page.waitForFunction(
            ({ expected, names }) => {
                const table = document.querySelector('[data-testid="project_table"]');
                if (!table) return false;
                const rows = table.querySelectorAll('[role="row"]');
                let firstSeededIdx = -1;
                for (let i = 0; i < rows.length; i++) {
                    const text = rows[i].textContent ?? '';
                    if (names.some((n: string) => text.includes(n))) {
                        firstSeededIdx = i;
                        break;
                    }
                }
                if (firstSeededIdx === -1) return false;
                return (rows[firstSeededIdx].textContent ?? '').includes(expected);
            },
            { expected: expectedFirstAmongSeeded, names: seededNames },
            { timeout: 5000 }
        );
    };

    // --- Sort by Name ---
    // Default is name asc (A-Z)
    let order = await getOrder();
    expect(order).toEqual(['AAA Project', 'BBB Project', 'CCC Project']);

    // Click to toggle to Z-A
    await clickSortHeader('Name', 'CCC Project');
    order = await getOrder();
    expect(order).toEqual(['CCC Project', 'BBB Project', 'AAA Project']);

    // --- Sort by Client (text: first click = A-Z, no-client last) ---
    await clickSortHeader('Client', 'AAA Project');
    order = await getOrder();
    expect(order).toEqual(['AAA Project', 'BBB Project', 'CCC Project']); // Alpha, Beta, No client

    // Reverse: Z-A, no-client still last
    await clickSortHeader('Client', 'BBB Project');
    order = await getOrder();
    expect(order).toEqual(['BBB Project', 'AAA Project', 'CCC Project']); // Beta, Alpha, No client

    // --- Sort by Total Time (numeric: first click = highest first) ---
    await clickSortHeader('Total Time', 'CCC Project');
    order = await getOrder();
    expect(order[0]).toBe('CCC Project'); // C=3h first, A and B tied at 1h

    // Reverse: lowest first
    await clickSortHeader('Total Time', 'AAA Project');
    order = await getOrder();
    expect(order[2]).toBe('CCC Project'); // C=3h last

    // --- Sort by Billable Rate (numeric: first click = highest first) ---
    await clickSortHeader('Billable Rate', 'BBB Project');
    order = await getOrder();
    expect(order).toEqual(['BBB Project', 'CCC Project', 'AAA Project']); // 15000, 10000, 5000

    // Reverse: lowest first
    await clickSortHeader('Billable Rate', 'AAA Project');
    order = await getOrder();
    expect(order).toEqual(['AAA Project', 'CCC Project', 'BBB Project']); // 5000, 10000, 15000

    // --- Sort by Progress (numeric: first click = highest first, no-estimate last) ---
    await clickSortHeader('Progress', 'BBB Project');
    order = await getOrder();
    expect(order).toEqual(['BBB Project', 'AAA Project', 'CCC Project']); // 50%, 10%, no estimate

    // Reverse: lowest first, no-estimate still last
    await clickSortHeader('Progress', 'AAA Project');
    order = await getOrder();
    expect(order).toEqual(['AAA Project', 'BBB Project', 'CCC Project']); // 10%, 50%, no estimate

    // --- Sort by Status (first click = active first, archived last) ---
    await expect(async () => {
        await clickSortHeader('Status', 'AAA Project');
        order = await getOrder();
        expect(order.indexOf('BBB Project')).toBeGreaterThan(order.indexOf('AAA Project'));
        expect(order.indexOf('BBB Project')).toBeGreaterThan(order.indexOf('CCC Project'));
    }).toPass({ timeout: 5000 });

    // Reverse: archived first
    await expect(async () => {
        await clickSortHeader('Status', 'BBB Project');
        order = await getOrder();
        expect(order.indexOf('BBB Project')).toBeLessThan(order.indexOf('AAA Project'));
        expect(order.indexOf('BBB Project')).toBeLessThan(order.indexOf('CCC Project'));
    }).toPass({ timeout: 5000 });
});

// Filter tests
test('test that filtering projects by status works', async ({ page, ctx }) => {
    const newProjectName = 'Filter Test Project ' + Math.floor(1 + Math.random() * 10000);
    await createProjectViaApi(ctx, { name: newProjectName });

    await goToProjectsOverview(page);
    await clearProjectTableState(page);
    await page.reload();
    await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });

    // Archive the project
    await page.getByRole('row').first().getByRole('button').click();
    await page.getByRole('menuitem').getByText('Archive').first().click();

    // Project should still be visible (default is "all" - no filter)
    await expect(page.getByText(newProjectName)).toBeVisible();

    // Apply Active filter - archived project should disappear
    await selectStatusFilter(page, 'Active');
    await expect(page.getByText(newProjectName)).not.toBeVisible();

    // Remove Active filter - project should reappear (back to "all")
    await removeStatusFilter(page);
    await expect(page.getByText(newProjectName)).toBeVisible();

    // Apply Archived filter - project should still be visible
    await selectStatusFilter(page, 'Archived');
    await expect(page.getByText(newProjectName)).toBeVisible();

    // Remove Archived filter and apply Active filter - project should not be visible
    await removeStatusFilter(page);
    await selectStatusFilter(page, 'Active');
    await expect(page.getByText(newProjectName)).not.toBeVisible();
});

test('test that filter state persists after page reload', async ({ page }) => {
    await goToProjectsOverview(page);
    await clearProjectTableState(page);
    await page.reload();

    // Apply Active status filter
    await selectStatusFilter(page, 'Active');

    // Verify the filter badge is visible
    await expect(page.getByTestId('status-filter-badge')).toBeVisible();

    // Reload the page
    await page.reload();

    // Verify the filter badge is still visible after reload
    await expect(page.getByTestId('status-filter-badge')).toBeVisible();
});

test('test that sort state persists after page reload', async ({ page }) => {
    await goToProjectsOverview(page);
    await clearProjectTableState(page);
    await page.reload();

    // Click on Name header twice to sort descending
    const nameHeader = page.getByText('Name').first();
    await nameHeader.click();
    await expect(nameHeader.locator('svg')).toBeVisible();
    await nameHeader.click();

    // Reload the page
    await page.reload();

    // Verify descending sort indicator is visible on Name column
    await expect(page.getByTestId('project_table')).toBeVisible();
});

test('test that custom billable rate is displayed correctly on project detail page', async ({
    page,
    ctx,
}) => {
    const newProjectName = 'Billable Rate Project ' + Math.floor(1 + Math.random() * 10000);
    const newBillableRate = Math.round(10 + Math.random() * 1000);
    await createProjectViaApi(ctx, { name: newProjectName });

    await goToProjectsOverview(page);
    await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });

    // Edit the project to set a custom billable rate
    await page.getByRole('row').first().getByRole('button').click();
    await page.getByRole('menuitem').getByText('Edit').first().click();

    // Set billable default to Billable
    await page.getByRole('dialog').locator('#billable').click();
    await page.getByRole('option', { name: 'Billable', exact: true }).click();

    // Set billable rate to Custom Rate
    await page.getByRole('dialog').locator('#billableRateType').click();
    await page.getByRole('option', { name: 'Custom Rate' }).click();

    await page.getByPlaceholder('Billable Rate').fill(newBillableRate.toString());
    await page.getByRole('button', { name: 'Update Project' }).click();

    await Promise.all([
        page.locator('button').filter({ hasText: 'Yes, update existing time' }).click(),
        page.waitForResponse(
            async (response) =>
                response.url().includes('/projects/') &&
                response.request().method() === 'PUT' &&
                response.status() === 200
        ),
    ]);

    // Navigate to the project detail page by clicking the project name
    await page.getByText(newProjectName).first().click();
    await page.waitForURL(/\/projects\/[a-f0-9-]+/);

    // Verify the badge displays the correctly formatted billable rate
    const expectedFormattedRate = formatCentsWithOrganizationDefaults(newBillableRate * 100);
    await expect(page.locator('nav[aria-label="Breadcrumb"]').locator('..')).toContainText(
        expectedFormattedRate
    );
});

// Tests for estimated time input (Issue #460)
test('test that creating a project with estimated time in human-readable format works', async ({
    page,
}) => {
    const newProjectName = 'Estimated Time Project ' + Math.floor(1 + Math.random() * 10000);
    await goToProjectsOverview(page);
    await page.getByRole('button', { name: 'Create Project' }).click();
    await page.getByLabel('Project Name').fill(newProjectName);

    // Fill in estimated time using human-readable format
    const estimatedTimeInput = page.getByLabel('Time Estimated');
    await estimatedTimeInput.fill('2h 30m');
    await estimatedTimeInput.press('Tab');

    await Promise.all([
        page.getByRole('button', { name: 'Create Project' }).click(),
        page.waitForResponse(
            async (response) =>
                response.url().includes('/projects') &&
                response.request().method() === 'POST' &&
                response.status() === 201 &&
                // 2h 30m = 9000 seconds
                (await response.json()).data.estimated_time === 9000
        ),
    ]);

    await expect(page.getByTestId('project_table')).toContainText(newProjectName);
});

test('test that creating a project with estimated time using decimal notation works', async ({
    page,
}) => {
    const newProjectName = 'Decimal Estimated Project ' + Math.floor(1 + Math.random() * 10000);
    await goToProjectsOverview(page);
    await page.getByRole('button', { name: 'Create Project' }).click();
    await page.getByLabel('Project Name').fill(newProjectName);

    // Fill in estimated time using decimal notation (1.5 hours = 1h 30m)
    const estimatedTimeInput = page.getByLabel('Time Estimated');
    await estimatedTimeInput.fill('1.5');
    await estimatedTimeInput.press('Tab');

    await Promise.all([
        page.getByRole('button', { name: 'Create Project' }).click(),
        page.waitForResponse(
            async (response) =>
                response.url().includes('/projects') &&
                response.request().method() === 'POST' &&
                response.status() === 201 &&
                // 1.5 hours = 5400 seconds
                (await response.json()).data.estimated_time === 5400
        ),
    ]);

    await expect(page.getByTestId('project_table')).toContainText(newProjectName);
});

test('test that creating a project with estimated time using comma decimal notation works', async ({
    page,
}) => {
    const newProjectName = 'Comma Decimal Project ' + Math.floor(1 + Math.random() * 10000);
    await goToProjectsOverview(page);
    await page.getByRole('button', { name: 'Create Project' }).click();
    await page.getByLabel('Project Name').fill(newProjectName);

    // Fill in estimated time using comma decimal notation (2,5 hours = 2h 30m)
    const estimatedTimeInput = page.getByLabel('Time Estimated');
    await estimatedTimeInput.fill('2,5');
    await estimatedTimeInput.press('Tab');

    await Promise.all([
        page.getByRole('button', { name: 'Create Project' }).click(),
        page.waitForResponse(
            async (response) =>
                response.url().includes('/projects') &&
                response.request().method() === 'POST' &&
                response.status() === 201 &&
                // 2.5 hours = 9000 seconds
                (await response.json()).data.estimated_time === 9000
        ),
    ]);

    await expect(page.getByTestId('project_table')).toContainText(newProjectName);
});

test('test that updating estimated time on existing project works', async ({ page, ctx }) => {
    const newProjectName = 'Update Estimated Project ' + Math.floor(1 + Math.random() * 10000);
    await createProjectViaApi(ctx, { name: newProjectName });

    await goToProjectsOverview(page);
    await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });

    // Edit the project to add estimated time
    await page.getByRole('row').first().getByRole('button').click();
    await page.getByRole('menuitem').getByText('Edit').first().click();

    // Fill in estimated time
    const estimatedTimeInput = page.getByLabel('Time Estimated');
    await estimatedTimeInput.fill('4h 15m');
    await estimatedTimeInput.press('Tab');

    await Promise.all([
        page.getByRole('button', { name: 'Update Project' }).click(),
        page.waitForResponse(
            async (response) =>
                response.url().includes('/projects/') &&
                response.request().method() === 'PUT' &&
                response.status() === 200 &&
                // 4h 15m = 15300 seconds
                (await response.json()).data.estimated_time === 15300
        ),
    ]);
});

test('test that estimated time input displays formatted value after blur', async ({ page }) => {
    await goToProjectsOverview(page);
    await page.getByRole('button', { name: 'Create Project' }).click();

    const estimatedTimeInput = page.getByLabel('Time Estimated');

    // Enter time in various formats and check the displayed value
    await estimatedTimeInput.fill('90');
    await estimatedTimeInput.press('Tab');
    // 90 hours should be displayed as "90h 00min" (default format)
    await expect(estimatedTimeInput).toHaveValue(/90h/);

    await estimatedTimeInput.fill('1:30');
    await estimatedTimeInput.press('Tab');
    // 1:30 should be displayed as "1h 30min"
    await expect(estimatedTimeInput).toHaveValue(/1h.*30/);
});

test('test that editing a task name on the project detail page works', async ({ page, ctx }) => {
    const projectName = 'Task Edit Project ' + Math.floor(1 + Math.random() * 10000);
    const originalTaskName = 'Original Task ' + Math.floor(1 + Math.random() * 10000);
    const updatedTaskName = 'Updated Task ' + Math.floor(1 + Math.random() * 10000);
    const project = await createProjectViaApi(ctx, { name: projectName });
    await createTaskViaApi(ctx, { name: originalTaskName, project_id: project.id });

    // Navigate to the project detail page
    await goToProjectsOverview(page);
    await expect(page.getByText(projectName)).toBeVisible({ timeout: 10000 });
    await page.getByText(projectName).first().click();
    await page.waitForURL(/\/projects\/[a-f0-9-]+/);

    // Verify task is visible
    await expect(page.getByTestId('task_table')).toContainText(originalTaskName);

    // Open edit modal via actions menu
    const moreButton = page.locator("[aria-label='Actions for Task " + originalTaskName + "']");
    await moreButton.click();
    await page.getByTestId('task_edit').click();

    // Update the task name
    await page.locator('#taskName').fill(updatedTaskName);
    await Promise.all([
        page.getByRole('button', { name: 'Update Task' }).click(),
        page.waitForResponse(
            async (response) =>
                response.url().includes('/tasks') &&
                response.request().method() === 'PUT' &&
                response.status() === 200
        ),
    ]);

    // Verify updated name is shown and old name is gone
    await expect(page.getByTestId('task_table')).toContainText(updatedTaskName);
    await expect(page.getByTestId('task_table')).not.toContainText(originalTaskName);
});

// =============================================
// Context Menu Tests
// =============================================

test('test that project context menu edit updates the project', async ({ page, ctx }) => {
    const projectName = 'CtxEditProject ' + Math.floor(1 + Math.random() * 10000);
    const updatedName = 'CtxUpdatedProject ' + Math.floor(1 + Math.random() * 10000);
    await createProjectViaApi(ctx, { name: projectName });
    await goToProjectsOverview(page);

    const row = page.getByRole('row').filter({ hasText: projectName }).first();
    await expect(row).toBeVisible();
    await row.click({ button: 'right' });
    await expect(page.getByRole('menu')).toBeVisible();
    await page.getByRole('menuitem', { name: 'Edit' }).click();
    await expect(page.getByRole('dialog')).toBeVisible();

    await page.getByPlaceholder('Project Name').fill(updatedName);
    await Promise.all([
        page.getByRole('button', { name: 'Update Project' }).click(),
        page.waitForResponse(
            (response) =>
                response.url().includes('/projects/') &&
                response.request().method() === 'PUT' &&
                response.status() === 200
        ),
    ]);

    await expect(page.getByTestId('project_table')).toContainText(updatedName);
    await expect(page.getByTestId('project_table')).not.toContainText(projectName);
});

test('test that project context menu archive archives the project', async ({ page, ctx }) => {
    const projectName = 'CtxArchiveProject ' + Math.floor(1 + Math.random() * 10000);
    await createProjectViaApi(ctx, { name: projectName });
    await goToProjectsOverview(page);

    const row = page.getByRole('row').filter({ hasText: projectName }).first();
    await expect(row).toBeVisible();
    await row.click({ button: 'right' });
    await expect(page.getByRole('menu')).toBeVisible();
    await Promise.all([
        page.waitForResponse(
            (response) =>
                response.url().includes('/projects') &&
                response.request().method() === 'PUT' &&
                response.status() === 200
        ),
        page.getByRole('menuitem', { name: 'Archive' }).click(),
    ]);
    // After archiving, the project stays visible (default filter is 'all') but status changes to 'Archived'
    await expect(row).toContainText('Archived');
});

test('test that project context menu delete deletes the project', async ({ page, ctx }) => {
    const projectName = 'CtxDeleteProject ' + Math.floor(1 + Math.random() * 10000);
    await createProjectViaApi(ctx, { name: projectName });
    await goToProjectsOverview(page);

    const row = page.getByRole('row').filter({ hasText: projectName }).first();
    await expect(row).toBeVisible();
    await row.click({ button: 'right' });
    await expect(page.getByRole('menu')).toBeVisible();
    await Promise.all([
        page.waitForResponse(
            (response) =>
                response.url().includes('/projects') &&
                response.request().method() === 'DELETE' &&
                response.status() === 204
        ),
        page.getByRole('menuitem', { name: 'Delete' }).click(),
    ]);
    await expect(page.getByTestId('project_table')).not.toContainText(projectName);
});

// =============================================
// Employee Permission Tests
// =============================================

test.describe('Employee Projects Restrictions', () => {
    test('employee can view public projects but cannot create', async ({ ctx, employee }) => {
        const projectName = 'EmpViewProj ' + Math.floor(Math.random() * 10000);
        await createPublicProjectViaApi(ctx, { name: projectName });

        await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects');
        await expect(employee.page.getByTestId('projects_view')).toBeVisible({
            timeout: 10000,
        });

        // Employee can see the public project
        await expect(employee.page.getByText(projectName)).toBeVisible({ timeout: 10000 });

        // Employee cannot see Create Project button
        await expect(
            employee.page.getByRole('button', { name: 'Create Project' })
        ).not.toBeVisible();
    });

    test('employee cannot see edit/delete/archive actions on projects', async ({
        ctx,
        employee,
    }) => {
        const projectName = 'EmpActionsProj ' + Math.floor(Math.random() * 10000);
        await createPublicProjectViaApi(ctx, { name: projectName });

        await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects');
        await expect(employee.page.getByText(projectName)).toBeVisible({ timeout: 10000 });

        // Click the actions dropdown trigger to open the menu
        const actionsButton = employee.page.locator(
            `[aria-label='Actions for Project ${projectName}']`
        );
        await actionsButton.click();

        // The dropdown menu items (Edit, Archive, Delete) should NOT be visible
        await expect(
            employee.page.locator(`[aria-label='Edit Project ${projectName}']`)
        ).not.toBeVisible();
        await expect(
            employee.page.locator(`[aria-label='Archive Project ${projectName}']`)
        ).not.toBeVisible();
        await expect(
            employee.page.locator(`[aria-label='Delete Project ${projectName}']`)
        ).not.toBeVisible();
    });

    test('employee does not see private projects they are not a member of', async ({
        ctx,
        employee,
    }) => {
        const publicName = 'EmpPublicVisible ' + Math.floor(Math.random() * 10000);
        const privateName = 'EmpPrivateHidden ' + Math.floor(Math.random() * 10000);
        await createPublicProjectViaApi(ctx, { name: publicName });
        // createProjectViaApi defaults to is_public: false (private); the employee is not a member
        await createProjectViaApi(ctx, { name: privateName });

        await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects');
        await expect(employee.page.getByTestId('projects_view')).toBeVisible({ timeout: 10000 });

        // The public project is visible — confirms the list has loaded
        await expect(employee.page.getByText(publicName)).toBeVisible({ timeout: 10000 });

        // The private project the employee is not a member of must not appear
        await expect(employee.page.getByText(privateName)).not.toBeVisible();
    });

    test('employee can see a private project they are a member of', async ({ ctx, employee }) => {
        const projectName = 'EmpPrivateMember ' + Math.floor(Math.random() * 10000);
        const project = await createProjectViaApi(ctx, { name: projectName });
        // Add the employee as a project member so the private project becomes visible to them
        await createProjectMemberViaApi(ctx, project.id, { member_id: employee.memberId });

        await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects');
        await expect(employee.page.getByTestId('projects_view')).toBeVisible({ timeout: 10000 });

        // The private project is visible because the employee is a member
        await expect(employee.page.getByText(projectName)).toBeVisible({ timeout: 10000 });
    });
});

test.describe('Employee Billable Rate Visibility', () => {
    test('employee cannot see billable rate column by default', async ({ ctx, employee }) => {
        const projectName = 'EmpBillableProj ' + Math.floor(Math.random() * 10000);
        await createPublicProjectViaApi(ctx, {
            name: projectName,
            is_billable: true,
            billable_rate: 15000,
        });

        await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects');
        await expect(employee.page.getByText(projectName)).toBeVisible({ timeout: 10000 });

        // Billable Rate column should not be visible to employee by default
        await expect(employee.page.getByText('Billable Rate')).not.toBeVisible();
    });

    test('employee can see billable rate column when employees_can_see_billable_rates is enabled', async ({
        ctx,
        employee,
    }) => {
        await updateOrganizationSettingViaApi(ctx, { employees_can_see_billable_rates: true });

        const projectName = 'EmpBillableVisProj ' + Math.floor(Math.random() * 10000);
        await createPublicProjectViaApi(ctx, {
            name: projectName,
            is_billable: true,
            billable_rate: 20000,
        });

        await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects');
        await expect(employee.page.getByText(projectName)).toBeVisible({ timeout: 10000 });

        // Billable Rate column header should be visible
        await expect(employee.page.getByText('Billable Rate')).toBeVisible();

        // The project row should show the formatted billable rate
        const projectRow = employee.page.getByRole('row').filter({ hasText: projectName });
        await expect(projectRow).toContainText('200');
    });
});

// ──────────────────────────────────────────────────
// Pagination Tests
// ──────────────────────────────────────────────────

test.describe('Projects Pagination', () => {
    test.describe.configure({ timeout: 30000 });

    test('test that project table paginates when there are more than 15 projects', async ({
        page,
        ctx,
    }) => {
        // Create 17 projects with zero-padded names so alphabetical sort is predictable.
        // Page size is 15 → page 1 shows indices 00–14, page 2 shows 15–16.
        const seed = Math.floor(Math.random() * 100000);
        const prefix = `PaginationProj ${seed} `;
        await Promise.all(
            Array.from({ length: 17 }, (_, i) =>
                createProjectViaApi(ctx, { name: prefix + String(i).padStart(2, '0') })
            )
        );

        await goToProjectsOverview(page);
        await clearProjectTableState(page);
        await page.reload();

        // Default sort is name asc; first 15 projects (00–14) should be on page 1.
        await expect(page.getByText(prefix + '00')).toBeVisible({ timeout: 10000 });
        await expect(page.getByRole('button', { name: 'Next Page' })).toBeVisible();
        // Project 15 should be on page 2, not visible on page 1.
        await expect(page.getByText(prefix + '15')).not.toBeVisible();

        // Exactly 15 data rows should be mounted on page 1.
        await expect(page.getByRole('row')).toHaveCount(15);

        // Go to page 2.
        await page.getByRole('button', { name: 'Next Page' }).click();
        await expect(page.getByText(prefix + '15')).toBeVisible();
        await expect(page.getByText(prefix + '00')).not.toBeVisible();
        // Page 2 contains the remaining 2 projects (15, 16).
        await expect(page.getByRole('row')).toHaveCount(2);

        // Return to page 1 via Previous Page.
        await page.getByRole('button', { name: 'Previous Page' }).click();
        await expect(page.getByText(prefix + '00')).toBeVisible();
        await expect(page.getByText(prefix + '15')).not.toBeVisible();

        // Jump to last page then back to first page.
        await page.getByRole('button', { name: 'Last Page' }).click();
        await expect(page.getByText(prefix + '15')).toBeVisible();
        await page.getByRole('button', { name: 'First Page' }).click();
        await expect(page.getByText(prefix + '00')).toBeVisible();
        await expect(page.getByText(prefix + '15')).not.toBeVisible();

        // Direct page-number button navigation.
        await page.getByRole('button', { name: 'Page 2' }).click();
        await expect(page.getByText(prefix + '15')).toBeVisible();
        // Page 2 button should be marked as selected.
        await expect(page.getByRole('button', { name: 'Page 2' })).toHaveAttribute(
            'aria-current',
            'page'
        );
    });

    test('test that project pagination is not shown when there are 15 or fewer projects', async ({
        page,
        ctx,
    }) => {
        await Promise.all(
            Array.from({ length: 10 }, (_, i) =>
                createProjectViaApi(ctx, {
                    name: `FewProj ${Math.floor(Math.random() * 100000)} ${i}`,
                })
            )
        );

        await goToProjectsOverview(page);
        await clearProjectTableState(page);
        await page.reload();

        await expect(page.getByTestId('project_table')).toBeVisible();
        await expect(page.getByRole('button', { name: 'Next Page' })).toHaveCount(0);
    });

    test('test that changing the sort resets pagination to page 1', async ({ page, ctx }) => {
        const seed = Math.floor(Math.random() * 100000);
        const prefix = `SortPagProj ${seed} `;
        await Promise.all(
            Array.from({ length: 17 }, (_, i) =>
                createProjectViaApi(ctx, { name: prefix + String(i).padStart(2, '0') })
            )
        );

        await goToProjectsOverview(page);
        await clearProjectTableState(page);
        await page.reload();

        await expect(page.getByText(prefix + '00')).toBeVisible({ timeout: 10000 });

        // Go to page 2.
        await page.getByRole('button', { name: 'Next Page' }).click();
        await expect(page.getByText(prefix + '15')).toBeVisible();

        // Sort by name descending: header click toggles asc → desc.
        const nameHeader = page
            .locator('[data-testid="project_table"] .select-none', { hasText: 'Name' })
            .first();
        await nameHeader.click();

        // After sorting, pagination resets to page 1; desc order → 16, 15, ... 02 visible.
        await expect(page.getByText(prefix + '16')).toBeVisible();
        await expect(page.getByText(prefix + '15')).toBeVisible();
        // Index 00 should now be on page 2 (last in desc order).
        await expect(page.getByText(prefix + '00')).not.toBeVisible();
    });
});
