import { ConvexError, v } from 'convex/values';

import {
  budgetConfigSchema,
  chatFilterConfigSchema,
  customInstructionsConfigSchema,
  dataNoticeConfigSchema,
  defaultModelsConfigSchema,
  featureFlagsConfigSchema,
  loginPolicyConfigSchema,
  modelAccessConfigSchema,
  moderationProviderConfigSchema,
  passwordPolicyConfigSchema,
  piiConfigSchema,
  retentionPolicyConfigSchema,
  sessionIdleTimeoutConfigSchema,
  systemPromptConfigSchema,
  twoFactorPolicyConfigSchema,
  uploadPolicyConfigSchema,
  userMemoriesConfigSchema,
  voiceOutputConfigSchema,
} from '../../lib/shared/schemas/governance';
import { isRecord } from '../../lib/utils/type-guards';
import { internal } from '../_generated/api';
import type { Id } from '../_generated/dataModel';
import { internalMutation, mutation } from '../_generated/server';
import { createAuditLog } from '../audit_logs/helpers';
import { getAuthUserIdentity } from '../lib/rls/auth/get_auth_user_identity';
import { isAdmin } from '../lib/rls/helpers/role_helpers';
import { getOrganizationMember } from '../lib/rls/organization/get_organization_member';
import { GOVERNANCE_POLICY_TYPES } from './schema';

const policyTypeValidator = v.union(
  ...GOVERNANCE_POLICY_TYPES.map((t) => v.literal(t)),
);

// Decides whether to set/update `effectiveAt` on a policy row. Returns:
// - a number to patch (rotation just activated — first enablement wins)
// - `undefined` to leave the existing value untouched
//
// Rotation semantics: every time `rotationDays` transitions from 0 to a
// positive value, stamp `effectiveAt = now` so affected users get a full
// grace window. Unrelated edits (e.g. tweaking minLength while rotation
// stays enabled) preserve the original timestamp so the grace window
// doesn't reset.
function readRotationDays(config: unknown): number {
  if (!isRecord(config)) return 0;
  const value = config.rotationDays;
  return typeof value === 'number' ? value : 0;
}

/**
 * Detect retention-policy shortening between two config snapshots.
 * Returns a human-readable summary of which categories were reduced,
 * or `null` when nothing was shortened (admin extending or unchanged).
 *
 * "Shortening" is per-category — extending category A while shortening
 * B still triggers the cooldown (the dangerous direction is the one
 * that destroys evidence).
 */
function detectRetentionShortening(
  oldConfig: unknown,
  newConfig: unknown,
): string | null {
  if (!isRecord(oldConfig) || !isRecord(newConfig)) return null;
  const checks: Array<[string, string]> = [
    ['documentsRetentionDays', 'documents'],
    ['userTempRetentionHours', 'user temp files'],
    ['agentTempRetentionHours', 'agent temp files'],
    ['chatHistoryRetentionDays', 'chat history'],
    ['auditLogRetentionDays', 'audit log'],
    ['workflowLogRetentionDays', 'workflow logs'],
    ['usageLedgerRetentionDays', 'usage ledger'],
    ['loginAttemptRetentionDays', 'login attempts'],
    ['chatFilterEventsRetentionDays', 'chat filter events'],
    ['promptTemplatesRetentionDays', 'prompt templates'],
    ['messageFeedbackRetentionDays', 'message feedback'],
    ['memoryAuditRetentionDays', 'memory audit'],
    ['customersRetentionDays', 'customers'],
    ['vendorsRetentionDays', 'vendors'],
    ['externalConversationsRetentionDays', 'external conversations'],
    ['messageMetadataRetentionDays', 'message metadata'],
    ['deletionGraceDays', 'deletion grace'],
  ];
  const reduced: string[] = [];
  for (const [key, label] of checks) {
    const oldVal = oldConfig[key];
    const newVal = newConfig[key];
    if (typeof oldVal !== 'number' || typeof newVal !== 'number') continue;
    if (newVal < oldVal) {
      reduced.push(`${label} (${oldVal} → ${newVal})`);
    }
  }
  return reduced.length === 0 ? null : `Reduced: ${reduced.join('; ')}`;
}

