import { describe, it, expect } from 'vitest';
import {
  generate,
  buildBackendPatches,
  buildAuthLiteral,
  injectProviderAuth,
  buildEnvAppend,
} from '../generate.js';
import { ConfigureLoginInputSchema, type ConfigureLoginInput } from '../types.js';
import { deepMerge } from '../../../../lib/json-merge.js';

/** Parse through the schema so defaults (secretsMode, frontendAuth, …) apply. */
function spec(overrides: Record<string, unknown> = {}): ConfigureLoginInput {
  return ConfigureLoginInputSchema.parse({
    projectPath: '/proj',
    appCode: 'demo',
    connectionTypes: { local: true },
    allowRegistration: true,
    ...overrides,
  });
}

const LAYOUT = { apiDir: 'src/Demo.Api', webDir: 'web/demo-web' };

// A realistic slice of the generated appsettings.json template — the merge must
// preserve every one of these keys.
const EXISTING_APPSETTINGS = {
  Jwt: { Issuer: 'SmartStack', Audience: 'SmartStack', AccessTokenExpirationMinutes: 60 },
  Authentication: {
    Microsoft: { ClientId: '', ClientSecret: '', CallbackPath: '/api/auth/microsoft/callback' },
    Google: { ClientId: '', ClientSecret: '', CallbackPath: '/api/auth/google/callback' },
    EntraSso: { Enabled: false, ClientId: '', AllowedIssuers: [], ClockSkewSeconds: 60 },
  },
  Session: { IdleTimeoutMinutes: 20, MaxConcurrentSessions: 1 },
  Email: { Provider: 'Development', FromName: 'SmartStack', TokenExpiration: { PasswordReset: '01:00:00' } },
  Security: { LockoutWindowMinutes: 15, MaxFailedAttempts: 5, InitialAdmin: { Email: '', Password: '' } },
  Localization: { DefaultLanguage: 'fr' },
};

// The canonical main.tsx the init generator emits (provider block verbatim).
const CANONICAL_MAIN = `import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { SmartStackProvider } from '@atlashub/smartstack';
import App from './App';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <BrowserRouter>
      <SmartStackProvider
        config={{
          apiUrl: import.meta.env.VITE_API_URL || 'http://localhost:5142',
          extensions: {}
        }}
      >
        <App />
      </SmartStackProvider>
    </BrowserRouter>
  </StrictMode>,
);
`;

describe('buildBackendPatches — deep-merge preservation', () => {
  it('preserves every unrelated key and sub-key when merged', () => {
    const { committed } = buildBackendPatches(
      spec({
        connectionTypes: {
          local: true,
          microsoft: { clientId: 'ms-id', clientSecret: 'ms-secret' },
          entra: { clientId: 'entra-id', allowedTenantIds: ['tenant-a'] },
        },
        allowRegistration: false,
        email: { provider: 'Smtp', fromEmail: 'no-reply@x.io', fromName: 'X', smtp: { host: 'mail.x.io' } },
      }),
    );
    const merged = deepMerge(EXISTING_APPSETTINGS, committed) as any;

    // Untouched sections survive.
    expect(merged.Jwt.AccessTokenExpirationMinutes).toBe(60);
    expect(merged.Session.IdleTimeoutMinutes).toBe(20);
    expect(merged.Localization.DefaultLanguage).toBe('fr');
    // Untouched sub-keys of touched sections survive.
    expect(merged.Authentication.Microsoft.CallbackPath).toBe('/api/auth/microsoft/callback');
    expect(merged.Authentication.Google.CallbackPath).toBe('/api/auth/google/callback');
    expect(merged.Security.LockoutWindowMinutes).toBe(15);
    expect(merged.Email.TokenExpiration.PasswordReset).toBe('01:00:00');
    expect(merged.Authentication.EntraSso.ClockSkewSeconds).toBe(60);
    // New values applied.
    expect(merged.Authentication.Microsoft.ClientId).toBe('ms-id');
    expect(merged.Authentication.EntraSso.ClientId).toBe('entra-id');
    expect(merged.Authentication.Microsoft.AllowedTenants).toEqual(['tenant-a']);
    expect(merged.Email.Provider).toBe('Smtp');
    expect(merged.Email.Smtp.Host).toBe('mail.x.io');
  });

  it('routes the Entra tenant whitelist to Authentication:Microsoft:AllowedTenants (replaced wholesale)', () => {
    const { committed } = buildBackendPatches(
      spec({ connectionTypes: { local: true, entra: { clientId: 'e', allowedTenantIds: ['only-this'] } } }),
    );
    const merged = deepMerge(EXISTING_APPSETTINGS, committed) as any;
    expect(merged.Authentication.Microsoft.AllowedTenants).toEqual(['only-this']);
    expect(merged.Authentication.EntraSso.AllowedTenantIds).toBeUndefined();
  });
});

