import Database, { Database as DB, Statement } from 'better-sqlite3';
import sharp, { FormatEnum, Metadata, Sharp } from 'sharp';
import * as fs from 'fs';
import * as https from 'https';

// Define the image format map
const imageFormatMap: Record<string, keyof FormatEnum> = {
    jpg: 'jpeg',
    jpeg: 'jpeg',
    png: 'png',
    webp: 'webp',
    tiff: 'tiff',
    gif: 'gif'
};

// Define the MIME type map
const mimeTypeMap: Record<string, string> = {
    jpg: 'image/jpeg',
    jpeg: 'image/jpeg',
    png: 'image/png',
    webp: 'image/webp',
    tiff: 'image/tiff',
    gif: 'image/gif'
};

// Database result for hits
interface HitResult {
    hits: number;
}

// RGB color type
interface RGBColor {
    r: number;
    g: number;
    b: number;
}

// Allows use of base class and subclass as a valid type
export interface Type<T> extends Function { new (...args: any[]): T; }

// Hit counter renderer interface
interface HitCounterRenderer {
    render(): Promise<any>;
}

// Hit counter configuration
interface HitCounterConfig {
    siteId: number;
    // path to the database file
    counterDB: string;
    // Background image URL for the counter display
    backgroundImageUrl: string;
    // local image path
    imageFile: string;
    backgroundCacheFile: string;
    // Text settings to be displayed on the image
    customText: string; // Main headline text
    secondaryText: string; // Secondary descriptive text
    textPositionX: number; // X position of the text
    textPositionY: number; // Y position of the text
    numberPositionX: number; // X position of the number
    numberPositionY: number; // Y position of the number
    fontSize: number; // Font size
    fontFace: string; // Font face
    borderWidth: number; // Border width
    textColorRGB: RGBColor; // Text color
    secondaryTextColorRGB: RGBColor; // Secondary text color
    numberColorRGB: RGBColor; // Number color
    frameColorRGB: RGBColor; // Frame color
    drawFrame: boolean; // Whether to draw a frame around the counter
}

// Default configuration for the hit counter
const defaultHitCounterConfig: HitCounterConfig = {
    siteId: 1,
    counterDB: './hits.db',
    backgroundImageUrl: 'https://datakra.sh/assets/example_counter.jpg',
    imageFile: './example_counter.png',
    backgroundCacheFile: './example_counter_bg.png',
    customText: 'Hit Counter!',
    secondaryText: 'Super cyber, super cool!',
    textPositionX: 5,
    textPositionY: 11,
    numberPositionX: 170,
    numberPositionY: 18,
    fontSize: 10,
    fontFace: 'fixed',
    borderWidth: 3,
    textColorRGB: { r: 253, g: 252, b: 1 },
    secondaryTextColorRGB: { r: 0, g: 255, b: 0 },
    numberColorRGB: { r: 255, g: 255, b: 255 },
    frameColorRGB: { r: 255, g: 0, b: 0 },
    drawFrame: true,
};


/**
 * Extract the file extension from a file path.
 * 
 * @param {String} file
 * 
 * @returns {String}
 * @throws {Error} if the file extension is invalid
 */
function fileExt(file: string): string {
    const ext = file.split('.').pop();
    if (!ext) throw new Error('Invalid file extension');
    return ext.toLowerCase();
}

/**
 * Convert a file extension to a Sharp image format.
 * 
 * @param {String} ext
 * 
 * @returns {String}
 * @throws {Error} if the file extension is not supported
 */
function extToFormat(ext: string): keyof FormatEnum {
    const format = imageFormatMap[ext];
    if (!format) throw new Error('Unsupported file extension');
    return format;
}

/**
 * Check if a path is a web URI.
 * 
 * @param {String|URL} path
 * The path to check.
 * 
 * @returns {Boolean}
 * Whether the path is a web URI.
 */
function isWebURI(path: string | URL): boolean {
    try {
        const url = new URL(path.toString());
        return url.protocol === 'http:' || url.protocol === 'https:';
    } catch {
        return false;
    }
}

/**
 * Base class for hit counter renderers.
 * 
 * @class BaseHitCounterRenderer
 * @implements {HitCounterRenderer}
 */
class BaseHitCounterRenderer implements HitCounterRenderer {
    protected counter: HitCounter;
    protected config: HitCounterConfig;

    /**
     * Creates an instance of BaseHitCounterRenderer.
     * 
     * @param {HitCounter} counter
     *  The hit counter instance.
     * @param {HitCounterConfig} config
     *  The hit counter configuration.
     */
    constructor(counter: HitCounter, config: HitCounterConfig) {
        this.counter = counter;
        this.config = config;
    }

