import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import { TargetProfileDoc, VpnClientDoc } from '../db/index.js';
import type { ITargetProfile, ITargetProfileTarget } from '../../ts_interfaces/data/target-profile.js';
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
import type { IRoute } from '../../ts_interfaces/data/route-management.js';

type TVpnClientAllowEntry = string | { clientId: string; domains: string[] };

/**
 * Manages TargetProfiles (target-side: what can be accessed).
 * TargetProfiles define what resources a VPN client can reach:
 * domains, specific IP:port targets, and/or direct route references.
 */
export class TargetProfileManager {
  private profiles = new Map<string, ITargetProfile>();

  constructor(
    private getAllRoutes?: () => Map<string, IRoute>,
  ) {}

  // =========================================================================
  // Lifecycle
  // =========================================================================

  public async initialize(): Promise<void> {
    await this.loadProfiles();
  }

  // =========================================================================
  // CRUD
  // =========================================================================

  public async createProfile(data: {
    name: string;
    description?: string;
    domains?: string[];
    targets?: ITargetProfileTarget[];
    routeRefs?: string[];
    allowRoutesByClientSourceIp?: boolean;
    createdBy: string;
  }): Promise<string> {
    // Enforce unique profile names
    for (const existing of this.profiles.values()) {
      if (existing.name === data.name) {
        throw new Error(`Target profile with name '${data.name}' already exists (id: ${existing.id})`);
      }
    }

    const id = plugins.uuid.v4();
    const now = Date.now();

    const routeRefs = this.normalizeRouteRefs(data.routeRefs);
    const profile: ITargetProfile = {
      id,
      name: data.name,
      description: data.description,
      domains: data.domains,
      targets: data.targets,
      routeRefs,
      allowRoutesByClientSourceIp: data.allowRoutesByClientSourceIp === true,
      createdAt: now,
      updatedAt: now,
      createdBy: data.createdBy,
    };

    this.profiles.set(id, profile);
    await this.persistProfile(profile);
    logger.log('info', `Created target profile '${profile.name}' (${id})`);
    return id;
  }

  public async updateProfile(
    id: string,
    patch: Partial<Omit<ITargetProfile, 'id' | 'createdAt' | 'createdBy'>>,
  ): Promise<void> {
    const profile = this.profiles.get(id);
    if (!profile) {
      throw new Error(`Target profile '${id}' not found`);
    }

    if (patch.name !== undefined && patch.name !== profile.name) {
      for (const existing of this.profiles.values()) {
        if (existing.id !== id && existing.name === patch.name) {
          throw new Error(`Target profile with name '${patch.name}' already exists (id: ${existing.id})`);
        }
      }
    }

    if (patch.name !== undefined) profile.name = patch.name;
    if (patch.description !== undefined) profile.description = patch.description;
    if (patch.domains !== undefined) profile.domains = patch.domains;
    if (patch.targets !== undefined) profile.targets = patch.targets;
    if (patch.routeRefs !== undefined) profile.routeRefs = this.normalizeRouteRefs(patch.routeRefs);
    if (patch.allowRoutesByClientSourceIp !== undefined) {
      profile.allowRoutesByClientSourceIp = patch.allowRoutesByClientSourceIp === true;
    }
    profile.updatedAt = Date.now();

    await this.persistProfile(profile);
    logger.log('info', `Updated target profile '${profile.name}' (${id})`);
  }

  public async deleteProfile(
    id: string,
    force?: boolean,
  ): Promise<{ success: boolean; message?: string }> {
    const profile = this.profiles.get(id);
    if (!profile) {
      return { success: false, message: `Target profile '${id}' not found` };
    }

    // Check if any VPN clients reference this profile
    const clients = await VpnClientDoc.findAll();
    const referencingClients = clients.filter(
      (c) => c.targetProfileIds?.includes(id),
    );

    if (referencingClients.length > 0 && !force) {
      return {
        success: false,
        message: `Profile '${profile.name}' is in use by ${referencingClients.length} VPN client(s). Use force=true to delete.`,
      };
    }

    // Delete from DB
    const doc = await TargetProfileDoc.findById(id);
    if (doc) await doc.delete();
    this.profiles.delete(id);

    if (referencingClients.length > 0) {
      // Remove profile ref from clients
      for (const client of referencingClients) {
        client.targetProfileIds = client.targetProfileIds?.filter((pid) => pid !== id);
        client.updatedAt = Date.now();
        await client.save();
      }
      logger.log('warn', `Force-deleted target profile '${profile.name}'; removed refs from ${referencingClients.length} client(s)`);
    } else {
      logger.log('info', `Deleted target profile '${profile.name}' (${id})`);
    }

    return { success: true };
  }

