import { afterEach, describe, it, expect, vi } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { Repository } from '../db/repository.js';
import { requireAuth, requireAdmin, fetchGiteaOrgsForUser, isProviderConfigured, isProviderActive, safeReturnTo, isTopLevelNavigation, consumeReturnTo } from './auth.js';
import type { AuthConfig } from '../config.js';
import type { Request, Response, NextFunction } from 'express';

function mockReqRes(overrides: Partial<Request> = {}) {
  const req = {
    isAuthenticated: () => false,
    user: undefined,
    originalUrl: '/api/test',
    headers: { accept: 'application/json' },
    ...overrides,
  } as unknown as Request;
  const res = {
    status: vi.fn().mockReturnThis(),
    json: vi.fn().mockReturnThis(),
    redirect: vi.fn().mockReturnThis(),
  } as unknown as Response;
  const next = vi.fn() as NextFunction;
  return { req, res, next };
}

describe('requireAuth', () => {
  it('calls next() for authenticated active user', () => {
    const { req, res, next } = mockReqRes({
      isAuthenticated: () => true,
      user: { id: '1', role: 'user', status: 'active' },
    } as Partial<Request>);
    requireAuth(req, res, next);
    expect(next).toHaveBeenCalled();
  });

  it('returns 401 for unauthenticated API request', () => {
    const { req, res, next } = mockReqRes();
    requireAuth(req, res, next);
    expect(res.status).toHaveBeenCalledWith(401);
    expect(next).not.toHaveBeenCalled();
  });

  it('redirects to /auth/login for unauthenticated non-API request', () => {
    const { req, res, next } = mockReqRes({
      originalUrl: '/ui',
      headers: { accept: 'text/html' },
    } as Partial<Request>);
    requireAuth(req, res, next);
    expect(res.redirect).toHaveBeenCalledWith('/auth/login');
  });

  it('redirects a top-level navigation to /auth/login?returnTo=<url> (even under /api/)', () => {
    const { req, res, next } = mockReqRes({
      method: 'GET',
      originalUrl: '/api/local/tasks/1/files/raw?trusted=1&path=report.html',
      headers: { 'sec-fetch-mode': 'navigate', accept: 'text/html' },
    } as Partial<Request>);
    requireAuth(req, res, next);
    expect(res.redirect).toHaveBeenCalledWith(
      '/auth/login?returnTo=' +
        encodeURIComponent('/api/local/tasks/1/files/raw?trusted=1&path=report.html'),
    );
    expect(next).not.toHaveBeenCalled();
  });

  it('returns 401 JSON for an unauthenticated SPA fetch (sec-fetch-mode=cors), not a redirect', () => {
    const { req, res, next } = mockReqRes({
      method: 'GET',
      originalUrl: '/api/local/tasks',
      headers: { 'sec-fetch-mode': 'cors', accept: 'application/json' },
    } as Partial<Request>);
    requireAuth(req, res, next);
    expect(res.status).toHaveBeenCalledWith(401);
    expect(res.redirect).not.toHaveBeenCalled();
  });

  it('returns 401 JSON for an unauthenticated POST even with an HTML Accept header', () => {
    const { req, res, next } = mockReqRes({
      method: 'POST',
      originalUrl: '/api/local/tasks',
      headers: { accept: 'text/html' },
    } as Partial<Request>);
    requireAuth(req, res, next);
    expect(res.status).toHaveBeenCalledWith(401);
    expect(res.redirect).not.toHaveBeenCalled();
  });
});

describe('safeReturnTo', () => {
  it('accepts a same-origin absolute path', () => {
    expect(safeReturnTo('/spaces/abc')).toBe('/spaces/abc');
    expect(safeReturnTo('/api/local/tasks/1/files/raw?trusted=1&path=a.html')).toBe(
      '/api/local/tasks/1/files/raw?trusted=1&path=a.html',
    );
    expect(safeReturnTo('/')).toBe('/');
  });

  it('rejects open-redirect and non-path inputs', () => {
    expect(safeReturnTo('//evil.com')).toBeNull(); // protocol-relative
    expect(safeReturnTo('/\\evil.com')).toBeNull(); // backslash smuggling
    expect(safeReturnTo('https://evil.com')).toBeNull(); // absolute URL
    expect(safeReturnTo('http://evil.com')).toBeNull();
    expect(safeReturnTo('javascript:alert(1)')).toBeNull();
    expect(safeReturnTo('spaces/abc')).toBeNull(); // not rooted
    expect(safeReturnTo('/foo\nbar')).toBeNull(); // control char
    expect(safeReturnTo('')).toBeNull();
    expect(safeReturnTo(undefined)).toBeNull();
    expect(safeReturnTo(123 as unknown)).toBeNull();
    expect(safeReturnTo('/' + 'a'.repeat(3000))).toBeNull(); // too long
  });
});