function computeNextEffectiveAt(
  policyType: string,
  nextConfig: unknown,
  prevConfig: unknown,
): number | undefined {
  if (policyType !== 'password_policy') return undefined;
  const next = readRotationDays(nextConfig);
  const prev = readRotationDays(prevConfig);
  if (next > 0 && prev === 0) {
    return Date.now();
  }
  return undefined;
}

export const upsertPolicy = mutation({
  args: {
    organizationId: v.string(),
    policyType: policyTypeValidator,
    config: v.any(),
  },
  returns: v.id('governancePolicies'),
  handler: async (ctx, args) => {
    const authUser = await getAuthUserIdentity(ctx);
    if (!authUser) throw new Error('Unauthenticated');

    const member = await getOrganizationMember(
      ctx,
      args.organizationId,
      authUser,
    );
    if (!isAdmin(member.role)) {
      throw new Error('Only admins can modify governance policies');
    }

    if (args.policyType === 'budgets') {
      const parsed = budgetConfigSchema.safeParse(args.config);
      if (!parsed.success) {
        throw new Error(
          `Invalid budget configuration: ${parsed.error.message}`,
        );
      }
    }

    // Round-2 review CRITICAL #24 / E.1.3: every other policyType
    // branched into a Zod safeParse; system_prompt was missing,
    // letting arbitrary JSON persist under this policy type and be
    // read back without validation.
    if (args.policyType === 'system_prompt') {
      const parsed = systemPromptConfigSchema.safeParse(args.config);
      if (!parsed.success) {
        throw new Error(
          `Invalid system_prompt configuration: ${parsed.error.message}`,
        );
      }
    }

    if (args.policyType === 'default_models') {
      const parsed = defaultModelsConfigSchema.safeParse(args.config);
      if (!parsed.success) {
        throw new Error(
          `Invalid default models configuration: ${parsed.error.message}`,
        );
      }
    }

    if (args.policyType === 'upload_policy') {
      const parsed = uploadPolicyConfigSchema.safeParse(args.config);
      if (!parsed.success) {
        throw new Error(
          `Invalid upload policy configuration: ${parsed.error.message}`,
        );
      }
    }

    if (args.policyType === 'pii_config') {
      const parsed = piiConfigSchema.safeParse(args.config);
      if (!parsed.success) {
        throw new Error(`Invalid PII configuration: ${parsed.error.message}`);
      }
    }

    if (args.policyType === 'feature_flags') {
      const parsed = featureFlagsConfigSchema.safeParse(args.config);
      if (!parsed.success) {
        throw new Error(
          `Invalid feature flags configuration: ${parsed.error.message}`,
        );
      }
    }

    if (args.policyType === 'retention_policy') {
      // For retention, the public path is `governance/retention_actions.
      // upsertRetentionPolicyAction` (V8 action). It loads the per-org
      // bounds file via Node action, validates against effective bounds,
      // and then calls `upsertRetentionPolicyInternal` below.
      //
      // Routing retention through this generic mutation would require fs
      // access here (V8 mutation, no Node), so we refuse and direct the
      // caller to the action.
      throw new ConvexError({
        code: 'use_action',
        message:
          'Use governance/retention_actions.upsertRetentionPolicyAction for retention_policy. The per-org bounds file at $TALE_CONFIG_DIR/<orgSlug>/retention.json must be read before validation.',
      });
    }

    if (args.policyType === 'model_access') {
      const parsed = modelAccessConfigSchema.safeParse(args.config);
      if (!parsed.success) {
        throw new Error(
          `Invalid model access configuration: ${parsed.error.message}`,
        );
      }
    }

    if (args.policyType === 'login_policy') {
      const parsed = loginPolicyConfigSchema.safeParse(args.config);
      if (!parsed.success) {
        throw new Error(
          `Invalid login policy configuration: ${parsed.error.message}`,
        );
      }
    }

    if (args.policyType === 'password_policy') {
      const parsed = passwordPolicyConfigSchema.safeParse(args.config);
      if (!parsed.success) {
        throw new Error(
          `Invalid password policy configuration: ${parsed.error.message}`,
        );
      }
    }

    if (args.policyType === 'two_factor_policy') {
      const parsed = twoFactorPolicyConfigSchema.safeParse(args.config);
      if (!parsed.success) {
        throw new Error(
          `Invalid two-factor policy configuration: ${parsed.error.message}`,
        );
      }
    }

    if (args.policyType === 'session_idle_timeout') {
      const parsed = sessionIdleTimeoutConfigSchema.safeParse(args.config);
      if (!parsed.success) {
        throw new Error(
          `Invalid session idle timeout configuration: ${parsed.error.message}`,
        );
      }
      // Persist the normalized output (schema defaults applied) rather than the
      // raw input, so a partial config is stored with `enabled` /
      // `idleTimeoutMinutes` resolved — and the `config.enabled` mirror below
      // sees a concrete boolean.
      args.config = parsed.data;
    }

    if (args.policyType === 'chat_filter') {
      const parsed = chatFilterConfigSchema.safeParse(args.config);
      if (!parsed.success) {
        throw new Error(
          `Invalid chat filter configuration: ${parsed.error.message}`,
        );
      }
    }

    if (args.policyType === 'moderation_provider') {
      const parsed = moderationProviderConfigSchema.safeParse(args.config);
      if (!parsed.success) {
        throw new Error(
          `Invalid moderation provider configuration: ${parsed.error.message}`,
        );
      }
    }

    if (args.policyType === 'custom_instructions') {
      const parsed = customInstructionsConfigSchema.safeParse(args.config);
      if (!parsed.success) {
        throw new Error(
          `Invalid custom_instructions configuration: ${parsed.error.message}`,
        );
      }
    }

    if (args.policyType === 'user_memories') {
      const parsed = userMemoriesConfigSchema.safeParse(args.config);
      if (!parsed.success) {
        throw new Error(
          `Invalid user_memories configuration: ${parsed.error.message}`,
        );
      }
    }

    if (args.policyType === 'voice_output') {
      const parsed = voiceOutputConfigSchema.safeParse(args.config);
      if (!parsed.success) {
        throw new Error(
          `Invalid voice_output configuration: ${parsed.error.message}`,
        );
      }
    }

    if (args.policyType === 'data_classification_notice') {
      const parsed = dataNoticeConfigSchema.safeParse(args.config);
      if (!parsed.success) {
        throw new Error(
          `Invalid data classification notice configuration: ${parsed.error.message}`,
        );
      }
    }

    if (args.policyType === 'dsar_governance') {
      // Routed through `governance/dsar_policy.proposeDsarPolicy`
      // instead. That mutation enforces:
      //   1. owner-only writes (admins can only read), and
      //   2. 24h loosen-grace window for weakening changes
      //      (shorter cooling-off / disabling dual approval / higher
      //      daily limit), with cross-admin notification, so a single
      //      compromised owner cannot both weaken the safeguard and
      //      use it inside the same window.
      // Refusing the generic path keeps that logic single-source.
      throw new ConvexError({
        code: 'use_dsar_policy_mutation',
        message:
          'Use governance/dsar_policy.proposeDsarPolicy for dsar_governance — that path enforces owner-only writes and a 24h loosen-grace window for weakening changes.',
      });
    }

    const existing = await ctx.db
      .query('governancePolicies')
      .withIndex('by_org_policyType', (q) =>
        q
          .eq('organizationId', args.organizationId)
          .eq('policyType', args.policyType),
      )
      .first();

    // For password_policy, track when rotation first became active so we
    // can grant a grace window (credential expiry is computed off the
    // later of account.passwordChangedAt and policy.effectiveAt).
    const nextEffectiveAt = computeNextEffectiveAt(
      args.policyType,
      args.config,
      existing?.config,
    );

    // Mirror `config.enabled` (when present) to the top-level `enabled`
    // column so reads from either side agree. The bespoke mutations this
    // replaced (e.g. `upsertPiiConfig`) took `enabled` as a separate arg
    // and wrote it at the top level; the UI still reads
    // `policy.enabled ?? config.enabled`, so without this mirror the
    // admin toggle silently fails to persist across reloads.
    const configEnabled =
      isRecord(args.config) && typeof args.config.enabled === 'boolean'
        ? args.config.enabled
        : undefined;

    let policyId;

    if (existing) {
      await ctx.db.patch(existing._id, {
        config: args.config,
        updatedAt: Date.now(),
        updatedBy: authUser.userId,
        ...(configEnabled !== undefined ? { enabled: configEnabled } : {}),
        ...(nextEffectiveAt !== undefined
          ? { effectiveAt: nextEffectiveAt }
          : {}),
      });
      policyId = existing._id;
    } else {
      policyId = await ctx.db.insert('governancePolicies', {
        organizationId: args.organizationId,
        policyType: args.policyType,
        enabled: configEnabled ?? true,
        config: args.config,
        updatedAt: Date.now(),
        updatedBy: authUser.userId,
        ...(nextEffectiveAt !== undefined
          ? { effectiveAt: nextEffectiveAt }
          : {}),
      });
    }

    await createAuditLog(ctx, {
      organizationId: args.organizationId,
      actorId: authUser.userId,
      actorEmail: authUser.email,
      actorType: 'user',
      action: existing ? 'policy.updated' : 'policy.created',
      category: 'security',
      resourceType: 'governance_policy',
      resourceId: String(policyId),
      resourceName: `Policy: ${args.policyType}`,
      newState: { policyType: args.policyType, config: args.config },
      previousState: existing
        ? { policyType: existing.policyType, config: existing.config }
        : undefined,
      status: 'success',
    });

    return policyId;
  },
});