    /**
     * Render the hit counter.
     */
    async render(): Promise<any> {
        throw new Error('Render not implemented');
    }
}

/**
 * Image hit counter renderer.
 * 
 * @class ImageHitCounterRenderer
 * @extends {BaseHitCounterRenderer}
 */
class ImageHitCounterRenderer extends BaseHitCounterRenderer {
    private imageFormat: keyof FormatEnum;
    private baseImage: Buffer;
    private baseImageMetadata: Metadata;

    /**
     * Creates an instance of ImageHitCounterRenderer.
     * 
     * @param {HitCounter} counter
     * The hit counter instance.
     * @param {HitCounterConfig} config
     * The hit counter configuration.
     */
    constructor(counter: HitCounter, config: HitCounterConfig) {
        super(counter, config);
        this.imageFormat = extToFormat(fileExt(this.config.imageFile));
    }

    /**
     * Download the background image.
     *
     * @returns {Promise<Buffer>}
     * The downloaded image.
     */
    private async downloadImage(): Promise<Buffer> {
        return new Promise((resolve, reject) => {
            let buf = Buffer.alloc(0);
            https.get(this.config.backgroundImageUrl, (response) => {
                response.on('data', (chunk) => (buf = Buffer.concat([buf, chunk])));
                response.on('end', () => resolve(buf));
            }).on('error', reject);
        });
    }

    /**
     * Load or cache the background image.
     *
     * @param {Boolean} [overwrite=false]
     * Whether to overwrite the cached background image.
     *
     * @returns {Promise<Sharp>}
     * The loaded or cached background image.
     */
    private async loadOrCacheBG(overwrite = false): Promise<Sharp> {
        if (fs.existsSync(this.config.backgroundCacheFile) && !overwrite) {
            return sharp(this.config.backgroundCacheFile);
        }

        const imageBuffer = isWebURI(this.config.backgroundImageUrl)
            ? await this.downloadImage()
            : fs.readFileSync(this.config.backgroundImageUrl);

        const image = sharp(imageBuffer).toFormat(this.imageFormat);
        await image.toFile(this.config.backgroundCacheFile);
        return image;
    }

    /**
     * Get the base image.
     *
     * @returns {Promise<Buffer>}
     * The base image.
     */
    private async getBaseImage(): Promise<Buffer> {
        if (typeof this.baseImage !== 'undefined') {
            return this.baseImage;
        }
        const bgImage = await this.loadOrCacheBG();
        const bgMetadata = await bgImage.metadata();

        const svgOverlay = this.createSvgOverlay(bgMetadata.width!, bgMetadata.height!);
        const compositeImage = bgImage.composite([{ input: svgOverlay, top: 0, left: 0, density: bgMetadata.density! }]).withMetadata();
        this.baseImage = await compositeImage.toBuffer();
        this.baseImageMetadata = await compositeImage.metadata();

        return this.baseImage;
    }

    /**
     * Create an SVG overlay.
     *
     * @param {Number} width
     * The width of the overlay.
     * @param {Number} height
     * The height of the overlay.
     *
     * @returns {Buffer}
     * The SVG overlay.
     */
    private createSvgOverlay(width: number, height: number): Buffer {
        const { textPositionX, textPositionY, fontFace, fontSize, customText, secondaryText, textColorRGB, secondaryTextColorRGB, frameColorRGB, drawFrame, borderWidth } = this.config;
        const frame = drawFrame
            ? `<rect x="0" y="0" width="100%" height="100%" fill="none" stroke="rgb(${frameColorRGB.r},${frameColorRGB.g},${frameColorRGB.b})" stroke-width="${borderWidth}" />`
            : '';

        const svg = `
            <svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
                <text y="${textPositionY}" font-family="${fontFace}" font-size="${fontSize}">
                    <tspan x="${textPositionX}" dy="0" fill="rgb(${textColorRGB.r},${textColorRGB.g},${textColorRGB.b})">${customText}</tspan>
                    <tspan x="${textPositionX}" dy="1.2em" fill="rgb(${secondaryTextColorRGB.r},${secondaryTextColorRGB.g},${secondaryTextColorRGB.b})">${secondaryText}</tspan>
                </text>
                ${frame}
            </svg>`;
        return Buffer.from(svg);
    }

