import collections
import os
import re
from datetime import timedelta
from pathlib import Path
from typing import Collection, List, Optional

import chalk._gen.chalk.artifacts.v1.export_pb2 as export_pb
from chalk._gen.chalk.artifacts.v1 import chart_pb2 as chart_pb
from chalk._gen.chalk.artifacts.v1.cdc_pb2 import CDCSource, CDCTableReference
from chalk._gen.chalk.artifacts.v1.cron_query_pb2 import CronQuery, RecomputeSettings
from chalk._lsp.error_builder import LSPErrorBuilder
from chalk._monitoring.Chart import Chart as _Chart
from chalk._monitoring.proto_conversion import convert_chart
from chalk._version import __version__
from chalk.config.project_config import ProjectSettings, load_project_config
from chalk.features import FeatureSetBase
from chalk.features.resolver import RESOLVER_REGISTRY
from chalk.features.scheduled_query import CRON_QUERY_REGISTRY
from chalk.importer import CHALK_IMPORTER, import_all_files
from chalk.parsed._proto.lsp import convert_lsp_gql_to_proto
from chalk.parsed._proto.utils import (
    build_failed_import,
    convert_failed_import_to_gql,
    convert_failed_import_to_proto,
    datetime_to_proto_timestamp,
)
from chalk.parsed._proto.validation import validate_artifacts
from chalk.parsed.to_proto import ToProtoConverter
from chalk.parsed.user_types_to_json import get_lsp_gql
from chalk.sql._internal.sql_source import BaseSQLSource, TableIngestMixIn
from chalk.streams import StreamSource
from chalk.utils.duration import timedelta_to_duration


def project_settings_to_proto(config: ProjectSettings) -> export_pb.ProjectSettings:
    def read_packages(filename: str) -> Optional[List[str]]:
        reqs = list()
        try:
            with open(filename) as f:
                for r in f.readlines():
                    cleaned = re.sub("#.*", "", r).rstrip("\n").strip()
                    if cleaned != "":
                        reqs.append(cleaned)
            return reqs
        except OSError:
            return None

    environments: List[export_pb.EnvironmentSettings] = []
    if config.environments is not None:
        environments = [
            export_pb.EnvironmentSettings(
                id=env_id,
                runtime=e.runtime,
                requirements=e.requirements,
                dockerfile=e.dockerfile,
                requires_packages=(
                    None
                    if e.requirements is None
                    else read_packages(
                        os.path.join(
                            os.path.dirname(config.local_path),
                            e.requirements,
                        )
                    )
                ),
            )
            for env_id, e in config.environments.items()
        ]

    validation: Optional[export_pb.ValidationSettings] = None
    if config.validation:
        feature_settings: Optional[export_pb.FeatureSettings] = None
        if config.validation.feature:
            metadata_settings = (
                [export_pb.MetadataSettings(name=m.name, missing=m.missing) for m in config.validation.feature.metadata]
                if config.validation.feature.metadata
                else None
            )
            feature_settings = export_pb.FeatureSettings(metadata=metadata_settings)

        resolver_settings: Optional[export_pb.ResolverSettings] = None
        if config.validation.resolver:
            metadata_settings = (
                [
                    export_pb.MetadataSettings(name=m.name, missing=m.missing)
                    for m in config.validation.resolver.metadata
                ]
                if config.validation.resolver.metadata
                else None
            )
            resolver_settings = export_pb.ResolverSettings(metadata=metadata_settings)

        validation = export_pb.ValidationSettings(
            feature=feature_settings,
            resolver=resolver_settings,
        )

    return export_pb.ProjectSettings(
        project=config.project,
        environments=environments,
        validation=validation,
    )