describe('consumeReturnTo', () => {
  it('returns the saved value and clears it (single use)', () => {
    const session = { returnTo: '/spaces/once' } as { returnTo?: string };
    const req = { session } as unknown as Request;
    expect(consumeReturnTo(req)).toBe('/spaces/once');
    expect(session.returnTo).toBeUndefined();
    expect(consumeReturnTo(req)).toBeNull(); // already consumed
  });

  it('re-validates the stored value and rejects unsafe paths', () => {
    const req = { session: { returnTo: '//evil.com' } } as unknown as Request;
    expect(consumeReturnTo(req)).toBeNull();
  });

  it('returns null when there is no session', () => {
    expect(consumeReturnTo({} as unknown as Request)).toBeNull();
  });
});

describe('isTopLevelNavigation', () => {
  const mk = (over: Partial<Request>) =>
    ({ method: 'GET', headers: {}, ...over } as unknown as Request);

  it('treats GET with Sec-Fetch-Mode: navigate as a navigation', () => {
    expect(isTopLevelNavigation(mk({ headers: { 'sec-fetch-mode': 'navigate' } }))).toBe(true);
  });

  it('falls back to Accept: text/html when Sec-Fetch-Mode is absent', () => {
    expect(isTopLevelNavigation(mk({ headers: { accept: 'text/html,*/*' } }))).toBe(true);
  });

  it('does not treat a CORS fetch as a navigation', () => {
    expect(
      isTopLevelNavigation(mk({ headers: { 'sec-fetch-mode': 'cors', accept: 'text/html' } })),
    ).toBe(false);
  });

  it('does not treat non-GET as a navigation', () => {
    expect(isTopLevelNavigation(mk({ method: 'POST', headers: { accept: 'text/html' } }))).toBe(
      false,
    );
  });
});

describe('requireAdmin', () => {
  it('calls next() for admin user', () => {
    const { req, res, next } = mockReqRes({
      isAuthenticated: () => true,
      user: { id: '1', role: 'admin', status: 'active' },
    } as Partial<Request>);
    requireAdmin(req, res, next);
    expect(next).toHaveBeenCalled();
  });

  it('returns 403 for non-admin user', () => {
    const { req, res, next } = mockReqRes({
      isAuthenticated: () => true,
      user: { id: '1', role: 'user', status: 'active' },
    } as Partial<Request>);
    requireAdmin(req, res, next);
    expect(res.status).toHaveBeenCalledWith(403);
  });

  it('returns 401 for unauthenticated request', () => {
    const { req, res, next } = mockReqRes();
    requireAdmin(req, res, next);
    expect(res.status).toHaveBeenCalledWith(401);
  });
});

describe('fetchGiteaOrgsForUser', () => {
  let tempDir = '';
  afterEach(() => {
    if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); tempDir = ''; }
    vi.restoreAllMocks();
  });
  function makeRepo(): Repository {
    tempDir = mkdtempSync(join(tmpdir(), 'auth-test-'));
    return new Repository(join(tempDir, 'db.sqlite'));
  }

  it('fetches and persists user orgs from Gitea API', async () => {
    const repo = makeRepo();
    try {
      const user = repo.createUser({
        email: 'a@gitea.local', name: 'alice', role: 'user', status: 'active',
      });
      vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
        ok: true,
        json: async () => [
          { id: 10, username: 'marketing' },
          { id: 20, username: 'platform' },
        ],
      } as Response);

      const orgIds = await fetchGiteaOrgsForUser(repo, user.id, 'http://gitea.local', 'token-xyz');

      expect(orgIds.sort()).toEqual(['10', '20']);
      expect(repo.listUserGiteaOrgs(user.id).map(o => o.orgName).sort())
        .toEqual(['marketing', 'platform']);
    } finally {
      repo.close();
    }
  });

  it('returns empty array on !res.ok', async () => {
    const repo = makeRepo();
    try {
      const user = repo.createUser({
        email: 'a@gitea.local', name: 'alice', role: 'user', status: 'active',
      });
      vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
        ok: false, status: 401,
      } as Response);

      const orgIds = await fetchGiteaOrgsForUser(repo, user.id, 'http://gitea.local', 'bad-token');

      expect(orgIds).toEqual([]);
      expect(repo.listUserGiteaOrgs(user.id)).toEqual([]);
    } finally {
      repo.close();
    }
  });

  it('returns empty array on fetch rejection (network error)', async () => {
    const repo = makeRepo();
    try {
      const user = repo.createUser({
        email: 'a@gitea.local', name: 'alice', role: 'user', status: 'active',
      });
      vi.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new Error('ECONNREFUSED'));

      const orgIds = await fetchGiteaOrgsForUser(repo, user.id, 'http://gitea.local', 'token');

      expect(orgIds).toEqual([]);
      expect(repo.listUserGiteaOrgs(user.id)).toEqual([]);
    } finally {
      repo.close();
    }
  });

  it('clears stale cached orgs when fetch fails (prevents permission lag after org removal)', async () => {
    const repo = makeRepo();
    try {
      const user = repo.createUser({
        email: 'a@gitea.local', name: 'alice', role: 'user', status: 'active',
      });
      repo.replaceUserGiteaOrgs(user.id, [
        { orgId: '10', orgName: 'marketing' },
        { orgId: '20', orgName: 'platform' },
      ]);
      expect(repo.listUserGiteaOrgs(user.id)).toHaveLength(2);

      vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ ok: false, status: 503 } as Response);
      await fetchGiteaOrgsForUser(repo, user.id, 'http://gitea.local', 'token');
      expect(repo.listUserGiteaOrgs(user.id)).toEqual([]);

      repo.replaceUserGiteaOrgs(user.id, [{ orgId: '10', orgName: 'marketing' }]);
      vi.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new Error('ECONNREFUSED'));
      await fetchGiteaOrgsForUser(repo, user.id, 'http://gitea.local', 'token');
      expect(repo.listUserGiteaOrgs(user.id)).toEqual([]);
    } finally {
      repo.close();
    }
  });
});