  public getProfile(id: string): ITargetProfile | undefined {
    return this.profiles.get(id);
  }

  /**
   * Normalize stored route references to route IDs when they can be resolved
   * uniquely against the current route registry.
   */
  public async normalizeAllRouteRefs(): Promise<void> {
    const allRoutes = this.getAllRoutes?.();
    if (!allRoutes?.size) return;

    for (const profile of this.profiles.values()) {
      const normalizedRouteRefs = this.normalizeRouteRefsAgainstRoutes(
        profile.routeRefs,
        allRoutes,
        'bestEffort',
      );
      if (this.sameStringArray(profile.routeRefs, normalizedRouteRefs)) continue;

      profile.routeRefs = normalizedRouteRefs;
      profile.updatedAt = Date.now();
      await this.persistProfile(profile);
      logger.log('info', `Normalized route refs for target profile '${profile.name}' (${profile.id})`);
    }
  }

  public listProfiles(): ITargetProfile[] {
    return [...this.profiles.values()];
  }

  /**
   * Get which VPN clients reference a target profile.
   */
  public async getProfileUsage(profileId: string): Promise<Array<{ clientId: string; description?: string }>> {
    const clients = await VpnClientDoc.findAll();
    return clients
      .filter((c) => c.targetProfileIds?.includes(profileId))
      .map((c) => ({ clientId: c.clientId, description: c.description }));
  }

  // =========================================================================
  // Direct target IPs (bypass SmartProxy)
  // =========================================================================

  /**
   * For a set of target profile IDs, collect all explicit target IPs.
   * These IPs bypass the SmartProxy forceTarget rewrite — VPN clients can
   * connect to them directly through the tunnel.
   */
  public getDirectTargetIps(targetProfileIds: string[]): string[] {
    const ips = new Set<string>();
    for (const profileId of targetProfileIds) {
      const profile = this.profiles.get(profileId);
      if (!profile?.targets?.length) continue;
      for (const t of profile.targets) {
        ips.add(t.ip);
      }
    }
    return [...ips];
  }

  // =========================================================================
  // Core matching: route → VPN client grants
  // =========================================================================

  /**
   * Find all enabled VPN clients whose assigned TargetProfile matches the route.
   * Returns SmartProxy VPN client allow entries for authenticated metadata checks.
   *
   * Entries are domain-scoped when a profile matches via specific domains that are
   * a subset of the route's wildcard. Plain IPs are returned for routeRef/target matches
   * or when profile domains exactly equal the route's domains. Profiles can also opt
   * into source-policy routes; SmartProxy evaluates the real source IP per connection.
   */
  public getMatchingVpnClients(
    route: IDcRouterRouteConfig,
    routeId: string | undefined,
    clients: VpnClientDoc[],
    allRoutes: Map<string, IRoute> = new Map(),
  ): TVpnClientAllowEntry[] {
    const entries: TVpnClientAllowEntry[] = [];
    const routeDomains = this.getRouteDomains(route);
    const routeNameIndex = this.buildRouteNameIndex(allRoutes);

    for (const client of clients) {
      if (!client.enabled || !client.clientId) continue;
      if (!client.targetProfileIds?.length) continue;

      // Collect scoped domains from all matching profiles for this client
      let fullAccess = false;
      const scopedDomains = new Set<string>();

      for (const profileId of client.targetProfileIds) {
        const profile = this.profiles.get(profileId);
        if (!profile) continue;

        const matchResult = this.routeMatchesProfileDetailed(
          route,
          routeId,
          profile,
          routeDomains,
          routeNameIndex,
        );
        if (matchResult === 'full') {
          fullAccess = true;
          break; // No need to check more profiles
        }
        if (matchResult !== 'none') {
          for (const d of matchResult.domains) scopedDomains.add(d);
        }

        if (
          profile.allowRoutesByClientSourceIp === true
          && this.routeHasSourcePolicy(route)
        ) {
          fullAccess = true;
          break;
        }
      }

      if (fullAccess) {
        entries.push(client.clientId);
      } else if (scopedDomains.size > 0) {
        entries.push({ clientId: client.clientId, domains: [...scopedDomains] });
      }
    }

    return entries;
  }

