import { describe, it, expect, vi, afterEach } from 'vitest';
import {
  readUiUrlState,
  buildUiUrlStateSearch,
  normalizeLegacyTasksUrl,
  parseLegacyTasksUrl,
  paramsEqualExcept,
  COLUMN_LABELS,
  COLUMN_LIST,
  type UiUrlState,
} from './urlState';

const DEFAULT_STATE: UiUrlState = {
  page: 'spaces',
  repo: '',
  status: 'all',
  search: '',
  sort: 'updated',
  scope: 'mine',
  detailTab: 'overview',
  mobileTab: 'chat',
  taskId: null,
  spaceSearch: '',
  spaceStatus: 'all',
  spaceSort: 'updated',
  spaceScope: 'mine',
};

function stubLocation(search: string) {
  vi.stubGlobal('window', { location: { search } });
}

afterEach(() => vi.unstubAllGlobals());

describe('readUiUrlState', () => {
  it('returns defaults when window is undefined (SSR/node)', () => {
    // node environment: no window global by default
    expect(readUiUrlState()).toEqual(DEFAULT_STATE);
  });

  it('returns defaults for an empty query string', () => {
    stubLocation('');
    const state = readUiUrlState();
    expect(state).toEqual({
      ...DEFAULT_STATE,
      section: undefined,
      piece: undefined,
    });
  });

  it('parses every valid parameter', () => {
    stubLocation(
      '?page=settings&repo=local%2Ftask-1&status=running&q=hello&sort=title' +
      '&tab=files&mobileTab=activity&task=42&section=auth&piece=chat' +
      '&pieceSource=user-custom&dashboardWidget=gpu&help=intro',
    );
    const state = readUiUrlState();
    expect(state.page).toBe('settings');
    expect(state.repo).toBe('local/task-1');
    expect(state.status).toBe('running');
    expect(state.search).toBe('hello');
    expect(state.sort).toBe('title');
    expect(state.detailTab).toBe('files');
    expect(state.mobileTab).toBe('activity');
    expect(state.taskId).toBe(42);
    expect(state.section).toBe('auth');
    expect(state.piece).toBe('chat');
    expect(state.pieceSource).toBe('user-custom');
    expect(state.dashboardWidget).toBe('gpu');
    expect(state.help).toBe('intro');
  });

  it('falls back to defaults on invalid enum values', () => {
    stubLocation('?page=bogus&status=bogus&sort=bogus&tab=bogus&mobileTab=bogus&section=bogus&pieceSource=bogus');
    const state = readUiUrlState();
    expect(state.page).toBe('spaces');
    expect(state.status).toBe('all');
    expect(state.sort).toBe('updated');
    expect(state.detailTab).toBe('overview');
    expect(state.mobileTab).toBe('chat');
    expect(state.section).toBeUndefined();
    expect(state.pieceSource).toBeUndefined();
  });

  it('rejects the removed legacy page=tasks (falls back to spaces)', () => {
    stubLocation('?page=tasks');
    expect(readUiUrlState().page).toBe('spaces');
  });

  it('rejects non-positive and non-numeric task ids', () => {
    for (const task of ['0', '-5', 'abc', '']) {
      stubLocation(`?task=${task}`);
      expect(readUiUrlState().taskId).toBeNull();
    }
    stubLocation('?task=7');
    expect(readUiUrlState().taskId).toBe(7);
  });

  it('still parses legacy settings section ids (soft bookmark landing)', () => {
    for (const legacy of ['gateway-keys', 'provider', 'workspace', 'browser-sessions']) {
      stubLocation(`?section=${legacy}`);
      expect(readUiUrlState().section).toBe(legacy);
    }
  });

  it('deep-links the organizations and server-tls settings sections', () => {
    for (const section of ['organizations', 'server-tls']) {
      stubLocation(`?section=${section}`);
      expect(readUiUrlState().section).toBe(section);
    }
  });

  it('no longer parses the removed notes settings section', () => {
    stubLocation('?section=notes');
    expect(readUiUrlState().section).toBeUndefined();
  });

  it('treats an empty piece param as undefined', () => {
    stubLocation('?piece=');
    expect(readUiUrlState().piece).toBeUndefined();
  });

  it('omits optional spread keys entirely when their params are absent', () => {
    stubLocation('?page=spaces');
    const state = readUiUrlState();
    expect('pieceSource' in state).toBe(false);
    expect('dashboardWidget' in state).toBe(false);
    expect('help' in state).toBe(false);
  });
});

