'use strict';

const crypto = require("crypto");

const conf = require('@simpleworkjs/conf');
const Table = require('.');
const ModelPs = require('../utils/model_pubsub');

const tldExtract = require('tld-extract').parse_host;
const {planARecordUpdate} = require('../utils/dns_records');

const providers = {
	Cloudflare: require('./dns_provider/cloudflare'),
	DigitalOcean: require('./dns_provider/digitalocean'),
	PorkBun: require('./dns_provider/porkbun'),
	DuckDns: require('./dns_provider/duckdns'),
};

class Domain extends Table{
	static _key = 'domain';
	static _keyMap = {
		'created_by': {isRequired: true, type: 'string', min: 3, max: 500},
		'created_on': {default: function(){return (new Date).getTime()}},
		'updated_by': {default:"__NONE__", isRequired: false, type: 'string',},
		'updated_on': {default: function(){return (new Date).getTime()}, always: true},
		'domain': {isRequired: true, type: 'string'},
		'dnsProvider_id': {isRequired: true, type: 'string'},
		'provider': {model: 'DnsProvider', rel:'one', localKey: 'dnsProvider_id'},
		'zoneId': {isRequired: false, type: 'string'},
	}

	// Try the exact string first — Domain.create() stores records under the
	// literal domain it's given (see model-redis' Table.create, which HSETs
	// under data[this._key] verbatim), and its own final `this.get(...)` to
	// return the created instance must find that same literal key. Falling
	// straight to tldExtract normalization here breaks that for any domain
	// tldExtract doesn't recognize as a shared/public suffix — e.g. a DuckDNS
	// name like "myhost.duckdns.org" normalizes to "duckdns.org" (tldExtract
	// has no idea duckdns.org is shared across many independent registrants),
	// so create() would always throw EntryNotFound on its own read-back
	// despite the record having just been written successfully.
	// Only fall back to the normalized parent domain if no exact record
	// exists — that's what lets callers look up an arbitrary hostname (e.g.
	// "www.example.com") and find the Domain that governs it (example.com).
	static async get(domain, ...args){
		try{
			return await super.get(domain, ...args);
		}catch(error){
			if(error.name !== 'EntryNotFound') throw error;

			let normalized;
			try{
				normalized = tldExtract(domain).domain;
			}catch{
				throw error;
			}
			if(normalized === domain) throw error;

			return await super.get(normalized, ...args);
		}
	}

	async getRecords(...args){
		return await this.provider.api.getRecords(this, ...args);
	}

	async createRecord(...args){
		return await this.provider.api.createRecord(this, ...args);
	}

	async deleteRecords(...args){
		return await this.provider.api.deleteRecords(this, ...args);
	}

	/**
	 * Provider-agnostic upsert of a single A record to `ip`. Providers'
	 * createRecord is not a reliable cross-provider upsert (CloudFlare returns the
	 * stale record on a duplicate, DigitalOcean duplicates, CloudFlare's
	 * deleteRecords is a no-op), but all three expose getRecords + deleteRecordById,
	 * so reconcile explicitly. `name` is a sub-label or '@' for apex.
	 */
	async upsertARecord(name, ip){
		let sub = (name === '@' || name === '') ? '' : name;
		let aRecords = await this.getRecords({type: 'A'});
		let {deleteIds, create} = planARecordUpdate(aRecords, sub, ip);

		for(let id of deleteIds){
			await this.provider.api.deleteRecordById(this, id);
		}
		if(create){
			await this.createRecord({type: 'A', name: (sub === '' ? '@' : sub), data: ip});
		}

		return {ip, changed: create || deleteIds.length > 0};
	}
}

Domain.register(ModelPs(Domain));

class DnsProvider extends Table{
	static _key = 'id';
	static _keyMap = {
		'created_by': {isRequired: true, type: 'string', min: 3, max: 500},
		'created_on': {default: function(){return (new Date).getTime()}},
		'updated_by': {default:"__NONE__", isRequired: false, type: 'string',},
		'updated_on': {default: function(){return (new Date).getTime()}, always: true},
		'id': {default: ()=>crypto.randomBytes(8).toString("hex")},
		'name': {isRequired: true, type: 'string'},
		'dnsProvider': {isRequired: true, type: 'string'},
		'domains': {model:'Domain', rel: 'many', remoteKey: 'dnsProvider_id'}
	}

	static __intraModel(provider){
		if(!Object.keys(providers).includes(provider)){
			throw new Error('Invalid DNS provider');
		}

		let Provider = providers[provider];
		// Provider._keyMap is spread last, so a provider-defined field with the
		// same name as one of ours (created_by, updated_by, name, dnsProvider,
		// domains, id — see this._keyMap above) silently overwrites it. This bit
		// a DuckDNS field named `domains`: it replaced the `domains` relation
		// (rel: 'many' to Domain), so `this.domains` stopped being an array and
		// `updateDomains()` broke with "this.domains.map is not a function".
		// New providers must avoid these names.
		let _keyMap = {...this._keyMap, ...Provider._keyMap};

		return ({
			[this.name] : class extends this {
				static _keyMap = _keyMap;
				static Provider = Provider;
			}
		})[this.name];
	}