// Audit 2026-06-09: provider completeness + primaryProvider gating. These pin
// the security-critical fail-closed / restriction semantics behind the new
// AuthForm (config editable from Settings UI).
const COMPLETE = { clientId: 'id', clientSecret: 'sec', callbackUrl: 'https://x/cb' };
const COMPLETE_GITEA = { ...COMPLETE, baseUrl: 'https://gitea' };
function authCfg(
  providers: Partial<AuthConfig['providers']>,
  primaryProvider?: 'google' | 'gitea',
): AuthConfig {
  return {
    sessionSecret: 's', sessionMaxAge: 1, secureCookie: false, adminEmails: [],
    primaryProvider, providers,
  } as AuthConfig;
}

describe('isProviderConfigured', () => {
  it('true only when all required fields are present', () => {
    expect(isProviderConfigured(COMPLETE, 'google')).toBe(true);
    expect(isProviderConfigured(COMPLETE_GITEA, 'gitea')).toBe(true);
  });
  it('gitea requires baseUrl', () => {
    expect(isProviderConfigured(COMPLETE, 'gitea')).toBe(false);
  });
  it('false when any field is missing or provider undefined', () => {
    expect(isProviderConfigured({ ...COMPLETE, clientSecret: '' }, 'google')).toBe(false);
    expect(isProviderConfigured({ ...COMPLETE, callbackUrl: undefined }, 'google')).toBe(false);
    expect(isProviderConfigured(undefined, 'google')).toBe(false);
  });
});

describe('isProviderActive (primaryProvider restriction)', () => {
  it('both active when both complete and no primary', () => {
    const c = authCfg({ google: COMPLETE, gitea: COMPLETE_GITEA });
    expect(isProviderActive(c, 'google')).toBe(true);
    expect(isProviderActive(c, 'gitea')).toBe(true);
  });
  it('a valid primary restricts to that provider only', () => {
    const c = authCfg({ google: COMPLETE, gitea: COMPLETE_GITEA }, 'google');
    expect(isProviderActive(c, 'google')).toBe(true);
    expect(isProviderActive(c, 'gitea')).toBe(false);
  });
  it('an invalid primary (unconfigured provider) is ignored', () => {
    const c = authCfg({ gitea: COMPLETE_GITEA }, 'google');
    expect(isProviderActive(c, 'gitea')).toBe(true); // gitea still usable
    expect(isProviderActive(c, 'google')).toBe(false); // google not configured
  });
  it('does not throw when providers is entirely absent (local-only config)', () => {
    // Saving "local only" from the Settings UI can persist an auth block with
    // no providers key at all. The OAuth helpers + strategy registration must
    // treat that as "no OAuth provider configured", not crash.
    const c = {
      sessionSecret: 's', sessionMaxAge: 1, secureCookie: false, adminEmails: [],
    } as AuthConfig;
    expect(() => isProviderActive(c, 'google')).not.toThrow();
    expect(isProviderActive(c, 'google')).toBe(false);
    expect(isProviderActive(c, 'gitea')).toBe(false);
  });
});
