import { beforeEach, describe, expect, it, vi } from 'vitest';
import { NOOP_SYNC_LOGGER, type SyncLogger } from '@sp/sync-core';
import { WebDavHttpAdapter } from '../../../src/file-based/webdav/webdav-http-adapter';
import {
  AuthFailSPError,
  HttpNotOkAPIError,
  PotentialCorsError,
  RemoteFileNotFoundAPIError,
  TooManyRequestsAPIError,
  WebDavNativeRequestError,
} from '../../../src/errors';
import type { NativeHttpExecutor } from '../../../src/http';
import type { ProviderPlatformInfo, WebFetchFactory } from '../../../src/platform';

const makeDeps = (overrides: {
  isNativePlatform?: boolean;
  fetchImpl?: typeof fetch;
  nativeHttp?: NativeHttpExecutor;
  logger?: SyncLogger;
}): {
  platformInfo: ProviderPlatformInfo;
  webFetch: WebFetchFactory;
  nativeHttp: NativeHttpExecutor;
  logger: SyncLogger;
} => ({
  platformInfo: {
    isNativePlatform: overrides.isNativePlatform ?? false,
    isAndroidWebView: false,
    isIosNative: false,
  },
  webFetch: () => overrides.fetchImpl ?? (globalThis.fetch as typeof fetch),
  nativeHttp: overrides.nativeHttp ?? vi.fn(),
  logger: overrides.logger ?? NOOP_SYNC_LOGGER,
});

const okFetchResponse = (status = 200, body = 'ok'): Response =>
  new Response(body, { status });