	static async create(data, ...args){
		let Provider;
		try{
			let __intraModel = this.__intraModel(data.dnsProvider);
			Provider = __intraModel.Provider;

			// This is here test if the given API key is valid
			let provider = new __intraModel.Provider(data, ...args);
			let domains = await provider.listDomains();

			let instance = await super.create.call(__intraModel, data, ...args);
			try{
				await instance.updateDomains(domains);
			}catch(updateError){
				// Don't leave a half-created provider behind (e.g. a Domain that
				// collided with a stale/orphaned record from a previous failed
				// attempt — see updateDomains' own comment on zoneId omission for
				// another case this can throw). Re-fetch before removing: `instance`
				// had its `domains` relation resolved by super.create()'s own
				// internal get() BEFORE updateDomains() created any Domain rows, so
				// it's permanently stale (always empty) — removing `instance`
				// directly would delete the provider but silently leave behind
				// whatever domains updateDomains() managed to create before failing,
				// which is exactly the kind of orphan this is meant to prevent.
				try{
					await (await this.get(instance.id)).remove();
				}catch(removeError){
					console.error('DnsProvider create: failed to roll back', instance.id, 'after updateDomains error:', removeError.message);
				}
				throw updateError;
			}

			// Same staleness issue as above: re-fetch so the returned instance
			// (and the API response built from it) reflects the domains
			// updateDomains() actually just created, not the empty snapshot from
			// before it ran.
			return await this.get(instance.id);
		}catch(error){
			if(error.name === 'UnauthorizedDnsApi'){
				let keys = [];
				for(let key in Provider._keyMap){
					keys.push({'key': key, message: 'Invalid Key'})
				}
				throw new this.errors.ObjectValidateError(keys, "API rejected key");
			}
			// Don't swallow other failures (e.g. a domain-sync validation error):
			// returning undefined here made the route crash on `item.id` with an
			// opaque message. Surface the real error to the caller instead.
			throw error;
		}
	}

	static async get(data, ...args){
		let instance = await super.get(data, ...args);
		let __intraModel = this.__intraModel(instance.dnsProvider);

		return await super.get.call(__intraModel, data, ...args);
	}

	static listProviders(){
		let out = [];
		for(let provider in providers){
			out.push({
				name: provider,
				fields: providers[provider]._keyMap,
			});
		}
		return out;
	}

	// Re-sync every configured provider's domain list from its API. Mirrors the
	// manual /dns/domain/refresh/:item route (get -> updateDomains) across all
	// providers; used by the host scheduler. Never throws — one bad provider
	// (e.g. a revoked key) must not abort the rest.
	static async refreshAllDomains(){
		let ids;
		try{
			ids = await this.list();
		}catch(error){
			console.error('refreshAllDomains: could not list providers', error.message);
			return;
		}
		for(let id of ids){
			try{
				let provider = await this.get(id);
				await provider.updateDomains();
			}catch(error){
				console.error('refreshAllDomains: provider', id, error.message);
			}
		}
	}

	get api(){
		return new this.constructor.Provider(this);
	}

	async listDomains(){
		return this.api.listDomains();
	}

	async updateDomains(domains){
		domains = domains || await this.listDomains();
		let currentDomains = this.domains.map(domain => domain.domain);


		for(let domain of domains){
			if(currentDomains.includes(domain.domain)){
				delete currentDomains[currentDomains.indexOf(domain.domain)];
				continue;
			}
			await Domain.create({
				created_by: this.created_by,
				domain: domain.domain,
				dnsProvider_id: this.id,
				// Only providers with a zone concept (e.g. Cloudflare) return a
				// zoneId. Porkbun/DigitalOcean don't, and passing an explicit
				// `undefined` trips model-redis' type check ("zoneId is not string
				// type") and aborts the whole sync — so omit it when absent.
				...(domain.zoneId !== undefined ? {zoneId: domain.zoneId} : {}),
			});
		}

		for(let domain of currentDomains){
			if(!domain) continue
			domain = await Domain.get(domain);
			await domain.remove();
		}
	}

	async remove(){
		for(let domain of await this.domains){
			await domain.remove();
		}
		let instance = await super.remove();

		return instance;
	}

	toJSON(){
		return {
			...super.toJSON(),
			...this.constructor.Provider.toJSON()
		};
	}
}

DnsProvider.register(ModelPs(DnsProvider))


if(require.main === module){(async function(){try{
	const conf = require('@simpleworkjs/conf');

	// console.log(await DnsProvider.findall());

	let provider = await DnsProvider.get('84c6613b9464fbe1');

	// console.log(await provider.listDomains())

	let domain = await Domain.get('holycore.quest') // pork
	// let domain = await Domain.get('rm-rf.stream') // DO
	// let domain = await Domain.get('test.wtf') // CF

	console.log(await domain.createRecord({
		type: 'TXT',
		name: 'apitewefweefwsewft222',
		data: 'sdddddddad'
	}));

	let txtRecords = await domain.getRecords();
	console.log(txtRecords.map(i=>`${i.name}: ${i.data}`))
	// console.log(await domain.deleteRecords({type: 'TXT'}))


}catch(error){
	console.log('IIFE Error:', error);
}finally{
	process.exit(0);
}})()}