describe('buildUiUrlStateSearch', () => {
  it('produces an empty string for the default state', () => {
    expect(buildUiUrlStateSearch(DEFAULT_STATE)).toBe('');
  });

  it('only serializes non-default values', () => {
    const search = buildUiUrlStateSearch({
      ...DEFAULT_STATE,
      status: 'failed',
      taskId: 12,
    });
    const params = new URLSearchParams(search);
    expect(params.get('status')).toBe('failed');
    expect(params.get('task')).toBe('12');
    expect(Array.from(params.keys()).sort()).toEqual(['status', 'task']);
  });

  it('omits the default dashboardWidget (worker-status) but keeps others', () => {
    expect(
      buildUiUrlStateSearch({ ...DEFAULT_STATE, dashboardWidget: 'worker-status' }),
    ).toBe('');
    expect(
      buildUiUrlStateSearch({ ...DEFAULT_STATE, dashboardWidget: 'gpu' }),
    ).toBe('dashboardWidget=gpu');
  });

  it('omits the default page (spaces) from the query string', () => {
    expect(buildUiUrlStateSearch(DEFAULT_STATE)).toBe('');
    expect(buildUiUrlStateSearch({ ...DEFAULT_STATE, page: 'settings' })).toBe('page=settings');
  });

  it('round-trips through readUiUrlState', () => {
    const state: UiUrlState = {
      page: 'help',
      repo: 'local/task-9',
      status: 'waiting_human',
      search: 'foo bar',
      sort: 'status',
      scope: 'others',
      detailTab: 'trace',
      mobileTab: 'files',
      taskId: 99,
      section: 'reflection',
      piece: 'research',
      pieceSource: 'builtin',
      dashboardWidget: 'queue',
      help: 'pieces',
      spaceSearch: 'report',
      spaceStatus: 'running',
      spaceSort: 'title',
      spaceScope: 'others',
    };
    stubLocation(`?${buildUiUrlStateSearch(state)}`);
    expect(readUiUrlState()).toEqual(state);
  });
});

describe('parseLegacyTasksUrl (M3 detection)', () => {
  it('returns null for non-legacy URLs', () => {
    expect(parseLegacyTasksUrl('')).toBeNull();
    expect(parseLegacyTasksUrl('page=spaces')).toBeNull();
    expect(parseLegacyTasksUrl('space=sp-x&chat=5')).toBeNull();
    expect(parseLegacyTasksUrl('page=settings&section=auth')).toBeNull();
  });

  it('detects a bare ?page=tasks with no task to resolve', () => {
    expect(parseLegacyTasksUrl('page=tasks')).toEqual({ legacyTaskId: null });
  });

  it('extracts the positive task id from explicit and bare deep links', () => {
    expect(parseLegacyTasksUrl('page=tasks&task=42&tab=files')).toEqual({ legacyTaskId: 42 });
    // bare ?task=ID (page omitted = old default) is the SW/notification form
    expect(parseLegacyTasksUrl('task=88')).toEqual({ legacyTaskId: 88 });
  });

  it('treats invalid/non-positive ids as no-task (still legacy)', () => {
    for (const task of ['0', '-5', 'abc']) {
      expect(parseLegacyTasksUrl(`page=tasks&task=${task}`)).toEqual({ legacyTaskId: null });
    }
  });
});

describe('normalizeLegacyTasksUrl (M2 redirect)', () => {
  it('lands a task deep link on the resolved owning workspace (M3), not personal', () => {
    // The caller resolves the task's real spaceId via a single-task fetch and
    // passes it here; an org/project task must NOT mis-land on personal.
    const params = new URLSearchParams(
      normalizeLegacyTasksUrl('page=tasks&task=42', 'sp-project-A')!,
    );
    expect(params.get('space')).toBe('sp-project-A');
    expect(params.get('chat')).toBe('42');
  });


  it('returns null for non-legacy URLs (no page=tasks)', () => {
    expect(normalizeLegacyTasksUrl('', 'sp-personal')).toBeNull();
    expect(normalizeLegacyTasksUrl('page=spaces', 'sp-personal')).toBeNull();
    expect(normalizeLegacyTasksUrl('page=settings&section=auth', 'sp-personal')).toBeNull();
  });

  it('rewrites a bare ?page=tasks to the personal workspace', () => {
    const next = normalizeLegacyTasksUrl('page=tasks', 'sp-personal');
    const params = new URLSearchParams(next!);
    expect(params.get('page')).toBeNull();
    expect(params.get('space')).toBe('sp-personal');
    expect(params.has('chat')).toBe(false);
  });

  it('maps page=tasks&task=ID&tab=... onto space + chat (drops legacy keys)', () => {
    const next = normalizeLegacyTasksUrl('page=tasks&task=42&tab=files&tasksTab=settings', 'sp-personal');
    const params = new URLSearchParams(next!);
    expect(params.get('page')).toBeNull();
    expect(params.get('space')).toBe('sp-personal');
    expect(params.get('chat')).toBe('42');
    expect(params.has('task')).toBe(false);
    expect(params.has('tab')).toBe(false);
    expect(params.has('tasksTab')).toBe(false);
  });

  it('drops an invalid/non-positive legacy task id', () => {
    for (const task of ['0', '-5', 'abc']) {
      const params = new URLSearchParams(normalizeLegacyTasksUrl(`page=tasks&task=${task}`, 'sp-personal')!);
      expect(params.has('chat')).toBe(false);
    }
  });

  it('omits space when the personal workspace has not resolved yet', () => {
    const params = new URLSearchParams(normalizeLegacyTasksUrl('page=tasks&task=7', null)!);
    expect(params.has('space')).toBe(false);
    expect(params.get('chat')).toBe('7');
  });

  it('rewrites a bare ?task=ID deep link (page omitted = old default)', () => {
    // Tasks was the default page so deep links dropped page=. SW/notifications
    // still emit this form; it must still open the chat after M2.
    const params = new URLSearchParams(normalizeLegacyTasksUrl('task=88', 'sp-personal')!);
    expect(params.get('space')).toBe('sp-personal');
    expect(params.get('chat')).toBe('88');
    expect(params.has('task')).toBe(false);
  });

  it('does NOT treat a new-scheme URL (space+chat, no task) as legacy', () => {
    expect(normalizeLegacyTasksUrl('space=sp-x&chat=5', 'sp-personal')).toBeNull();
    expect(normalizeLegacyTasksUrl('page=spaces&space=sp-x', 'sp-personal')).toBeNull();
  });
});