describe('buildBackendPatches — provider toggles', () => {
  it('includes Microsoft only when configured', () => {
    const on = buildBackendPatches(spec({ connectionTypes: { local: true, microsoft: { clientId: 'm', clientSecret: 's' } } }));
    expect((on.committed as any).Authentication.Microsoft.ClientId).toBe('m');
    const off = buildBackendPatches(spec({ connectionTypes: { local: true } }));
    expect((off.committed as any).Authentication).toBeUndefined();
  });

  it('includes Google only when configured', () => {
    const on = buildBackendPatches(spec({ connectionTypes: { local: true, google: { clientId: 'g', clientSecret: 's' } } }));
    expect((on.committed as any).Authentication.Google.ClientId).toBe('g');
  });

  it('includes EntraSso nested under Authentication only when configured, enabled default true', () => {
    const on = buildBackendPatches(spec({ connectionTypes: { local: true, entra: { clientId: 'e' } } }));
    expect((on.committed as any).Authentication.EntraSso).toEqual({ Enabled: true, ClientId: 'e' });
    const off = buildBackendPatches(spec({ connectionTypes: { local: true } }));
    expect((off.committed as any).Authentication).toBeUndefined();
  });

  it('local-only with no admin/email carries NO sections at all', () => {
    const { committed, local } = buildBackendPatches(spec({ connectionTypes: { local: true } }));
    expect((committed as any).Authentication).toBeUndefined();
    expect((committed as any).Security).toBeUndefined();
    expect((committed as any).Email).toBeUndefined();
    expect(local).toBeNull();
  });
});

describe('connection-types refinement', () => {
  it('rejects a spec with no connection type enabled', () => {
    const res = ConfigureLoginInputSchema.safeParse({
      projectPath: '/p',
      appCode: 'a',
      connectionTypes: { local: false },
      allowRegistration: true,
    });
    expect(res.success).toBe(false);
  });
});

describe('self-registration is never written to the backend', () => {
  it('never emits Security:EnableSelfRegistration (the backend has no such flag)', () => {
    for (const allow of [true, false]) {
      const { committed } = buildBackendPatches(spec({ allowRegistration: allow }));
      expect(JSON.stringify(committed)).not.toContain('EnableSelfRegistration');
    }
  });

  it('still reflects allowRegistration in the (gated) frontend auth literal', () => {
    expect(buildAuthLiteral(spec({ allowRegistration: false }))).toContain('allowRegistration: false');
    expect(buildAuthLiteral(spec({ allowRegistration: true }))).toContain('allowRegistration: true');
  });
});

describe('branding + initialAdmin', () => {
  it('emits branding into the auth literal when provided, omits when absent', () => {
    const withBrand = buildAuthLiteral(spec({ branding: { appName: 'Acme Portal' } }));
    expect(withBrand).toContain('branding: { appName: "Acme Portal" }');
    expect(buildAuthLiteral(spec())).not.toContain('branding:');
  });

  it('emits no Security section when no initial admin is supplied', () => {
    const none = buildBackendPatches(spec());
    expect((none.committed as any).Security).toBeUndefined();
  });

  it('emits InitialAdmin (email committed, password local) when supplied', () => {
    const withAdmin = buildBackendPatches(spec({ initialAdmin: { email: 'a@x.io', password: 'pw' } }));
    expect((withAdmin.committed as any).Security.InitialAdmin.Email).toBe('a@x.io');
    expect((withAdmin.committed as any).Security.InitialAdmin.Password).toBeUndefined();
    expect((withAdmin.local as any).Security.InitialAdmin.Password).toBe('pw');
  });

  it('carries RequirePasswordChange only when set', () => {
    const on = buildBackendPatches(spec({ initialAdmin: { email: 'a@x.io', password: 'pw', requirePasswordChange: true } }));
    expect((on.committed as any).Security.InitialAdmin.RequirePasswordChange).toBe(true);
    const off = buildBackendPatches(spec({ initialAdmin: { email: 'a@x.io', password: 'pw' } }));
    expect((off.committed as any).Security.InitialAdmin.RequirePasswordChange).toBeUndefined();
  });
});

