/**
 * Org / project usage rollups from the UsageFact ledger (ADR-0021, ADR-0026).
 * Operational usage accounting, not payment collection. Organizations may use
 * either their own provider credentials or a distinct platform-managed
 * connection; commercial billing remains outside the pilot scope.
 *
 * The truth is in `UsageFact`; `AgentRun.costUsd / inputTokens / outputTokens`
 * are a derived rollup cache. This service reads `UsageFact` directly so that
 * external-capability consumption (PDF→MD, ASR, …) is attributed correctly,
 * not just the main model loop. A run with no cost-bearing fact is
 * `runsWithoutCost` — ADR-0022: missing cost ≠ zero.
 *
 * Breakdown buckets keep kind / provider / model / capability / unit, so the
 * admin UI can separate model tokens from non-token external meters instead of
 * collapsing everything into a single input/output token total.
 */
import type { Prisma, PrismaClient } from "@prisma/client";

export interface UsageTotals {
  readonly runCount: number;
  readonly runsWithCost: number;
  readonly runsWithoutCost: number;
  readonly inputTokens: number;
  readonly outputTokens: number;
  readonly costUsd: number | null;
}

export interface ProjectUsageRow extends UsageTotals {
  readonly projectId: string;
  readonly projectName: string;
  readonly folderId: string | null;
}

/** One ledger slice: kind + provider + model + capability + unit. */
export interface UsageBreakdownRow {
  readonly kind: string;
  readonly provider: string;
  readonly model: string | null;
  readonly capabilityId: string | null;
  readonly unit: string | null;
  readonly factCount: number;
  readonly factsWithCost: number;
  readonly factsWithoutCost: number;
  readonly inputTokens: number;
  readonly outputTokens: number;
  /** Sum of quantity when unit is non-null; null when this bucket is token-only. */
  readonly quantity: number | null;
  readonly costUsd: number | null;
}

export interface UsageReport {
  readonly from: string | null;
  readonly to: string | null;
  readonly projects: readonly ProjectUsageRow[];
  readonly totals: UsageTotals;
  readonly breakdown: readonly UsageBreakdownRow[];
}

export interface ProjectUsageReport extends ProjectUsageRow {
  readonly from: string | null;
  readonly to: string | null;
  readonly breakdown: readonly UsageBreakdownRow[];
}

type FactRow = {
  readonly kind: string;
  readonly provider: string;
  readonly model: string | null;
  readonly capabilityId: string | null;
  readonly unit: string | null;
  readonly quantity: unknown;
  readonly inputTokens: number | null;
  readonly outputTokens: number | null;
  readonly costUsd: unknown;
};

type RunWithFacts = {
  readonly projectId: string;
  readonly usageFacts: readonly FactRow[];
};

type MutableTotals = {
  runCount: number;
  runsWithCost: number;
  runsWithoutCost: number;
  inputTokens: number;
  outputTokens: number;
  costUsd: number | null;
};

type MutableBreakdown = {
  kind: string;
  provider: string;
  model: string | null;
  capabilityId: string | null;
  unit: string | null;
  factCount: number;
  factsWithCost: number;
  factsWithoutCost: number;
  inputTokens: number;
  outputTokens: number;
  quantity: number | null;
  costUsd: number | null;
};

type MutableProjectRow = MutableTotals & {
  readonly projectId: string;
  readonly projectName: string;
  readonly folderId: string | null;
};

const FACT_SELECT = {
  kind: true,
  provider: true,
  model: true,
  capabilityId: true,
  unit: true,
  quantity: true,
  inputTokens: true,
  outputTokens: true,
  costUsd: true,
} as const;

function factCostUsdToNumber(value: unknown): number | null {
  if (value === null || value === undefined) return null;
  const n = typeof value === "number" ? value : Number(value);
  return Number.isFinite(n) ? n : null;
}

function factQuantityToNumber(value: unknown): number | null {
  if (value === null || value === undefined) return null;
  const n = typeof value === "number" ? value : Number(value);
  return Number.isFinite(n) ? n : null;
}

function breakdownKey(f: FactRow): string {
  return [
    f.kind,
    f.provider,
    f.model ?? "",
    f.capabilityId ?? "",
    f.unit ?? "",
  ].join("\u0000");
}

function emptyMutableTotals(): MutableTotals {
  return {
    runCount: 0,
    runsWithCost: 0,
    runsWithoutCost: 0,
    inputTokens: 0,
    outputTokens: 0,
    costUsd: null,
  };
}