describe('space chat filter params', () => {
  it('parses space-scoped filter params independently from the tasks page', () => {
    stubLocation('?page=spaces&space=s1&q=tasksearch&status=failed&sort=title&scope=others' +
      '&sq=spacesearch&sstatus=running&ssort=status&sscope=others');
    const state = readUiUrlState();
    // Tasks-page filters stay on their own keys.
    expect(state.search).toBe('tasksearch');
    expect(state.status).toBe('failed');
    expect(state.sort).toBe('title');
    expect(state.scope).toBe('others');
    // Space-page filters read from the space-scoped keys.
    expect(state.spaceSearch).toBe('spacesearch');
    expect(state.spaceStatus).toBe('running');
    expect(state.spaceSort).toBe('status');
    expect(state.spaceScope).toBe('others');
  });

  it('falls back to defaults on invalid space filter values', () => {
    stubLocation('?sstatus=bogus&ssort=bogus&sscope=bogus');
    const state = readUiUrlState();
    expect(state.spaceStatus).toBe('all');
    expect(state.spaceSort).toBe('updated');
    expect(state.spaceScope).toBe('mine');
    expect(state.spaceSearch).toBe('');
  });

  it('serializes only non-default space filter values', () => {
    expect(buildUiUrlStateSearch(DEFAULT_STATE)).toBe('');
    const search = buildUiUrlStateSearch({
      ...DEFAULT_STATE,
      spaceSearch: 'hi',
      spaceStatus: 'queued',
      spaceSort: 'status',
      spaceScope: 'others',
    });
    const params = new URLSearchParams(search);
    expect(params.get('sq')).toBe('hi');
    expect(params.get('sstatus')).toBe('queued');
    expect(params.get('ssort')).toBe('status');
    expect(params.get('sscope')).toBe('others');
    // Does not leak onto the tasks-page keys.
    expect(params.has('q')).toBe(false);
    expect(params.has('status')).toBe(false);
    expect(params.has('sort')).toBe(false);
    expect(params.has('scope')).toBe(false);
  });
});

describe('paramsEqualExcept', () => {
  it('considers identical params equal', () => {
    const a = new URLSearchParams('a=1&b=2');
    const b = new URLSearchParams('a=1&b=2');
    expect(paramsEqualExcept(a, b, [])).toBe(true);
  });

  it('ignores key order', () => {
    const a = new URLSearchParams('b=2&a=1');
    const b = new URLSearchParams('a=1&b=2');
    expect(paramsEqualExcept(a, b, [])).toBe(true);
  });

  it('detects value differences', () => {
    const a = new URLSearchParams('a=1');
    const b = new URLSearchParams('a=2');
    expect(paramsEqualExcept(a, b, [])).toBe(false);
  });

  it('skips ignored keys on either side', () => {
    const a = new URLSearchParams('a=1&tab=files');
    const b = new URLSearchParams('a=1&tab=trace');
    expect(paramsEqualExcept(a, b, ['tab'])).toBe(true);
    const c = new URLSearchParams('a=1');
    expect(paramsEqualExcept(a, c, ['tab'])).toBe(true);
  });

  it('detects extra non-ignored keys', () => {
    const a = new URLSearchParams('a=1&extra=x');
    const b = new URLSearchParams('a=1');
    expect(paramsEqualExcept(a, b, [])).toBe(false);
  });

  it('handles repeated keys deterministically', () => {
    const a = new URLSearchParams('k=2&k=1');
    const b = new URLSearchParams('k=1&k=2');
    expect(paramsEqualExcept(a, b, [])).toBe(true);
  });
});

describe('column constants', () => {
  it('every status column has a label', () => {
    for (const col of COLUMN_LIST) {
      expect(COLUMN_LABELS[col], `label for ${col}`).toBeTruthy();
    }
  });
});