describe('email — provider settings + secret partition', () => {
  it('Development: sender only, no secret, no Local', () => {
    const { committed, local } = buildBackendPatches(
      spec({ email: { provider: 'Development', fromEmail: 'f@x.io', fromName: 'F' } }),
    );
    expect((committed as any).Email).toEqual({ Enabled: true, Provider: 'Development', FromEmail: 'f@x.io', FromName: 'F' });
    expect(local).toBeNull();
  });

  it('Smtp: public fields committed, password partitioned to Local', () => {
    const { committed, local } = buildBackendPatches(
      spec({
        email: {
          provider: 'Smtp',
          fromEmail: 'f@x.io',
          fromName: 'F',
          smtp: { host: 'smtp.x.io', port: 587, username: 'u@x.io', password: 'SMTP-PW', useSsl: true },
        },
      }),
    );
    expect((committed as any).Email.Smtp).toEqual({ Host: 'smtp.x.io', Port: 587, Username: 'u@x.io', UseSsl: true });
    expect(JSON.stringify(committed)).not.toContain('SMTP-PW');
    expect((local as any).Email.Smtp.Password).toBe('SMTP-PW');
  });

  it('SendGrid: api key only in Local, nothing secret committed', () => {
    const { committed, local } = buildBackendPatches(
      spec({ email: { provider: 'SendGrid', fromEmail: 'f@x.io', fromName: 'F', sendGrid: { apiKey: 'SG-KEY' } } }),
    );
    expect((committed as any).Email.Provider).toBe('SendGrid');
    expect(JSON.stringify(committed)).not.toContain('SG-KEY');
    expect((local as any).Email.SendGrid.ApiKey).toBe('SG-KEY');
  });

  it('AzureAcs: senderAddress committed, connection string in Local', () => {
    const { committed, local } = buildBackendPatches(
      spec({
        email: {
          provider: 'AzureAcs',
          fromEmail: 'f@x.io',
          fromName: 'F',
          azureAcs: { connectionString: 'endpoint=...;accesskey=SECRET', senderAddress: 'no-reply@x.io' },
        },
      }),
    );
    expect((committed as any).Email.AzureAcs).toEqual({ SenderAddress: 'no-reply@x.io' });
    expect(JSON.stringify(committed)).not.toContain('SECRET');
    expect((local as any).Email.AzureAcs.ConnectionString).toBe('endpoint=...;accesskey=SECRET');
  });

  it('placeholders mode: SMTP password is "" committed, no Local', () => {
    const { committed, local } = buildBackendPatches(
      spec({
        secretsMode: 'placeholders',
        email: { provider: 'Smtp', fromEmail: 'f@x.io', fromName: 'F', smtp: { host: 'smtp.x.io', password: 'SMTP-PW' } },
      }),
    );
    expect(JSON.stringify(committed)).not.toContain('SMTP-PW');
    expect((committed as any).Email.Smtp.Password).toBe('');
    expect(local).toBeNull();
  });

  it('rejects a provider whose settings block is missing', () => {
    expect(ConfigureLoginInputSchema.safeParse({
      projectPath: '/p', appCode: 'a', connectionTypes: { local: true }, allowRegistration: true,
      email: { provider: 'SendGrid', fromEmail: 'f@x.io', fromName: 'F' },
    }).success).toBe(false);
  });
});

describe('secrets safety — local-file vs placeholders', () => {
  it('local-file: committed has NO secrets, local carries them', () => {
    const { committed, local } = buildBackendPatches(
      spec({
        secretsMode: 'local-file',
        connectionTypes: { local: true, microsoft: { clientId: 'm', clientSecret: 'TOP-SECRET' } },
        initialAdmin: { email: 'a@x.io', password: 'ADMIN-PW' },
      }),
    );
    const committedStr = JSON.stringify(committed);
    expect(committedStr).not.toContain('TOP-SECRET');
    expect(committedStr).not.toContain('ADMIN-PW');
    expect((committed as any).Authentication.Microsoft.ClientSecret).toBeUndefined();
    expect((committed as any).Security.InitialAdmin.Password).toBeUndefined();
    expect((local as any).Authentication.Microsoft.ClientSecret).toBe('TOP-SECRET');
    expect((local as any).Security.InitialAdmin.Password).toBe('ADMIN-PW');
  });

  it('placeholders: secrets are "" in committed, local is null', () => {
    const { committed, local } = buildBackendPatches(
      spec({
        secretsMode: 'placeholders',
        connectionTypes: { local: true, microsoft: { clientId: 'm', clientSecret: 'TOP-SECRET' } },
        initialAdmin: { email: 'a@x.io', password: 'ADMIN-PW' },
      }),
    );
    expect(JSON.stringify(committed)).not.toContain('TOP-SECRET');
    expect(JSON.stringify(committed)).not.toContain('ADMIN-PW');
    expect((committed as any).Authentication.Microsoft.ClientSecret).toBe('');
    expect((committed as any).Security.InitialAdmin.Password).toBe('');
    expect(local).toBeNull();
  });
});

