import {
  ConflictException,
  Injectable,
  Logger,
  NotFoundException,
} from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { createHash } from 'node:crypto'
import { Model } from 'mongoose'
import { AuditService, type AuditActor } from '../audit/audit.service.js'
import { Flag, FlagDocument } from '../schemas/flag.schema.js'
import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
import type { CreateFlagDto } from './dto/create-flag.dto.js'
import type { UpdateFlagDto } from './dto/update-flag.dto.js'

// Max number of history entries kept embedded on a flag doc. Older entries
// get dropped on the next change. If we want unbounded audit, route it to a
// separate audit collection (see NEXT-STEPS.md follow-up).
const HISTORY_LIMIT = 20

export interface EvalContext {
  tenantId: string
  tenantSlug: string
  plan: string
  partnerSlug?: string
  env: 'prod' | 'staging' | 'dev'
}

// Existing ActorRef was a local subset of AuditActor — collapse to the shared
// type so the controller passes one shape into both flag history and the
// platform audit log.
export type ActorRef = AuditActor

@Injectable()
export class FlagsService {
  private readonly logger = new Logger(FlagsService.name)

  constructor(
    @InjectModel(Flag.name) private readonly flagModel: Model<FlagDocument>,
    @InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
    private readonly audit: AuditService,
  ) {}

  async create(dto: CreateFlagDto, actor?: ActorRef): Promise<FlagDocument> {
    const exists = await this.flagModel.exists({ key: dto.key })
    if (exists) throw new ConflictException(`Flag "${dto.key}" already exists`)
    const doc = await this.flagModel.create({
      key: dto.key,
      description: dto.description ?? '',
      state: dto.state ?? 'off',
      pct: dto.pct ?? 0,
      scope: dto.scope ?? { plans: [], tenantSlugs: [], partnerSlugs: [], environments: [] },
      history: [
        {
          at: new Date(),
          byEmail: actor?.email,
          action: `created · state=${dto.state ?? 'off'}`,
        },
      ],
    })
    void this.audit.record(
      {
        action: 'flag.created',
        resourceType: 'flag',
        resourceId: String(doc._id),
        resourceName: doc.key,
        metadata: { state: doc.state, pct: doc.pct },
      },
      actor,
    )
    return doc
  }

  findAll(): Promise<FlagDocument[]> {
    return this.flagModel.find().sort({ key: 1 }).exec()
  }

  async findOne(key: string): Promise<FlagDocument> {
    const flag = await this.flagModel.findOne({ key }).exec()
    if (!flag) throw new NotFoundException(`Flag "${key}" not found`)
    return flag
  }

  async update(key: string, dto: UpdateFlagDto, actor?: ActorRef): Promise<FlagDocument> {
    const flag = await this.findOne(key)

    const actions: string[] = []
    if (dto.description !== undefined && dto.description !== flag.description) {
      actions.push('description updated')
      flag.description = dto.description
    }
    if (dto.state !== undefined && dto.state !== flag.state) {
      actions.push(`state: ${flag.state} → ${dto.state}`)
      flag.state = dto.state
    }
    if (dto.pct !== undefined && dto.pct !== flag.pct) {
      actions.push(`pct: ${flag.pct}% → ${dto.pct}%`)
      flag.pct = dto.pct
    }
    if (dto.scope !== undefined) {
      // Replace the scope wholesale (DTO supplies the new shape).
      flag.scope = {
        plans: dto.scope.plans ?? flag.scope.plans,
        tenantSlugs: dto.scope.tenantSlugs ?? flag.scope.tenantSlugs,
        partnerSlugs: dto.scope.partnerSlugs ?? flag.scope.partnerSlugs,
        environments: dto.scope.environments ?? flag.scope.environments,
      }
      actions.push('scope updated')
    }

    if (actions.length === 0) return flag  // no-op update; don't pollute history

    flag.history.push({
      at: new Date(),
      byEmail: actor?.email,
      action: actions.join(' · '),
      note: dto.note,
    })
    if (flag.history.length > HISTORY_LIMIT) {
      flag.history.splice(0, flag.history.length - HISTORY_LIMIT)
    }

    await flag.save()

    // Kill-switch (state→off + pct→0 from a non-off state) gets its own action
    // verb so the operator can search the timeline for "what got killed?". The
    // FlagDetail UI's kill-switch button always passes note='kill-switch'.
    const isKillSwitch = dto.state === 'off' && dto.note === 'kill-switch'
    void this.audit.record(
      {
        action: isKillSwitch ? 'flag.killed' : 'flag.updated',
        resourceType: 'flag',
        resourceId: String(flag._id),
        resourceName: flag.key,
        metadata: { changes: actions, note: dto.note },
      },
      actor,
    )

    return flag
  }