function addCost(existing: number | null, add: number): number {
  return (existing ?? 0) + add;
}

function rollupRunTokensAndCost(facts: readonly FactRow[]): {
  readonly hasCost: boolean;
  readonly inputTokens: number;
  readonly outputTokens: number;
  readonly costUsd: number | null;
} {
  let inputTokens = 0;
  let outputTokens = 0;
  let costUsd: number | null = null;
  let hasCost = false;
  for (const f of facts) {
    inputTokens += f.inputTokens ?? 0;
    outputTokens += f.outputTokens ?? 0;
    const c = factCostUsdToNumber(f.costUsd);
    if (c !== null) {
      hasCost = true;
      costUsd = addCost(costUsd, c);
    }
  }
  return { hasCost, inputTokens, outputTokens, costUsd };
}

function accumulateBreakdown(
  buckets: Map<string, MutableBreakdown>,
  facts: readonly FactRow[],
): void {
  for (const f of facts) {
    const key = breakdownKey(f);
    let bucket = buckets.get(key);
    if (bucket === undefined) {
      bucket = {
        kind: f.kind,
        provider: f.provider,
        model: f.model,
        capabilityId: f.capabilityId,
        unit: f.unit,
        factCount: 0,
        factsWithCost: 0,
        factsWithoutCost: 0,
        inputTokens: 0,
        outputTokens: 0,
        quantity: null,
        costUsd: null,
      };
      buckets.set(key, bucket);
    }
    bucket.factCount += 1;
    bucket.inputTokens += f.inputTokens ?? 0;
    bucket.outputTokens += f.outputTokens ?? 0;
    const qty = factQuantityToNumber(f.quantity);
    if (qty !== null) {
      bucket.quantity = (bucket.quantity ?? 0) + qty;
    }
    const c = factCostUsdToNumber(f.costUsd);
    if (c !== null) {
      bucket.factsWithCost += 1;
      bucket.costUsd = addCost(bucket.costUsd, c);
    } else {
      bucket.factsWithoutCost += 1;
    }
  }
}

function freezeBreakdown(buckets: Map<string, MutableBreakdown>): UsageBreakdownRow[] {
  return [...buckets.values()]
    .map((b) => ({
      kind: b.kind,
      provider: b.provider,
      model: b.model,
      capabilityId: b.capabilityId,
      unit: b.unit,
      factCount: b.factCount,
      factsWithCost: b.factsWithCost,
      factsWithoutCost: b.factsWithoutCost,
      inputTokens: b.inputTokens,
      outputTokens: b.outputTokens,
      quantity: b.quantity,
      costUsd: b.costUsd,
    }))
    .sort((a, b) => {
      const kindCmp = a.kind.localeCompare(b.kind);
      if (kindCmp !== 0) return kindCmp;
      const provCmp = a.provider.localeCompare(b.provider);
      if (provCmp !== 0) return provCmp;
      const capA = a.capabilityId ?? "";
      const capB = b.capabilityId ?? "";
      const capCmp = capA.localeCompare(capB);
      if (capCmp !== 0) return capCmp;
      const modelA = a.model ?? "";
      const modelB = b.model ?? "";
      return modelA.localeCompare(modelB);
    });
}

function freezeTotals(t: MutableTotals): UsageTotals {
  return {
    runCount: t.runCount,
    runsWithCost: t.runsWithCost,
    runsWithoutCost: t.runsWithoutCost,
    inputTokens: t.inputTokens,
    outputTokens: t.outputTokens,
    costUsd: t.costUsd,
  };
}

function applyRunToTotals(totals: MutableTotals, facts: readonly FactRow[]): void {
  const roll = rollupRunTokensAndCost(facts);
  totals.runCount += 1;
  if (roll.hasCost) {
    totals.runsWithCost += 1;
    totals.costUsd = addCost(totals.costUsd, roll.costUsd ?? 0);
  } else {
    totals.runsWithoutCost += 1;
  }
  totals.inputTokens += roll.inputTokens;
  totals.outputTokens += roll.outputTokens;
}

function emptyReport(from?: Date, to?: Date): UsageReport {
  return {
    from: from?.toISOString() ?? null,
    to: to?.toISOString() ?? null,
    projects: [],
    totals: freezeTotals(emptyMutableTotals()),
    breakdown: [],
  };
}