  /**
   * For a given client (by its targetProfileIds), compute the set of
   * domains and target IPs it can access. Used for WireGuard AllowedIPs.
   */
  public getClientAccessSpec(
    targetProfileIds: string[],
    allRoutes: Map<string, IRoute>,
  ): { domains: string[]; targetIps: string[] } {
    const domains = new Set<string>();
    const targetIps = new Set<string>();
    const routeNameIndex = this.buildRouteNameIndex(allRoutes);

    // Collect all access specifiers from assigned profiles
    for (const profileId of targetProfileIds) {
      const profile = this.profiles.get(profileId);
      if (!profile) continue;

      // Direct domain entries
      if (profile.domains?.length) {
        for (const d of profile.domains) {
          domains.add(d);
        }
      }

      // Direct target IP entries
      if (profile.targets?.length) {
        for (const t of profile.targets) {
          targetIps.add(t.ip);
        }
      }

      // Route references: scan all routes
      for (const [routeId, route] of allRoutes) {
        if (!route.enabled) continue;
        const dcRoute = route.route as IDcRouterRouteConfig;
        const routeDomains = this.getRouteDomains(dcRoute);
        const profileMatchesRoute = this.routeMatchesProfile(
          dcRoute,
          routeId,
          profile,
          routeNameIndex,
        );
        const sourceIpMatchesRoute = profile.allowRoutesByClientSourceIp === true
          && this.routeHasSourcePolicy(dcRoute);
        if (profileMatchesRoute || sourceIpMatchesRoute) {
          for (const d of routeDomains) {
            domains.add(d);
          }
        }
      }
    }

    return {
      domains: [...domains],
      targetIps: [...targetIps],
    };
  }

  // =========================================================================
  // Private: matching logic
  // =========================================================================

  /**
   * Check if a route matches a profile (boolean convenience wrapper).
   */
  private routeMatchesProfile(
    route: IDcRouterRouteConfig,
    routeId: string | undefined,
    profile: ITargetProfile,
    routeNameIndex: Map<string, string[]>,
  ): boolean {
    const routeDomains = this.getRouteDomains(route);
    const result = this.routeMatchesProfileDetailed(
      route,
      routeId,
      profile,
      routeDomains,
      routeNameIndex,
    );
    return result !== 'none';
  }

  /**
   * Detailed match: returns 'full' (plain IP, entire route), 'scoped' (domain-limited),
   * or 'none' (no match).
   *
   * - routeRefs / target matches → 'full' (explicit reference = full access)
   * - domain match where profile domains are a subset of route wildcard → 'scoped'
   * - domain match where domains are identical or profile is a wildcard → 'full'
   */
  private routeMatchesProfileDetailed(
    route: IDcRouterRouteConfig,
    routeId: string | undefined,
    profile: ITargetProfile,
    routeDomains: string[],
    routeNameIndex: Map<string, string[]>,
  ): 'full' | { type: 'scoped'; domains: string[] } | 'none' {
    // 1. Route reference match → full access
    if (profile.routeRefs?.length) {
      if (routeId && profile.routeRefs.includes(routeId)) return 'full';
      if (routeId && route.name && profile.routeRefs.includes(route.name)) {
        const matchingRouteIds = routeNameIndex.get(route.name) || [];
        if (matchingRouteIds.length === 1 && matchingRouteIds[0] === routeId) {
          return 'full';
        }
      }
    }

    // 2. Domain match
    if (profile.domains?.length && routeDomains.length) {
      const matchedProfileDomains: string[] = [];

      for (const profileDomain of profile.domains) {
        for (const routeDomain of routeDomains) {
          if (this.domainMatchesPattern(routeDomain, profileDomain) ||
              this.domainMatchesPattern(profileDomain, routeDomain)) {
            matchedProfileDomains.push(profileDomain);
            break; // This profileDomain matched, move to the next
          }
        }
      }

      if (matchedProfileDomains.length > 0) {
        // Check if profile domains cover the route entirely (same wildcards = full access)
        const isFullCoverage = routeDomains.every((rd) =>
          matchedProfileDomains.some((pd) =>
            rd === pd || this.domainMatchesPattern(rd, pd),
          ),
        );
        if (isFullCoverage) return 'full';

        // Profile domains are a subset → scoped access to those specific domains
        return { type: 'scoped', domains: matchedProfileDomains };
      }
    }

    // 3. Target match (host + port) → full access (precise by nature)
    if (profile.targets?.length) {
      const routeTargets = (route.action as any)?.targets;
      if (Array.isArray(routeTargets)) {
        for (const profileTarget of profile.targets) {
          for (const routeTarget of routeTargets) {
            const routeHost = routeTarget.host;
            const routePort = routeTarget.port;
            if (routeHost === profileTarget.ip && routePort === profileTarget.port) {
              return 'full';
            }
          }
        }
      }
    }

    return 'none';
  }

  /**
   * Check if a domain matches a pattern.
   * - '*.example.com' matches 'sub.example.com', 'a.b.example.com'
   * - 'example.com' matches only 'example.com'
   */
  private domainMatchesPattern(domain: string, pattern: string): boolean {
    if (pattern === domain) return true;
    if (pattern.startsWith('*.')) {
      const suffix = pattern.slice(1); // '.example.com'
      return domain.endsWith(suffix) && domain.length > suffix.length;
    }
    return false;
  }