/**
 * Cancel a pending retention-shortening before it takes effect.
 * Admin-only. After this the new (shortened) values that were saved
 * onto the policy row are reverted to the snapshot's `oldConfig`.
 */
export const cancelPendingRetentionChange = mutation({
  args: {
    organizationId: v.string(),
    pendingId: v.id('retentionPolicyPendingChanges'),
  },
  returns: v.null(),
  handler: async (ctx, args) => {
    const authUser = await getAuthUserIdentity(ctx);
    if (!authUser) {
      throw new ConvexError({
        code: 'unauthenticated',
        message: 'Sign in required.',
      });
    }
    const callerId = authUser.userId;
    const member = await getOrganizationMember(ctx, args.organizationId, {
      userId: callerId,
      email: authUser.email ?? '',
    });
    if (!isAdmin(member.role)) {
      throw new ConvexError({
        code: 'forbidden',
        message: 'Only admins can cancel pending retention changes.',
      });
    }

    const pending = await ctx.db.get(args.pendingId);
    if (!pending || pending.organizationId !== args.organizationId) {
      throw new ConvexError({
        code: 'not_found',
        message: 'Pending change does not exist.',
      });
    }

    // Revert the policy row to the snapshot's oldConfig.
    const policyRow = await ctx.db
      .query('governancePolicies')
      .withIndex('by_org_policyType', (q) =>
        q
          .eq('organizationId', args.organizationId)
          .eq('policyType', 'retention_policy'),
      )
      .first();
    if (policyRow) {
      await ctx.db.patch(policyRow._id, {
        config: pending.oldConfig,
        updatedAt: Date.now(),
        updatedBy: callerId,
      });
    }
    await ctx.db.delete(args.pendingId);

    await createAuditLog(ctx, {
      organizationId: args.organizationId,
      actorId: callerId,
      actorEmail: authUser.email ?? '',
      actorType: 'user',
      action: 'policy.retention_shortening_cancelled',
      category: 'security',
      resourceType: 'governance_policy',
      resourceId: String(args.pendingId),
      resourceName: 'retention_policy',
      newState: { summary: pending.summary },
      status: 'success',
    });

    return null;
  },
});