describe('WebDavHttpAdapter', () => {
  describe('routing', () => {
    it('uses native executor when isNativePlatform', async () => {
      const nativeHttp = vi.fn().mockResolvedValue({
        status: 200,
        headers: { etag: 'x' },
        data: 'native-body',
      });
      const adapter = new WebDavHttpAdapter(
        makeDeps({ isNativePlatform: true, nativeHttp }),
      );

      const r = await adapter.request({
        url: 'https://dav.example.com/sync/file',
        method: 'GET',
      });

      expect(r.data).toBe('native-body');
      expect(nativeHttp).toHaveBeenCalledTimes(1);
      expect(nativeHttp).toHaveBeenCalledWith(
        expect.objectContaining({
          url: 'https://dav.example.com/sync/file',
          method: 'GET',
          responseType: 'text',
        }),
      );
    });

    it('sends no-cache request headers on the native path (#7144)', async () => {
      // Regression guard: iOS URLSession / upstream proxies otherwise serve a
      // stale sync-data.json, which hides remote changes and defeats the
      // content-hash conflict check, causing silent overwrite/data loss.
      const nativeHttp = vi.fn().mockResolvedValue({
        status: 200,
        headers: {},
        data: 'native-body',
      });
      const adapter = new WebDavHttpAdapter(
        makeDeps({ isNativePlatform: true, nativeHttp }),
      );

      await adapter.request({
        url: 'https://dav.example.com/sync/file',
        method: 'GET',
        headers: { Authorization: 'Basic abc' },
      });

      const sentHeaders = (
        nativeHttp.mock.calls[0][0] as { headers: Record<string, string> }
      ).headers;
      expect(sentHeaders['Cache-Control']).toBe('no-cache, no-store');
      expect(sentHeaders['Pragma']).toBe('no-cache');
      // Caller headers are preserved.
      expect(sentHeaders['Authorization']).toBe('Basic abc');
    });

    it('uses fetch when not native', async () => {
      const fetchImpl = vi.fn().mockResolvedValue(okFetchResponse(200, 'web-body'));
      const adapter = new WebDavHttpAdapter(
        makeDeps({
          isNativePlatform: false,
          fetchImpl: fetchImpl as unknown as typeof fetch,
        }),
      );

      const r = await adapter.request({
        url: 'https://dav.example.com/sync/file',
        method: 'PROPFIND',
        body: '<propfind/>',
      });

      expect(r.data).toBe('web-body');
      expect(fetchImpl).toHaveBeenCalledTimes(1);
      expect(fetchImpl).toHaveBeenCalledWith(
        'https://dav.example.com/sync/file',
        expect.objectContaining({ method: 'PROPFIND', cache: 'no-store' }),
      );
    });
  });

  describe('status mapping', () => {
    it('throws AuthFailSPError on 401', async () => {
      const fetchImpl = vi.fn().mockResolvedValue(okFetchResponse(401, ''));
      const adapter = new WebDavHttpAdapter(
        makeDeps({ fetchImpl: fetchImpl as unknown as typeof fetch }),
      );
      const promise = adapter.request({
        url: 'https://dav.example.com/x',
        method: 'GET',
      });
      await expect(promise).rejects.toBeInstanceOf(AuthFailSPError);
      await expect(promise).rejects.toMatchObject({
        message: 'Authentication failed (HTTP 401)',
      });
    });

    it('throws RemoteFileNotFoundAPIError on 404', async () => {
      const fetchImpl = vi.fn().mockResolvedValue(okFetchResponse(404, ''));
      const adapter = new WebDavHttpAdapter(
        makeDeps({ fetchImpl: fetchImpl as unknown as typeof fetch }),
      );
      await expect(
        adapter.request({ url: 'https://dav.example.com/x', method: 'GET' }),
      ).rejects.toBeInstanceOf(RemoteFileNotFoundAPIError);
    });

    it('throws TooManyRequestsAPIError on 429', async () => {
      const fetchImpl = vi.fn().mockResolvedValue(okFetchResponse(429, ''));
      const adapter = new WebDavHttpAdapter(
        makeDeps({ fetchImpl: fetchImpl as unknown as typeof fetch }),
      );
      await expect(
        adapter.request({ url: 'https://dav.example.com/x', method: 'GET' }),
      ).rejects.toBeInstanceOf(TooManyRequestsAPIError);
    });

    it('throws HttpNotOkAPIError on generic 5xx', async () => {
      const fetchImpl = vi.fn().mockResolvedValue(okFetchResponse(500, 'boom'));
      const adapter = new WebDavHttpAdapter(
        makeDeps({ fetchImpl: fetchImpl as unknown as typeof fetch }),
      );
      await expect(
        adapter.request({ url: 'https://dav.example.com/x', method: 'GET' }),
      ).rejects.toBeInstanceOf(HttpNotOkAPIError);
    });

    it('lets 304 Not Modified pass through (native path)', async () => {
      // Use the native path: Node's Response constructor rejects 304, but the
      // native executor produces its response shape directly.
      const nativeHttp = vi.fn().mockResolvedValue({
        status: 304,
        headers: {},
        data: '',
      });
      const adapter = new WebDavHttpAdapter(
        makeDeps({ isNativePlatform: true, nativeHttp }),
      );
      const r = await adapter.request({
        url: 'https://dav.example.com/x',
        method: 'GET',
      });
      expect(r.status).toBe(304);
    });
  });

  describe('CORS heuristic', () => {
    it.each([
      ['CORS policy denied', 'Failed to fetch: CORS policy denied'],
      ['cross-origin', 'cross-origin request blocked'],
      ['opaque', 'opaque response not allowed'],
      ['Chrome / Safari generic', 'Failed to fetch'],
      ['Safari load failed', 'Load failed'],
      ['Firefox', 'NetworkError when attempting to fetch resource at https://dav/'],
      ['Capacitor / Cordova', 'Network request failed'],
    ])('treats %s as PotentialCorsError on the fetch path', async (_label, msg) => {
      const fetchImpl = vi.fn().mockRejectedValue(new TypeError(msg));
      const adapter = new WebDavHttpAdapter(
        makeDeps({ fetchImpl: fetchImpl as unknown as typeof fetch }),
      );
      await expect(
        adapter.request({ url: 'https://dav.example.com/x', method: 'GET' }),
      ).rejects.toBeInstanceOf(PotentialCorsError);
    });

    it('passes non-TypeError fetch errors through as HttpNotOkAPIError', async () => {
      // e.g. AbortError, generic Error — not a CORS smell.
      const fetchImpl = vi.fn().mockRejectedValue(new Error('aborted'));
      const adapter = new WebDavHttpAdapter(
        makeDeps({ fetchImpl: fetchImpl as unknown as typeof fetch }),
      );
      await expect(
        adapter.request({ url: 'https://dav.example.com/x', method: 'GET' }),
      ).rejects.toBeInstanceOf(HttpNotOkAPIError);
    });

    it('PotentialCorsError carries only the scrubbed URL, never the raw error', async () => {
      const fetchImpl = vi
        .fn()
        .mockRejectedValue(
          new TypeError(
            'NetworkError when attempting to fetch resource at https://user:pass@dav.example.com/x?token=secret',
          ),
        );
      const adapter = new WebDavHttpAdapter(
        makeDeps({
          fetchImpl: fetchImpl as unknown as typeof fetch,
        }),
      );
      try {
        await adapter.request({
          url: 'https://user:pass@dav.example.com/x?token=secret',
          method: 'GET',
        });
        expect.fail('expected throw');
      } catch (e) {
        expect(e).toBeInstanceOf(PotentialCorsError);
        const msg = (e as Error).message ?? '';
        expect(msg).not.toContain('user:pass');
        expect(msg).not.toContain('secret');
      }
    });
  });

  describe('privacy', () => {
    let loggedMessages: Array<{ msg: string; meta?: unknown }>;
    let logger: SyncLogger;

    beforeEach(() => {
      loggedMessages = [];
      logger = {
        ...NOOP_SYNC_LOGGER,
        normal: (msg, meta) => loggedMessages.push({ msg, meta }),
        critical: (msg, meta) => loggedMessages.push({ msg, meta }),
      };
    });

    it('scrubs WebDAV request log URLs to host only', async () => {
      const fetchImpl = vi.fn().mockResolvedValue(okFetchResponse(200, 'ok'));
      const adapter = new WebDavHttpAdapter(
        makeDeps({ fetchImpl: fetchImpl as unknown as typeof fetch, logger }),
      );

      await adapter.request({
        url: 'https://user:pass@dav.example.com/remote.php/dav/files/alice/sync/file?token=secret',
        method: 'GET',
      });

      const meta = loggedMessages[0]?.meta as { url?: string };
      expect(meta?.url).toBe('dav.example.com');
      expect(JSON.stringify(loggedMessages)).not.toContain('secret');
      expect(JSON.stringify(loggedMessages)).not.toContain('user:pass');
      expect(JSON.stringify(loggedMessages)).not.toContain('alice');
      expect(JSON.stringify(loggedMessages)).not.toContain('sync/file');
    });

    it('logs allowlisted cache headers but never sensitive ones (#7144 diagnostic)', async () => {
      /* eslint-disable @typescript-eslint/naming-convention */
      const nativeHttp = vi.fn().mockResolvedValue({
        status: 200,
        headers: {
          ETag: 'W/"v15"',
          Age: '7',
          'X-Cache': 'HIT',
          'Set-Cookie': 'session=topsecret',
          'WWW-Authenticate': 'Basic realm="x"',
        },
        data: 'body',
      });
      /* eslint-enable @typescript-eslint/naming-convention */
      const adapter = new WebDavHttpAdapter(
        makeDeps({ isNativePlatform: true, nativeHttp, logger }),
      );

      await adapter.request({
        url: 'https://dav.example.com/sync/file',
        method: 'GET',
      });

      const responseLog = loggedMessages.find((l) => l.msg.includes('.response()'));
      expect(responseLog).toBeTruthy();
      const cacheHeaders = (responseLog?.meta as { cacheHeaders?: string }).cacheHeaders;
      // Allowlisted, case-insensitive.
      expect(cacheHeaders).toContain('etag=W/"v15"');
      expect(cacheHeaders).toContain('age=7');
      expect(cacheHeaders).toContain('x-cache=HIT');
      // Sensitive headers never surface in the log.
      expect(JSON.stringify(loggedMessages)).not.toContain('topsecret');
      expect(JSON.stringify(loggedMessages)).not.toContain('set-cookie');
      expect(JSON.stringify(loggedMessages)).not.toContain('Set-Cookie');
    });

    it('logs cacheHeaders=none when the response has no cache headers', async () => {
      const nativeHttp = vi.fn().mockResolvedValue({
        status: 200,
        headers: {},
        data: 'body',
      });
      const adapter = new WebDavHttpAdapter(
        makeDeps({ isNativePlatform: true, nativeHttp, logger }),
      );

      await adapter.request({
        url: 'https://dav.example.com/sync/file',
        method: 'GET',
      });

      const responseLog = loggedMessages.find((l) => l.msg.includes('.response()'));
      expect((responseLog?.meta as { cacheHeaders?: string }).cacheHeaders).toBe('none');
    });

    it('logs structured errorMeta on unexpected error (no raw Error)', async () => {
      const fetchImpl = vi.fn().mockRejectedValue(new Error('boom-url-leak'));
      const adapter = new WebDavHttpAdapter(
        makeDeps({ fetchImpl: fetchImpl as unknown as typeof fetch, logger }),
      );

      await expect(
        adapter.request({
          url: 'https://dav.example.com/sync/file',
          method: 'GET',
        }),
      ).rejects.toBeInstanceOf(HttpNotOkAPIError);

      const errorLog = loggedMessages.find((l) => l.msg.includes('error'));
      expect(errorLog).toBeTruthy();
      // No raw error object in meta — only safe primitives
      expect(JSON.stringify(errorLog?.meta)).not.toContain('boom-url-leak');
      expect(errorLog?.meta).toEqual(
        expect.objectContaining({
          errorName: 'Error',
          url: 'dav.example.com',
        }),
      );
    });

    it('surfaces native network errors with code and readable message', async () => {
      const nativeHttp = vi.fn().mockRejectedValue(
        Object.assign(new Error('Network error: Unable to resolve host'), {
          code: 'NETWORK_ERROR',
        }),
      );
      const adapter = new WebDavHttpAdapter(
        makeDeps({ isNativePlatform: true, nativeHttp, logger }),
      );

      const promise = adapter.request({
        url: 'https://dav.example.com/sync/file',
        method: 'PROPFIND',
      });
      await expect(promise).rejects.toBeInstanceOf(WebDavNativeRequestError);
      await expect(promise).rejects.toMatchObject({
        message: 'Network error: Unable to resolve host',
        code: 'NETWORK_ERROR',
      });

      const errorLog = loggedMessages.find((l) => l.msg.includes('error'));
      expect(errorLog?.meta).toEqual(
        expect.objectContaining({
          errorName: 'Error',
          errorCode: 'NETWORK_ERROR',
          url: 'dav.example.com',
        }),
      );
    });

    it('redacts URL secrets and paths from re-thrown native error messages', async () => {
      const nativeHttp = vi
        .fn()
        .mockRejectedValue(
          Object.assign(
            new Error(
              'Network error: Failed for https://user:pass@dav.example.com/remote.php/dav/files/alice/sp?token=secret#frag',
            ),
            { code: 'NETWORK_ERROR' },
          ),
        );
      const adapter = new WebDavHttpAdapter(
        makeDeps({ isNativePlatform: true, nativeHttp, logger }),
      );

      try {
        await adapter.request({
          url: 'https://dav.example.com/sync/file',
          method: 'PROPFIND',
        });
        expect.fail('expected throw');
      } catch (e) {
        const message = (e as Error).message;
        expect(message).toContain('https://dav.example.com');
        expect(message).not.toContain('user:pass');
        expect(message).not.toContain('token=secret');
        expect(message).not.toContain('/remote.php');
        expect(message).not.toContain('alice');
      }
    });
  });
});