describe('appsettings idempotency', () => {
  it('merging the patch twice equals merging once', () => {
    const { committed } = buildBackendPatches(
      spec({ connectionTypes: { local: true, entra: { clientId: 'e', allowedTenantIds: ['t'] } }, allowRegistration: false }),
    );
    const once = deepMerge(EXISTING_APPSETTINGS, committed);
    const twice = deepMerge(once, committed);
    expect(twice).toEqual(once);
  });
});

describe('generate — emitted files', () => {
  it('local-file with a secret emits committed + Local, both deep-merge-json', () => {
    const files = generate(spec({ connectionTypes: { local: true, microsoft: { clientId: 'm', clientSecret: 's' } } }), LAYOUT);
    expect(files.map((f) => f.path)).toEqual(['src/Demo.Api/appsettings.json', 'src/Demo.Api/appsettings.Local.json']);
    expect(files.every((f) => f.strategy === 'deep-merge-json')).toBe(true);
  });

  it('local-only emits a single committed file (no Local)', () => {
    const files = generate(spec({ connectionTypes: { local: true } }), LAYOUT);
    expect(files.map((f) => f.path)).toEqual(['src/Demo.Api/appsettings.json']);
  });

  it('placeholders emits a single committed file even with providers', () => {
    const files = generate(spec({ secretsMode: 'placeholders', connectionTypes: { local: true, google: { clientId: 'g', clientSecret: 's' } } }), LAYOUT);
    expect(files.map((f) => f.path)).toEqual(['src/Demo.Api/appsettings.json']);
  });
});

describe('injectProviderAuth — main.tsx', () => {
  it('injects auth inside config={{ and keeps apiUrl + extensions', () => {
    const { content, status } = injectProviderAuth(CANONICAL_MAIN, spec({ connectionTypes: { local: true, entra: { clientId: 'e-id' } } }));
    expect(status).toBe('injected');
    expect(content).toContain('auth: { entra: { clientId: "e-id" }');
    expect(content).toContain('apiUrl: import.meta.env.VITE_API_URL');
    expect(content).toContain('extensions: {}');
  });

  it('is idempotent — re-run updates in place, never duplicates the marker', () => {
    const s = spec({ connectionTypes: { local: true, entra: { clientId: 'e-id' } } });
    const first = injectProviderAuth(CANONICAL_MAIN, s).content;
    const second = injectProviderAuth(first, s);
    expect(second.status).toBe('updated');
    expect(second.content).toBe(first);
    expect((second.content.match(/@login-config:auth/g) ?? []).length).toBe(1);
  });

  it('refreshes the block when the spec changes', () => {
    const a = injectProviderAuth(CANONICAL_MAIN, spec({ allowRegistration: true })).content;
    const b = injectProviderAuth(a, spec({ allowRegistration: false }));
    expect(b.content).toContain('allowRegistration: false');
    expect(b.content).not.toContain('allowRegistration: true');
  });

  it('returns not-found (unchanged) when there is no provider config', () => {
    const src = 'export const x = 1;\n';
    const { content, status } = injectProviderAuth(src, spec());
    expect(status).toBe('not-found');
    expect(content).toBe(src);
  });

  it('respects the @customised marker', () => {
    const src = '// @customised\n' + CANONICAL_MAIN;
    const { content, status } = injectProviderAuth(src, spec());
    expect(status).toBe('skipped-customised');
    expect(content).toBe(src);
  });
});

describe('buildEnvAppend — MSAL placeholders', () => {
  it('appends the three keys for Entra and is append-only', () => {
    const { content, added } = buildEnvAppend('VITE_API_URL=\n', spec({ connectionTypes: { local: true, entra: { clientId: 'e' } } }));
    expect(added).toEqual(['VITE_MSAL_CLIENT_ID', 'VITE_MSAL_AUTHORITY', 'VITE_MSAL_REDIRECT_URI']);
    expect(content).toContain('VITE_API_URL=');
    expect(content).toContain('# VITE_MSAL_CLIENT_ID=');
    // idempotent
    const again = buildEnvAppend(content, spec({ connectionTypes: { local: true, entra: { clientId: 'e' } } }));
    expect(again.added).toEqual([]);
    expect(again.content).toBe(content);
  });

  it('does nothing without Entra', () => {
    const { content, added } = buildEnvAppend('VITE_API_URL=\n', spec());
    expect(added).toEqual([]);
    expect(content).toBe('VITE_API_URL=\n');
  });
});