  private routeHasSourcePolicy(route: IDcRouterRouteConfig): boolean {
    const security = (route as any).security;
    const blockEntries = Array.isArray(security?.ipBlockList)
      ? security.ipBlockList
      : security?.ipBlockList
        ? [security.ipBlockList]
        : [];
    return !blockEntries.some((entry: unknown) => typeof entry === 'string' && entry.trim() === '*');
  }

  private getRouteDomains(route: IDcRouterRouteConfig): string[] {
    const domains = (route.match as any)?.domains;
    if (!domains) return [];
    return Array.isArray(domains) ? domains : [domains];
  }

  private normalizeRouteRefs(routeRefs?: string[]): string[] | undefined {
    const allRoutes = this.getAllRoutes?.() || new Map<string, IRoute>();
    return this.normalizeRouteRefsAgainstRoutes(routeRefs, allRoutes, 'strict');
  }

  private normalizeRouteRefsAgainstRoutes(
    routeRefs: string[] | undefined,
    allRoutes: Map<string, IRoute>,
    mode: 'strict' | 'bestEffort',
  ): string[] | undefined {
    if (!routeRefs?.length) return undefined;
    if (!allRoutes.size) return [...new Set(routeRefs)];

    const routeNameIndex = this.buildRouteNameIndex(allRoutes);
    const normalizedRefs = new Set<string>();

    for (const routeRef of routeRefs) {
      if (allRoutes.has(routeRef)) {
        normalizedRefs.add(routeRef);
        continue;
      }

      const matchingRouteIds = routeNameIndex.get(routeRef) || [];
      if (matchingRouteIds.length === 1) {
        normalizedRefs.add(matchingRouteIds[0]);
        continue;
      }

      if (mode === 'bestEffort') {
        normalizedRefs.add(routeRef);
        continue;
      }

      if (matchingRouteIds.length > 1) {
        throw new Error(`Route reference '${routeRef}' is ambiguous; use a route ID instead`);
      }
      throw new Error(`Route reference '${routeRef}' not found`);
    }

    return [...normalizedRefs];
  }

  private buildRouteNameIndex(allRoutes: Map<string, IRoute>): Map<string, string[]> {
    const routeNameIndex = new Map<string, string[]>();
    for (const [routeId, route] of allRoutes) {
      const routeName = route.route.name;
      if (!routeName) continue;
      const matchingRouteIds = routeNameIndex.get(routeName) || [];
      matchingRouteIds.push(routeId);
      routeNameIndex.set(routeName, matchingRouteIds);
    }
    return routeNameIndex;
  }

  private sameStringArray(left?: string[], right?: string[]): boolean {
    if (!left?.length && !right?.length) return true;
    if (!left || !right || left.length !== right.length) return false;
    return left.every((value, index) => value === right[index]);
  }

  // =========================================================================
  // Private: persistence
  // =========================================================================

  private async loadProfiles(): Promise<void> {
    const docs = await TargetProfileDoc.findAll();
    for (const doc of docs) {
      if (doc.id) {
        this.profiles.set(doc.id, {
          id: doc.id,
          name: doc.name,
          description: doc.description,
          domains: doc.domains,
          targets: doc.targets,
          routeRefs: doc.routeRefs,
          allowRoutesByClientSourceIp: doc.allowRoutesByClientSourceIp === true,
          createdAt: doc.createdAt,
          updatedAt: doc.updatedAt,
          createdBy: doc.createdBy,
        });
      }
    }
    if (this.profiles.size > 0) {
      logger.log('info', `Loaded ${this.profiles.size} target profile(s) from storage`);
    }
  }

  private async persistProfile(profile: ITargetProfile): Promise<void> {
    const existingDoc = await TargetProfileDoc.findById(profile.id);
    if (existingDoc) {
      existingDoc.name = profile.name;
      existingDoc.description = profile.description;
      existingDoc.domains = profile.domains;
      existingDoc.targets = profile.targets;
      existingDoc.routeRefs = profile.routeRefs;
      existingDoc.allowRoutesByClientSourceIp = profile.allowRoutesByClientSourceIp === true;
      existingDoc.updatedAt = profile.updatedAt;
      await existingDoc.save();
    } else {
      const doc = new TargetProfileDoc();
      doc.id = profile.id;
      doc.name = profile.name;
      doc.description = profile.description;
      doc.domains = profile.domains;
      doc.targets = profile.targets;
      doc.routeRefs = profile.routeRefs;
      doc.allowRoutesByClientSourceIp = profile.allowRoutesByClientSourceIp === true;
      doc.createdAt = profile.createdAt;
      doc.updatedAt = profile.updatedAt;
      doc.createdBy = profile.createdBy;
      await doc.save();
    }
  }
}
