import fs from 'fs';
import { NextFunction, Request, RequestHandler, Response } from 'express';
import { Redis } from 'ioredis';
import YAML from 'yaml';
import { z } from 'zod';
import { TooManyRequestsError } from '../../business/errors/tooManyRequestError';
import { LoggerPort } from '../../business/ports/loggerPort';
import { MetricRecorderPort } from '../../business/ports/metricRecorderPort';

// ---------- Types ----------

const ThrottleRuleSchema = z
    .object({
        bucketKey: z.string().min(1).optional(),
        burst: z.number().int().min(0),
        pattern: z.string().min(1),
        refill: z.number().int().min(0),
    })
    .refine((data) => data.refill > 0 || data.burst === 0, {
        message: 'refill=0 is only valid when burst=0 (hard block); otherwise the bucket cannot refill',
    });

export type ThrottleRule = z.infer<typeof ThrottleRuleSchema>;

export interface CompiledRule {
    pattern: string;
    burst: number;
    refill: number;
    regex: RegExp;
    bucketKeyTemplate: string | null;
    isCatchAll: boolean;
}

export interface ThrottleOptions {
    redis: Redis;
    loader: ThrottleRuleLoader;
    requestSignature: (req: Request) => string[];
    logger: LoggerPort;
    metrics: MetricRecorderPort;
    /** Prefix for metric names. Defaults to 'throttle'. Callers should set a package-specific prefix. */
    metricPrefix?: string;
    /** Tags merged into every emitted metric. Use this for cross-cutting dimensions (e.g. `nodeType`). */
    baseMetricTags?: Record<string, string>;
    shadowMode?: boolean;
    failOpen?: boolean;
    clock?: () => number;
}

interface MatchResult {
    rule: CompiledRule;
    bucketKey: string;
}

// ---------- Pure helpers ----------

export function globToRegex(pattern: string): RegExp {
    const escaped = pattern.replace(/[.+^${}()|[\]\\?]/g, '\\$&').replace(/\*/g, '(.*)');
    return new RegExp(`^${escaped}$`);
}

export function parseRules(yamlString: string): { rules: ThrottleRule[]; errors: string[] } {
    const errors: string[] = [];

    let raw: unknown;
    try {
        raw = YAML.parse(yamlString);
    } catch (e) {
        const message = e instanceof Error ? e.message : String(e);
        return { errors: [`YAML parse error: ${message}`], rules: [] };
    }

    if (raw === null || raw === undefined) {
        return { errors: [], rules: [] };
    }

    if (!Array.isArray(raw)) {
        return { errors: ['Throttle rules file must be a YAML array of rules'], rules: [] };
    }

    const rules: ThrottleRule[] = [];
    for (const [i, entry] of raw.entries()) {
        const result = ThrottleRuleSchema.safeParse(entry);
        if (result.success) {
            rules.push(result.data);
        } else {
            const issues = result.error.issues.map((iss) => `${iss.path.join('.') || '<root>'}: ${iss.message}`).join(', ');
            errors.push(`Rule #${i + 1} invalid (${issues}), skipped`);
        }
    }

    return { errors, rules };
}

export function compileRules(rules: ThrottleRule[]): CompiledRule[] {
    return rules.map((rule) => ({
        bucketKeyTemplate: rule.bucketKey ?? null,
        burst: rule.burst,
        isCatchAll: /^\*+(:\*+)*$/.test(rule.pattern),
        pattern: rule.pattern,
        refill: rule.refill,
        regex: globToRegex(rule.pattern),
    }));
}

// ---------- Rule loaders ----------

export interface ThrottleRuleLoader {
    load(): CompiledRule[];
}

export class FileThrottleRuleLoader implements ThrottleRuleLoader {
    constructor(
        private readonly filePath: string,
        private readonly logger: LoggerPort,
    ) {}

    load(): CompiledRule[] {
        let yamlString: string;
        try {
            yamlString = fs.readFileSync(this.filePath, 'utf8');
        } catch (err) {
            this.logger.error({ err, filePath: this.filePath }, 'Throttle: failed to read rules file');
            return [];
        }

        const { errors, rules: rawRules } = parseRules(yamlString);
        for (const error of errors) {
            this.logger.warn({ error }, 'Throttle: rule parse warning');
        }

        if (rawRules.length === 0) {
            this.logger.warn({ filePath: this.filePath }, 'Throttle: no valid rules loaded');
            return [];
        }

        const rules = compileRules(rawRules);
        const hasCatchAll = rules.some((r) => r.isCatchAll);
        if (!hasCatchAll) {
            this.logger.warn('Throttle: no catch-all rule found, requests without match will pass through');
        }

        this.logger.info({ filePath: this.filePath, rulesCount: rules.length }, 'Throttle: rules loaded');
        return rules;
    }
}

export function resolveBucketKey(template: string | null, match: RegExpExecArray): string {
    if (template === null) {
        return match[0];
    }
    return template.replace(/\{(\d+)\}/g, (_, idx: string) => {
        const i = parseInt(idx, 10);
        return match[i + 1] ?? '';
    });
}

export function matchRule(signatures: string[], rules: CompiledRule[]): MatchResult | null {
    // Try shorter signatures first. A pattern like "*:*" with capture groups
    // intended to mean "instanceId:userId" must match the 2-segment signature
    // "instanceId:userId" rather than greedily capturing over a 3-segment one
    // like "instanceId:userId:path" and producing a bucket-per-path.
    const sortedSignatures = [...signatures].sort((a, b) => a.length - b.length);
    for (const rule of rules) {
        for (const sig of sortedSignatures) {
            const match = rule.regex.exec(sig);
            if (match !== null) {
                return { bucketKey: resolveBucketKey(rule.bucketKeyTemplate, match), rule };
            }
        }
    }
    return null;
}