export async function getOrgUsage(
  prisma: PrismaClient,
  input: {
    readonly organizationId: string;
    readonly from?: Date | undefined;
    readonly to?: Date | undefined;
    readonly folderId?: string | undefined;
  },
): Promise<UsageReport> {
  const projectWhere: Prisma.ProjectWhereInput = {
    organizationId: input.organizationId,
    ...(input.folderId !== undefined ? { folderId: input.folderId } : {}),
  };
  const projects = await prisma.project.findMany({
    where: projectWhere,
    select: { id: true, name: true, folderId: true },
    orderBy: { name: "asc" },
  });
  if (projects.length === 0) {
    return emptyReport(input.from, input.to);
  }

  const projectIds = projects.map((p) => p.id);
  // Date range filters by run.startedAt, matching the pre-ADR-0026 semantics:
  // a run is in or out of the window based on when it started, and all of its
  // facts come along. Filtering facts by occurredAt independently would let a
  // run contribute partial cost to a window it doesn't belong to.
  const runs = await prisma.agentRun.findMany({
    where: {
      projectId: { in: projectIds },
      ...(input.from !== undefined || input.to !== undefined
        ? {
            startedAt: {
              ...(input.from !== undefined ? { gte: input.from } : {}),
              ...(input.to !== undefined ? { lte: input.to } : {}),
            },
          }
        : {}),
    },
    select: {
      projectId: true,
      usageFacts: {
        select: FACT_SELECT,
        orderBy: { occurredAt: "asc" },
      },
    },
  });

  const byProject = new Map<string, MutableProjectRow>();
  for (const p of projects) {
    byProject.set(p.id, {
      projectId: p.id,
      projectName: p.name,
      folderId: p.folderId,
      ...emptyMutableTotals(),
    });
  }

  const breakdownBuckets = new Map<string, MutableBreakdown>();
  for (const run of runs as readonly RunWithFacts[]) {
    const row = byProject.get(run.projectId);
    if (row === undefined) continue;
    applyRunToTotals(row, run.usageFacts);
    accumulateBreakdown(breakdownBuckets, run.usageFacts);
  }

  const projectsOut: ProjectUsageRow[] = [...byProject.values()].map((r) => ({
    projectId: r.projectId,
    projectName: r.projectName,
    folderId: r.folderId,
    ...freezeTotals(r),
  }));

  const totals = emptyMutableTotals();
  for (const row of projectsOut) {
    totals.runCount += row.runCount;
    totals.runsWithCost += row.runsWithCost;
    totals.runsWithoutCost += row.runsWithoutCost;
    totals.inputTokens += row.inputTokens;
    totals.outputTokens += row.outputTokens;
    if (row.costUsd !== null) {
      totals.costUsd = addCost(totals.costUsd, row.costUsd);
    }
  }

  return {
    from: input.from?.toISOString() ?? null,
    to: input.to?.toISOString() ?? null,
    projects: projectsOut,
    totals: freezeTotals(totals),
    breakdown: freezeBreakdown(breakdownBuckets),
  };
}

export async function getProjectUsage(
  prisma: PrismaClient,
  input: {
    readonly organizationId: string;
    readonly projectId: string;
    readonly from?: Date | undefined;
    readonly to?: Date | undefined;
  },
): Promise<ProjectUsageReport> {
  const project = await prisma.project.findFirst({
    where: { id: input.projectId, organizationId: input.organizationId },
    select: { id: true, name: true, folderId: true },
  });
  if (project === null) {
    throw new Error(`project not found: ${input.projectId}`);
  }

  const runs = await prisma.agentRun.findMany({
    where: {
      projectId: project.id,
      ...(input.from !== undefined || input.to !== undefined
        ? {
            startedAt: {
              ...(input.from !== undefined ? { gte: input.from } : {}),
              ...(input.to !== undefined ? { lte: input.to } : {}),
            },
          }
        : {}),
    },
    select: {
      usageFacts: {
        select: FACT_SELECT,
        orderBy: { occurredAt: "asc" },
      },
    },
  });

  const totals = emptyMutableTotals();
  const breakdownBuckets = new Map<string, MutableBreakdown>();
  for (const run of runs) {
    applyRunToTotals(totals, run.usageFacts as readonly FactRow[]);
    accumulateBreakdown(breakdownBuckets, run.usageFacts as readonly FactRow[]);
  }

  return {
    projectId: project.id,
    projectName: project.name,
    folderId: project.folderId,
    ...freezeTotals(totals),
    from: input.from?.toISOString() ?? null,
    to: input.to?.toISOString() ?? null,
    breakdown: freezeBreakdown(breakdownBuckets),
  };
}