/**
 * Internal mutation invoked by `governance/retention_actions.
 * upsertRetentionPolicyAction`. Owns: Zod schema validation of the
 * retention config, 7-day cooldown insertion when values shrink,
 * policy-row persistence, and audit emission.
 *
 * Does NOT validate per-category bounds — the V8 action wrapper does
 * that against the file-loaded effective bounds before calling here.
 *
 * The action passes `actorId` / `actorEmail` / `actorName` through so
 * audit emission attributes the change to the human caller (we lose
 * `authComponent` access in this internal layer).
 */
export const upsertRetentionPolicyInternal = internalMutation({
  args: {
    organizationId: v.string(),
    config: v.any(),
    actorId: v.string(),
    actorEmail: v.optional(v.string()),
    actorName: v.optional(v.string()),
  },
  returns: v.id('governancePolicies'),
  handler: async (ctx, args): Promise<Id<'governancePolicies'>> => {
    const parsed = retentionPolicyConfigSchema.safeParse(args.config);
    if (!parsed.success) {
      throw new Error(
        `Invalid retention policy configuration: ${parsed.error.message}`,
      );
    }

    const existing = await ctx.db
      .query('governancePolicies')
      .withIndex('by_org_policyType', (q) =>
        q
          .eq('organizationId', args.organizationId)
          .eq('policyType', 'retention_policy'),
      )
      .first();

    // 7-day cooldown on retention shortening (Phase 3).
    if (existing && isRecord(existing.config)) {
      const summary = detectRetentionShortening(existing.config, args.config);
      if (summary) {
        const cooldownMs = 7 * 24 * 60 * 60 * 1000;
        await ctx.db.insert('retentionPolicyPendingChanges', {
          organizationId: args.organizationId,
          appliesAt: Date.now() + cooldownMs,
          oldConfig: existing.config,
          newConfig: args.config,
          requestedBy: args.actorId,
          requestedAt: Date.now(),
          summary,
        });
        await createAuditLog(ctx, {
          organizationId: args.organizationId,
          actorId: args.actorId,
          actorEmail: args.actorEmail,
          actorType: 'user',
          action: 'policy.retention_shortening_pending',
          category: 'security',
          resourceType: 'governance_policy',
          resourceId: String(existing._id),
          resourceName: 'retention_policy',
          newState: { summary, appliesAt: Date.now() + cooldownMs },
          status: 'success',
        });
      }
    }

    const configEnabled =
      isRecord(args.config) && typeof args.config.enabled === 'boolean'
        ? args.config.enabled
        : undefined;

    let policyId: Id<'governancePolicies'>;
    if (existing) {
      await ctx.db.patch(existing._id, {
        config: args.config,
        updatedAt: Date.now(),
        updatedBy: args.actorId,
        ...(configEnabled !== undefined ? { enabled: configEnabled } : {}),
      });
      policyId = existing._id;
    } else {
      policyId = await ctx.db.insert('governancePolicies', {
        organizationId: args.organizationId,
        policyType: 'retention_policy',
        enabled: configEnabled ?? true,
        config: args.config,
        updatedAt: Date.now(),
        updatedBy: args.actorId,
      });
    }

    await createAuditLog(ctx, {
      organizationId: args.organizationId,
      actorId: args.actorId,
      actorEmail: args.actorEmail,
      actorType: 'user',
      action: existing ? 'policy.updated' : 'policy.created',
      category: 'security',
      resourceType: 'governance_policy',
      resourceId: String(policyId),
      resourceName: 'Policy: retention_policy',
      newState: { policyType: 'retention_policy', config: args.config },
      previousState: existing
        ? { policyType: existing.policyType, config: existing.config }
        : undefined,
      status: 'success',
    });

    // First-enable seed: when no `retentionAppliedBounds` row exists for
    // this org yet, the admin's first save IS their consent to the
    // current operator bounds. Schedule the seed action (idempotent —
    // a no-op when a row already exists) so cleanup has something to
    // read from on its next run. Cannot run inline because file IO
    // requires the Node action runtime.
    const existingApplied = await ctx.db
      .query('retentionAppliedBounds')
      .withIndex('by_organizationId', (q) =>
        q.eq('organizationId', args.organizationId),
      )
      .first();
    if (!existingApplied) {
      await ctx.scheduler.runAfter(
        0,
        internal.governance.retention_bounds_proposal.seedInitialBoundsInternal,
        {
          organizationId: args.organizationId,
          actorId: args.actorId,
          actorEmail: args.actorEmail,
          actorType: 'user',
        },
      );
    }

    return policyId;
  },
});