    /**
     * Create an SVG number overlay.
     *
     * @param {Number} hits
     * The number of hits.
     * @param {Number} width
     * The width of the image.
     * @param {Number} height
     * The height of the image.
     *
     * @returns {Buffer}
     * The SVG number overlay.
     */
    private createSVGNumberOverlay(hits: number, width: number, height: number): Buffer {
        const { numberPositionX, numberPositionY, fontFace, fontSize, numberColorRGB } = this.config;
        const svg = `
            <svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
                <text x="${numberPositionX}" y="${numberPositionY}" text-anchor="end" font-family="${fontFace}" font-size="${fontSize}" fill="rgb(${numberColorRGB.r},${numberColorRGB.g},${numberColorRGB.b})">${hits}</text>
            </svg>`;
        return Buffer.from(svg);
    }

    /**
     * Render the hit counter.
     *
     * @returns {Promise<Buffer>}
     * The rendered hit counter.
     */
    async render(): Promise<Buffer> {
        const hits = await this.counter.getHits();
        return await sharp(await this.getBaseImage())
            .composite([
                {
                    input: this.createSVGNumberOverlay(hits, this.baseImageMetadata.width!, this.baseImageMetadata.height!),
                    top: 0,
                    left: 0
                }
            ])
            .toFormat(this.imageFormat)
            .toBuffer();
    }
}

/**
 * Hit counter class.
 * 
 * @class HitCounter
 */
class HitCounter {
    private db: DB;
    private renderer: BaseHitCounterRenderer;
    private preparedStatements: Record<string, Statement>;
    private mimeType: string;
    private config: HitCounterConfig;

    /**
     * Creates an instance of HitCounter.
     * 
     * @param {HitCounterConfig} [config=defaultHitCounterConfig]
     * The hit counter configuration. Defaults to defaultHitCounterConfig.
     * @param {Type<BaseHitCounterRenderer>} renderer
     * The hit counter renderer. Defaults to ImageHitCounterRenderer.
     */
    constructor(config: HitCounterConfig = defaultHitCounterConfig, renderer: Type<BaseHitCounterRenderer> = ImageHitCounterRenderer) {
        config = { ...defaultHitCounterConfig, ...config };
        this.config = config;
        this.renderer = new renderer(this, config);
        this.mimeType = mimeTypeMap[fileExt(config.imageFile)];
        this.db = this.initDB(config.counterDB);
        this.preparedStatements = this.prepareStatements();
    }

    /**
     * Initialize the database connection.
     * 
     * @param {String} filePath
     * The path to the database file.
     * 
     * @returns {DB}
     * The database instance.
     */
    private initDB(filePath: string): DB {
        const db = new Database(filePath, {fileMustExist: false});
        db.pragma('locking_mode = EXCLUSIVE');
        db.pragma('journal_mode = WAL');
        db.pragma('synchronous = OFF');
        return db;
    }

    /**
     * Prepare the SQL statements.
     * 
     * @returns {Record<string, Statement>}
     * The prepared statements.
     */
    private prepareStatements(): Record<string, Statement> {
        this.db.exec(`
            CREATE TABLE IF NOT EXISTS hit_counter (
                id INTEGER PRIMARY KEY,
                hits INTEGER NOT NULL DEFAULT 0
            );
        `);

        this.db.exec(`
            INSERT OR IGNORE INTO hit_counter (id, hits)
            VALUES (${this.config.siteId}, 0)
        `);

        return {
            get: this.db.prepare('SELECT hits FROM hit_counter WHERE id = ?'),
            increment: this.db.prepare('UPDATE hit_counter SET hits = hits + 1 WHERE id = ?'),
            reset: this.db.prepare('UPDATE hit_counter SET hits = 0 WHERE id = ?'),
        };
    }

    /**
     * Get the number of hits.
     * 
     * @returns {Promise<Number>}
     * The number of hits
     */
    async getHits(): Promise<number> {
        const result = this.preparedStatements.get.get(this.config.siteId) as HitResult;
        return result.hits;
    }

    /**
     * Increment the hit counter.
     */
    async increment(): Promise<void> {
        this.preparedStatements.increment.run(this.config.siteId);
    }

    /**
     * Reset the hit counter
     */
    async reset(): Promise<void> {
        this.preparedStatements.reset.run(this.config.siteId);
    }

    /**
     * Render the hit counter.
     * 
     * @returns {Promise<any>}
     * The rendered hit counter.
     */
    async render(): Promise<any> {
        return this.renderer.render();
    }

    /**
     * Get the MIME type of the hit counter image.
     * 
     * @returns {String}
     * The MIME type.
     */
    getMimeType(): string {
        return this.mimeType;
    }

    /**
     * Destructor.
     */
    finalize(): void {
        this.db.close();
    }
}

export {
    HitCounter,
    HitCounterConfig,
    HitCounterRenderer,
    BaseHitCounterRenderer,
    ImageHitCounterRenderer
};
export default HitCounter;