  async remove(key: string, actor?: ActorRef): Promise<void> {
    const flag = await this.flagModel.findOne({ key }).exec()
    const result = await this.flagModel.deleteOne({ key }).exec()
    if (result.deletedCount === 0) throw new NotFoundException(`Flag "${key}" not found`)
    void this.audit.record(
      {
        action: 'flag.deleted',
        resourceType: 'flag',
        resourceId: flag ? String(flag._id) : undefined,
        resourceName: key,
      },
      actor,
    )
  }

  // ── Evaluation ───────────────────────────────────────────────────────────

  async evaluateAll(tenantSlug: string): Promise<Record<string, boolean>> {
    const tenant = await this.tenantModel.findOne({ slug: tenantSlug }).exec()
    if (!tenant) throw new NotFoundException(`Tenant "${tenantSlug}" not found`)

    const env = (process.env.DEZKY_ENV ?? 'dev') as 'prod' | 'staging' | 'dev'

    const ctx: EvalContext = {
      tenantId: String(tenant._id),
      tenantSlug: tenant.slug,
      plan: tenant.plan,
      // partnerId is on Tenant.partnerId; we'd need a lookup for the slug.
      // Leave as undefined for now — the eval still works (partnerSlugs scope
      // becomes "no partner" matcher). Hydrate when partner-targeted flags
      // actually exist.
      partnerSlug: undefined,
      env,
    }

    const flags = await this.flagModel.find().exec()
    const out: Record<string, boolean> = {}
    for (const flag of flags) {
      out[flag.key] = this.evaluateOne(flag, ctx)
    }
    return out
  }

  evaluateOne(flag: FlagDocument, ctx: EvalContext): boolean {
    if (flag.state === 'off') return false
    if (flag.state === 'on') return true

    // `targeted` is an explicit allowlist. Empty scope = nobody is on the list
    // yet — evaluating to false is the defensive read. If you wanted "everyone",
    // you'd flip the state to `on`.
    if (flag.state === 'targeted') {
      if (!hasAnyScope(flag.scope)) return false
      return matchesScope(flag.scope, ctx)
    }

    // `rollout` filters by scope (empty axes = no restriction on that axis) and
    // then by deterministic hash bucket within the matching set.
    if (flag.state === 'rollout') {
      if (!matchesScope(flag.scope, ctx)) return false
      return inRolloutBucket(ctx.tenantId, flag.key, flag.pct)
    }
    return false
  }
}

// ── Pure helpers (exported for tests) ───────────────────────────────────────

export function hasAnyScope(scope: {
  plans: string[]
  tenantSlugs: string[]
  partnerSlugs: string[]
  environments: string[]
}): boolean {
  return (
    scope.plans.length > 0 ||
    scope.tenantSlugs.length > 0 ||
    scope.partnerSlugs.length > 0 ||
    scope.environments.length > 0
  )
}

export function matchesScope(
  scope: {
    plans: string[]
    tenantSlugs: string[]
    partnerSlugs: string[]
    environments: string[]
  },
  ctx: EvalContext,
): boolean {
  if (scope.environments.length && !scope.environments.includes(ctx.env)) return false
  if (scope.plans.length && !scope.plans.includes(ctx.plan)) return false
  if (scope.tenantSlugs.length && !scope.tenantSlugs.includes(ctx.tenantSlug)) return false
  if (scope.partnerSlugs.length) {
    if (!ctx.partnerSlug) return false
    if (!scope.partnerSlugs.includes(ctx.partnerSlug)) return false
  }
  return true
}

// Deterministic rollout assignment: sha256(`${tenantId}:${flagKey}`) → 0–99
// bucket. A given tenant either is or isn't in the rollout for a given flag.
// Bumping pct only flips the new slice, never moves existing in/out tenants.
export function inRolloutBucket(tenantId: string, flagKey: string, pct: number): boolean {
  if (pct >= 100) return true
  if (pct <= 0) return false
  const hash = createHash('sha256').update(`${tenantId}:${flagKey}`).digest()
  const bucket = hash.readUInt16BE(0) % 100
  return bucket < pct
}