// ---------- Token bucket Lua script ----------

// Returns [allowed (1|0), tokens_remaining, retry_after_seconds, reset_seconds]
const TOKEN_BUCKET_SCRIPT = `
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])

local data = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(data[1]) or capacity
local last_refill = tonumber(data[2]) or now

local elapsed = math.max(0, now - last_refill)
local new_tokens = math.min(capacity, tokens + elapsed * refill_rate)

local ttl = math.ceil(capacity / refill_rate) + 1

if new_tokens < requested then
  redis.call('HSET', key, 'tokens', new_tokens, 'last_refill', now)
  redis.call('EXPIRE', key, ttl)
  local retry_after = math.ceil((requested - new_tokens) / refill_rate)
  local reset = math.ceil((capacity - new_tokens) / refill_rate)
  return {0, tostring(new_tokens), retry_after, reset}
end

new_tokens = new_tokens - requested
redis.call('HSET', key, 'tokens', new_tokens, 'last_refill', now)
redis.call('EXPIRE', key, ttl)
local reset = math.ceil((capacity - new_tokens) / refill_rate)
return {1, tostring(new_tokens), 0, reset}
`;

interface BucketResult {
    allowed: boolean;
    remaining: number;
    retryAfterSeconds: number;
    resetSeconds: number;
}

async function consumeToken(redis: Redis, key: string, burst: number, refill: number, nowMs: number): Promise<BucketResult> {
    const nowSeconds = nowMs / 1000;
    const result = (await redis.eval(TOKEN_BUCKET_SCRIPT, 1, key, String(burst), String(refill), String(nowSeconds), '1')) as [
        number,
        string,
        number,
        number,
    ];
    return {
        allowed: result[0] === 1,
        remaining: Math.floor(parseFloat(result[1])),
        resetSeconds: result[3],
        retryAfterSeconds: result[2],
    };
}

// ---------- Middleware factory ----------

// Hard-block rules have no meaningful retry window (will only succeed once config changes).
// RFC 6585 recommends a Retry-After header on 429, so we emit a conservative large value.
const HARD_BLOCK_RETRY_AFTER_SECONDS = 86400;

export function createThrottleMiddleware(opts: ThrottleOptions): RequestHandler {
    const { redis, loader, requestSignature, logger, metrics } = opts;
    const rules = loader.load();
    const shadowMode = opts.shadowMode ?? false;
    const failOpen = opts.failOpen ?? true;
    const clock = opts.clock ?? Date.now;
    const prefix = opts.metricPrefix ?? 'throttle';
    const metricAllowed = `${prefix}.allowed`;
    const metricRejected = `${prefix}.rejected`;
    const metricNoMatch = `${prefix}.no_match`;
    const metricRedisError = `${prefix}.redis_error`;
    const baseMetricTags = opts.baseMetricTags ?? {};
    const tagsWith = (extra: Record<string, string> = {}): Record<string, string> => ({ ...baseMetricTags, ...extra });

    return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
        let signatures: string[];
        try {
            signatures = requestSignature(req);
        } catch (err) {
            logger.warn({ err }, 'Throttle: requestSignature threw, letting request through');
            next();
            return;
        }

        const matched = matchRule(signatures, rules);

        if (matched === null) {
            metrics.increment(metricNoMatch, tagsWith());
            next();
            return;
        }

        const { rule, bucketKey } = matched;

        // Fast path: hard block (no Redis roundtrip)
        if (rule.burst === 0 && rule.refill === 0) {
            metrics.increment(metricRejected, tagsWith({ rule: rule.pattern, shadow: String(shadowMode) }));
            logger.warn({ bucketKey, rule: rule.pattern, shadowMode, signatures }, 'Throttle: hard-blocked rule matched');
            if (shadowMode) {
                next();
                return;
            }
            res.setHeader('RateLimit-Limit', '0');
            res.setHeader('RateLimit-Remaining', '0');
            res.setHeader('Retry-After', String(HARD_BLOCK_RETRY_AFTER_SECONDS));
            next(new TooManyRequestsError());
            return;
        }

        let result: BucketResult;
        try {
            result = await consumeToken(redis, bucketKey, rule.burst, rule.refill, clock());
        } catch (err) {
            metrics.increment(metricRedisError, tagsWith());
            logger.warn({ bucketKey, err, rule: rule.pattern, signatures }, 'Throttle: Redis error, fail-open behavior');
            if (failOpen) {
                next();
                return;
            }
            next(new TooManyRequestsError());
            return;
        }

        res.setHeader('RateLimit-Limit', String(rule.burst));
        res.setHeader('RateLimit-Remaining', String(Math.max(0, result.remaining)));
        res.setHeader('RateLimit-Reset', String(result.resetSeconds));

        if (result.allowed) {
            metrics.increment(metricAllowed, tagsWith({ rule: rule.pattern }));
            next();
            return;
        }

        metrics.increment(metricRejected, tagsWith({ rule: rule.pattern, shadow: String(shadowMode) }));
        logger.warn({ bucketKey, retryAfter: result.retryAfterSeconds, rule: rule.pattern, shadowMode, signatures }, 'Throttle: request rejected');

        if (shadowMode) {
            next();
            return;
        }

        res.setHeader('Retry-After', String(result.retryAfterSeconds));
        next(new TooManyRequestsError());
    };
}