def export_to_proto(directory: Optional[str], file_allowlist: Optional[List[str]] = None) -> export_pb.Export:
    failed_protos: List[export_pb.FailedImport] = []

    if directory is not None:
        os.chdir(directory)
    failed_imports_gql = import_all_files(
        file_allowlist=file_allowlist,
        project_root=None if directory is None else Path(directory),
    )
    failed_protos.extend([convert_failed_import_to_proto(f) for f in failed_imports_gql])

    graph_res = ToProtoConverter.convert_graph(
        features_registry=FeatureSetBase.registry,
        resolver_registry=RESOLVER_REGISTRY.get_all_resolvers(),
        sql_source_registry=BaseSQLSource.registry,
        stream_source_registry=StreamSource.registry,
    )
    if graph_res.errors:
        failed_protos.extend(graph_res.errors)

    crons: List[CronQuery] = []
    for cron in CRON_QUERY_REGISTRY.values():
        if not isinstance(cron.recompute_features, (Collection, bool)):
            failed_protos.append(
                build_failed_import(
                    TypeError(f"Invalid `recompute_features` type '{type(cron.recompute_features).__name__}'"),
                    f"cron '{cron.name}'",
                )
            )
            continue
        crons.append(
            CronQuery(
                name=cron.name,
                cron=timedelta_to_duration(cron.cron) if isinstance(cron.cron, timedelta) else cron.cron,
                output=[str(f) for f in cron.output],
                max_samples=cron.max_samples,
                recompute=RecomputeSettings(
                    feature_fqns=list(cron.recompute_features)
                    if isinstance(cron.recompute_features, Collection)
                    else None,
                    all_features=cron.recompute_features  # pyright: ignore[reportArgumentType]
                    if isinstance(cron.recompute_features, bool)
                    else None,
                ),
                lower_bound=datetime_to_proto_timestamp(cron.lower_bound)
                if cron.lower_bound is not None
                else cron.lower_bound,
                upper_bound=datetime_to_proto_timestamp(cron.upper_bound)
                if cron.upper_bound is not None
                else cron.upper_bound,
                tags=cron.tags,
                required_resolver_tags=cron.required_resolver_tags,
                store_online=cron.store_online,
                store_offline=cron.store_offline,
                file_name=cron.filename,
            )
        )

    charts: List[chart_pb.Chart] = []
    for chart in _Chart.registry:
        try:
            charts.append(convert_chart(chart))
        except Exception as e:
            failed_protos.append(build_failed_import(e, f"chart ' {chart.name}'"))

    integration_name_to_tables = collections.defaultdict(list)
    for source in BaseSQLSource.registry:
        if isinstance(source, TableIngestMixIn):
            for schema_dot_table, preferences in source.ingested_tables.items():
                if preferences.cdc is True:
                    assert isinstance(source, BaseSQLSource)
                    parts = schema_dot_table.split(".")
                    if len(parts) != 2:
                        build_failed_import(
                            f"Expected {{schema}}.{{table}}, got {schema_dot_table}", f"Database source '{source.name}'"
                        )
                        continue
                    integration_name_to_tables[source.name].append(CDCTableReference(schema=parts[0], name=parts[1]))
    cdc_sources = [
        CDCSource(
            integration_name=integration_name,
            tables=tables,
        )
        for integration_name, tables in integration_name_to_tables.items()
    ]

    config = load_project_config()
    if config is not None:
        config = project_settings_to_proto(config)

    # Has side effect of populating LSPErrorBuilder
    logs = validate_artifacts(graph_res.graph, config)

    lsp = get_lsp_gql()
    if LSPErrorBuilder.lsp:
        failed_gql = [convert_failed_import_to_gql(f) for f in failed_protos]
        lsp.diagnostics = CHALK_IMPORTER.supplement_diagnostics(failed_imports=failed_gql, diagnostics=lsp.diagnostics)

    return export_pb.Export(
        graph=graph_res.graph,
        crons=crons,
        charts=charts,
        chalkpy=export_pb.ChalkpyInfo(version=__version__),
        cdc_sources=cdc_sources,
        config=config,
        logs=logs,
        lsp=convert_lsp_gql_to_proto(lsp),
        failed=failed_protos,
    )
